diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1919fac9c5..8cfa7f998b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.14 + rev: v0.15.15 hooks: - id: ruff args: diff --git a/README.md b/README.md index 562c35ff1a..b6a4b836a4 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,33 @@ unsloth studio -p 8888 ``` #### Advanced launch options -Cap Studio's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`. Explicit `OMP_NUM_THREADS` / `MKL_NUM_THREADS` / `OPENBLAS_NUM_THREADS` / `NUMEXPR_NUM_THREADS` still take precedence. +Installer options can be passed as environment variables. On macOS, Linux and WSL place the variable after the pipe so the shell passes it to `sh`; on Windows set it with `$env:` before piping to `iex`. + +Skip PyTorch (GGUF-only mode): +```bash +curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh +``` +```powershell +$env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex +``` + +Pin the Python version: +```bash +curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh +``` +```powershell +$env:UNSLOTH_PYTHON='3.12'; irm https://unsloth.ai/install.ps1 | iex +``` + +Install to a custom location with `UNSLOTH_STUDIO_HOME`: +```bash +curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh +``` +```powershell +$env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex +``` + +Cap Studio's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`. #### Uninstall The recommended way to fully remove Unsloth Studio is the matching uninstall script for your OS. It stops any running servers, removes the install dir, the launcher data dir, the desktop shortcut, and any platform-specific entries (macOS `.app` bundle + Launch Services on Mac; Start Menu, `HKCU\Software\Unsloth` registry key and user `PATH` entries on Windows): diff --git a/install.ps1 b/install.ps1 index d3942bf2fc..eb18e0d426 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,14 +1,20 @@ # Unsloth Studio Installer for Windows PowerShell -# Usage: irm https://raw.githubusercontent.com/unslothai/unsloth/main/install.ps1 | iex -# Local: Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\install.ps1 --local -# NoTorch: .\install.ps1 --no-torch (skip PyTorch, GGUF-only mode) -# Test: .\install.ps1 --package roland-sloth # -# Env vars (priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME > USERPROFILE-redirect > default): -# UNSLOTH_STUDIO_HOME / STUDIO_HOME = path -> install under that path -# (DataDir nests inside; user PATH not modified persistently). -# Default ($USERPROFILE\.unsloth\studio) is preserved when no env var is set. - +# Usage: irm https://unsloth.ai/install.ps1 | iex +# Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\install.ps1 --local +# +# irm | iex cannot forward arguments, so web installs take options as env vars set +# before the pipe (flags still work via .\install.ps1): +# $env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex # skip PyTorch (GGUF-only) +# $env:UNSLOTH_PYTHON='3.12'; irm https://unsloth.ai/install.ps1 | iex # pin Python version +# $env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex +# .\install.ps1 --no-torch # equivalent flag +# Or pass flags to a scriptblock: & ([scriptblock]::Create((irm https://unsloth.ai/install.ps1))) --no-torch +# +# Install dir priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME (alias) > $USERPROFILE\.unsloth\studio +# +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 function Install-UnslothStudio { $ErrorActionPreference = "Stop" $script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq "1") @@ -112,6 +118,10 @@ function Install-UnslothStudio { } } } + + # Env-var equivalent for web installs; an explicit flag still wins. + if ($env:UNSLOTH_NO_TORCH -in @('1', 'true', 'yes', 'on')) { $SkipTorch = $true } + # Propagate to child processes so they also respect verbose mode. # Process-scoped -- does not persist. if ($script:UnslothVerbose) { @@ -132,7 +142,8 @@ function Install-UnslothStudio { return (Exit-InstallFailure "--package name contains invalid characters") } - $PythonVersion = "3.13" + # UNSLOTH_PYTHON pins the version (mirrors install.sh --python); default 3.13. + $PythonVersion = if ($env:UNSLOTH_PYTHON) { $env:UNSLOTH_PYTHON } else { "3.13" } # Resolve install destinations. Priority: UNSLOTH_STUDIO_HOME, then # STUDIO_HOME alias, then USERPROFILE-redirect, then default. @@ -935,7 +946,9 @@ shell.Run cmd, 0, False # py.exe resolves to the standard CPython install, not conda. $pyLauncher = Get-Command py -CommandType Application -ErrorAction SilentlyContinue if ($pyLauncher -and $pyLauncher.Source -notmatch $script:CondaSkipPattern) { - foreach ($minor in @("3.13", "3.12", "3.11")) { + # Prefer the requested $PythonVersion, then newest-first fallback. + $minors = @($PythonVersion) + (@("3.13", "3.12", "3.11") | Where-Object { $_ -ne $PythonVersion }) + foreach ($minor in $minors) { try { $out = & $pyLauncher.Source "-$minor" --version 2>&1 | Out-String if ($out -match "Python (3\.1[1-3])\.\d+") { @@ -1566,7 +1579,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.9" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.10" unsloth-zoo } if ($baseInstallExit -eq 0) { # Resolve pydantic WITH deps so pip pins pydantic-core # to the matching version (no-torch-runtime.txt below @@ -1580,7 +1593,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.9" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.10" unsloth-zoo } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -1627,7 +1640,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.9" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.10" unsloth-zoo } if ($baseInstallExit -eq 0) { # Same pydantic-with-deps trick as the migrated branch. $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic } @@ -1639,7 +1652,7 @@ shell.Run cmd, 0, False } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.9" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.10" unsloth-zoo } } else { $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } } @@ -1667,7 +1680,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.9" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.10" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit) diff --git a/install.sh b/install.sh index 89c506bc9d..1d4d9067c1 100755 --- a/install.sh +++ b/install.sh @@ -1,17 +1,22 @@ #!/bin/sh -# Unsloth Studio Installer -# Usage (curl): curl -fsSL https://unsloth.ai/install.sh | sh -# Usage (wget): wget -qO- https://unsloth.ai/install.sh | sh -# Usage (local): ./install.sh --local (install from local repo instead of PyPI) -# Usage (no-torch): ./install.sh --no-torch (skip PyTorch, GGUF-only mode) -# Usage (test): ./install.sh --package roland-sloth (install a different package name) -# Usage (py): ./install.sh --python 3.12 (override auto-detected Python version) # -# Env vars (priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME > HOME-redirect > default): -# UNSLOTH_STUDIO_HOME=/abs/path -> install under that path -# STUDIO_HOME=/abs/path -> alias, same effect (UNSLOTH_STUDIO_HOME wins) -# (DATA_DIR + unsloth CLI shim nest inside; no shell rc-file append.) -# Default ($HOME/.unsloth/studio) is preserved when no env var is set. +# Unsloth Studio Installer +# +# Usage: curl -fsSL https://unsloth.ai/install.sh | sh +# wget -qO- https://unsloth.ai/install.sh | sh +# ./install.sh --local (install from a cloned repo instead of PyPI) +# +# Piped installs take options as env vars after the pipe (a bare `| sh --no-torch` +# makes sh reject --no-torch as its own option). Flags still work via ./install.sh: +# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh # skip PyTorch (GGUF-only) +# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh # pin Python version +# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh +# Equivalent flags: ./install.sh --no-torch --python 3.12 (or pipe them: sh -s -- --no-torch) +# +# Install dir priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME (alias) > $HOME/.unsloth/studio +# +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 set -e # ── Output style (aligned with studio/setup.sh) ── @@ -70,6 +75,10 @@ for arg in "$@"; do esac done +# Env-var equivalents for piped installs; an explicit flag still wins. +case "${UNSLOTH_NO_TORCH:-}" in 1|true|TRUE|yes|YES|on|ON) _NO_TORCH_FLAG=true ;; esac +[ -z "$_USER_PYTHON" ] && [ -n "${UNSLOTH_PYTHON:-}" ] && _USER_PYTHON="$UNSLOTH_PYTHON" + if [ "$_VERBOSE" = true ]; then export UNSLOTH_VERBOSE=1 fi @@ -2083,7 +2092,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.9" unsloth-zoo + "unsloth>=2026.5.10" unsloth-zoo # Resolve pydantic WITH deps so pip pins pydantic-core to the # matching version (no-torch-runtime.txt below is --no-deps). # All transitive deps are torch-free. @@ -2096,7 +2105,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.9" unsloth-zoo + "unsloth>=2026.5.10" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -2300,7 +2309,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.9" unsloth-zoo + "unsloth>=2026.5.10" unsloth-zoo # Same pydantic-with-deps trick as the migrated branch. run_install_cmd "install pydantic (with deps for compatible core)" \ uv pip install --python "$_VENV_PY" pydantic @@ -2318,7 +2327,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.9" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.5.10" unsloth-zoo substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." @@ -2350,7 +2359,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.9" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.10" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." diff --git a/scripts/install_gemma4_mlx.sh b/scripts/install_gemma4_mlx.sh index e1f43b827c..e1ac4628fa 100755 --- a/scripts/install_gemma4_mlx.sh +++ b/scripts/install_gemma4_mlx.sh @@ -1,4 +1,6 @@ #!/bin/bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 set -euo pipefail # ============================================================ diff --git a/scripts/install_qwen3_6_mlx.sh b/scripts/install_qwen3_6_mlx.sh index 38fe1bea05..ef0a3ee9e3 100644 --- a/scripts/install_qwen3_6_mlx.sh +++ b/scripts/install_qwen3_6_mlx.sh @@ -1,4 +1,6 @@ #!/bin/bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 set -euo pipefail # ============================================================ diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 6174e7e494..25dd4af01b 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +# # Unsloth Studio uninstaller for Windows PowerShell. # Stops running servers and removes install dir, launcher data, CLI shim, # desktop and Start Menu shortcuts, the user PATH entry, and the PathBackup diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 94ca04b204..bb830151ae 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -1,4 +1,7 @@ #!/usr/bin/env sh +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +# # Unsloth Studio uninstaller (macOS / Linux / WSL). # Stops running servers and removes install dir, launcher data, # CLI shim, desktop shortcut, .app bundle, and Launch Services entry. diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index eecb84ca27..0f5dbfa237 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -421,6 +421,35 @@ _workdirs: dict[str, str] = {} # Non-matching session_ids collapse to ``_invalid`` to block cross-session escapes. _SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_\-]{1,64}\Z") +_PROJECT_SESSION_PREFIX = "project-" + + +def _get_project_workdir(session_id: str) -> str | None: + if not session_id.startswith(_PROJECT_SESSION_PREFIX): + return None + project_id = session_id[len(_PROJECT_SESSION_PREFIX) :] + if not project_id or not _SESSION_ID_RE.match(project_id): + return None + try: + from storage.studio_db import ensure_chat_project_workspace + + project = ensure_chat_project_workspace(project_id) + except Exception: + logger.warning( + "Failed to resolve project sandbox for %s", session_id, exc_info = True + ) + return None + if not project: + return None + root_path = project.get("rootPath") + sandbox_path = project.get("sandboxPath") + if not root_path or not sandbox_path: + return None + root_real = os.path.realpath(root_path) + sandbox_real = os.path.realpath(sandbox_path) + if sandbox_real != root_real and not sandbox_real.startswith(root_real + os.sep): + return None + return sandbox_real def _get_workdir(session_id: str | None = None) -> str: @@ -430,7 +459,14 @@ def _get_workdir(session_id: str | None = None) -> str: if key not in _workdirs or not os.path.isdir(_workdirs[key]): home = os.path.expanduser("~") sandbox_root = os.path.join(home, "studio_sandbox") - if session_id and _SESSION_ID_RE.match(session_id): + project_workdir = ( + _get_project_workdir(session_id) + if session_id and _SESSION_ID_RE.match(session_id) + else None + ) + if project_workdir: + workdir = project_workdir + elif session_id and _SESSION_ID_RE.match(session_id): workdir = os.path.join(sandbox_root, session_id) if not os.path.realpath(workdir).startswith( os.path.realpath(sandbox_root) + os.sep @@ -453,6 +489,10 @@ def _get_workdir(session_id: str | None = None) -> str: return _workdirs[key] +def get_sandbox_workdir(session_id: str | None = None) -> str: + return _get_workdir(session_id) + + WEB_SEARCH_TOOL = { "type": "function", "function": { diff --git a/studio/backend/main.py b/studio/backend/main.py index 2768f56f49..10fbbfddf0 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -243,6 +243,7 @@ from routes import ( training_history_router, training_router, ) +from routes.settings import router as settings_router from auth import storage from auth.authentication import get_current_subject from utils.hardware import ( @@ -514,7 +515,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response.headers.setdefault("Referrer-Policy", "no-referrer") response.headers.setdefault( "Permissions-Policy", - "camera=(), microphone=(), geolocation=()", + "camera=(), microphone=(self), geolocation=()", ) response.headers["server"] = "unsloth-studio" return response @@ -523,11 +524,15 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): app.add_middleware(SecurityHeadersMiddleware) -# Cap upload body on protected POSTs; default 500 MB, env-tunable. +# Cap request bodies on protected POSTs. Upload routes get explicit multipart +# headroom, while non-upload routes keep the default body cap. import json as _json_for_413 # noqa: E402 +from utils.upload_limits import ( # noqa: E402 + UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES, + default_request_body_limit_bytes, + upload_request_limit_bytes, +) - -_MAX_BODY_BYTES = int(os.environ.get("UNSLOTH_STUDIO_MAX_BODY_MB", "500")) * 1024 * 1024 _BODY_PROTECTED_PREFIXES = ( "/v1/chat/completions", "/v1/completions", @@ -535,17 +540,50 @@ _BODY_PROTECTED_PREFIXES = ( "/api/data-recipe", "/api/datasets", "/api/chat", + "/api/settings", "/api/train", "/api/export", ) +_DATASET_UPLOAD_PASSTHROUGH_PREFIX = "/api/datasets/upload" +_DATA_RECIPE_UNSTRUCTURED_UPLOAD_PASSTHROUGH_PREFIX = ( + "/api/data-recipe/seed/upload-unstructured-file" +) +_BODY_UPLOAD_PASSTHROUGH_PREFIXES = ( + _DATASET_UPLOAD_PASSTHROUGH_PREFIX, + _DATA_RECIPE_UNSTRUCTURED_UPLOAD_PASSTHROUGH_PREFIX, +) -async def _send_413(send, total_bytes: int) -> None: +def _get_upload_passthrough_request_max_bytes(path: str) -> int: + if path.startswith(_DATA_RECIPE_UNSTRUCTURED_UPLOAD_PASSTHROUGH_PREFIX): + return upload_request_limit_bytes(UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES) + if path.startswith(_DATASET_UPLOAD_PASSTHROUGH_PREFIX): + return upload_request_limit_bytes() + return default_request_body_limit_bytes() + + +async def _send_411(send) -> None: + payload = _json_for_413.dumps( + {"detail": "Content-Length required for upload requests."}, + ).encode("utf-8") + await send( + { + "type": "http.response.start", + "status": 411, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(payload)).encode("ascii")), + ], + } + ) + await send({"type": "http.response.body", "body": payload, "more_body": False}) + + +async def _send_413(send, total_bytes: int, max_bytes: int) -> None: payload = _json_for_413.dumps( { "detail": ( - f"Request body too large " - f"({total_bytes:,} bytes; max {_MAX_BODY_BYTES:,})." + f"Request body too large ({total_bytes:,} bytes; max {max_bytes:,})." ) }, ).encode("utf-8") @@ -565,10 +603,32 @@ async def _send_413(send, total_bytes: int) -> None: class MaxBodyMiddleware: """Reject oversized bodies on protected POST/PUT/PATCH; raw ASGI so chunked uploads cannot bypass the cap.""" - def __init__(self, app, max_bytes: int, protected_prefixes: tuple): + def __init__( + self, + app, + max_bytes_getter, + protected_prefixes: tuple, + upload_passthrough_prefixes: tuple = (), + upload_passthrough_max_bytes_getter = None, + ): self.app = app - self.max_bytes = max_bytes + self.max_bytes_getter = max_bytes_getter self.protected_prefixes = protected_prefixes + self.upload_passthrough_prefixes = upload_passthrough_prefixes + self.upload_passthrough_max_bytes_getter = upload_passthrough_max_bytes_getter + + def _upload_passthrough_max_bytes(self, path: str) -> int: + if self.upload_passthrough_max_bytes_getter is None: + return int(self.max_bytes_getter()) + try: + return int(self.upload_passthrough_max_bytes_getter(path)) + except TypeError: + try: + return int(self.upload_passthrough_max_bytes_getter()) + except Exception: + return int(self.max_bytes_getter()) + except Exception: + return int(self.max_bytes_getter()) async def __call__(self, scope, receive, send): if scope["type"] != "http": @@ -582,6 +642,7 @@ class MaxBodyMiddleware: await self.app(scope, receive, send) return + max_bytes = int(self.max_bytes_getter()) declared = None for name, value in scope.get("headers", []): if name == b"content-length": @@ -590,8 +651,20 @@ class MaxBodyMiddleware: except (ValueError, UnicodeDecodeError): declared = None break - if declared is not None and declared > self.max_bytes: - await _send_413(send, declared) + + if any(path.startswith(p) for p in self.upload_passthrough_prefixes): + upload_max_bytes = self._upload_passthrough_max_bytes(path) + if declared is None: + await _send_411(send) + return + if declared > upload_max_bytes: + await _send_413(send, declared, upload_max_bytes) + return + await self.app(scope, receive, send) + return + + if declared is not None and declared > max_bytes: + await _send_413(send, declared, max_bytes) return chunks: list = [] @@ -607,8 +680,8 @@ class MaxBodyMiddleware: body = msg.get("body", b"") or b"" if body: total += len(body) - if total > self.max_bytes: - await _send_413(send, total) + if total > max_bytes: + await _send_413(send, total, max_bytes) return chunks.append(body) if not msg.get("more_body", False): @@ -632,8 +705,10 @@ class MaxBodyMiddleware: app.add_middleware( MaxBodyMiddleware, - max_bytes = _MAX_BODY_BYTES, + max_bytes_getter = default_request_body_limit_bytes, protected_prefixes = _BODY_PROTECTED_PREFIXES, + upload_passthrough_prefixes = _BODY_UPLOAD_PASSTHROUGH_PREFIXES, + upload_passthrough_max_bytes_getter = _get_upload_passthrough_request_max_bytes, ) @@ -688,6 +763,7 @@ app.include_router(inference_studio_router, prefix = "/api/inference", tags = [" # standard /v1/chat/completions path. app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"]) app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"]) +app.include_router(settings_router, prefix = "/api/settings", tags = ["settings"]) app.include_router(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"]) app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"]) app.include_router(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"]) diff --git a/studio/backend/requirements/extras.txt b/studio/backend/requirements/extras.txt index daa8982ea5..40737b0876 100644 --- a/studio/backend/requirements/extras.txt +++ b/studio/backend/requirements/extras.txt @@ -11,9 +11,9 @@ hydra-core hypothesis kgb parameterized -pytest<9.0 +pytest>=9.0.3,<10 pytest-json-report -pytest-rerunfailures==15.1 +pytest-rerunfailures>=16.2,<17 pytest-xdist # Also needed by sentence_transformers (installed with --no-deps in extras-no-deps.txt) scikit-learn==1.7.1 diff --git a/studio/backend/routes/auth.py b/studio/backend/routes/auth.py index bb4ce87cd7..23112ca2f8 100644 --- a/studio/backend/routes/auth.py +++ b/studio/backend/routes/auth.py @@ -9,6 +9,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status import ipaddress import os +import shlex +import sys import threading import time from collections import deque @@ -38,6 +40,36 @@ from auth.authentication import ( router = APIRouter() +def _reset_password_command() -> str: + """Shell command shown in the 'incorrect password' hint. + + Prefer the ABSOLUTE path to this install's ``unsloth`` launcher (a sibling + of the running interpreter) so the hint works even when the launcher's + directory is not on PATH -- e.g. a terminal opened before install, a stale + Windows PATH, or ``~/.local/bin`` not on PATH (the default on macOS) -- and + regardless of the current working directory. + + On POSIX the path is shell-quoted so spaces are handled. On Windows we only + use the bare absolute path when it has no spaces, because a quoted path needs + different syntax in cmd (``"..."``) vs PowerShell (``& "..."``); when it has + a space we fall back to the PATH-based form to stay unambiguous across + shells. If the launcher can't be located we fall back to the PATH form too. + """ + try: + bin_dir = os.path.dirname(os.path.abspath(sys.executable)) + if os.name == "nt": + exe = os.path.join(bin_dir, "unsloth.exe") + if os.path.isfile(exe) and " " not in exe: + return f"{exe} studio reset-password" + else: + exe = os.path.join(bin_dir, "unsloth") + if os.path.isfile(exe): + return f"{shlex.quote(exe)} studio reset-password" + except Exception: + pass + return "unsloth studio reset-password" + + # Per-(ip, username) bucket + per-IP aggregate. Account bucket stops one user's # typos from blocking others; the aggregate stops username-rotation spray. # Single-process only -- multi-worker deployments need a shared store. @@ -228,7 +260,7 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token: _record_login_failure(unknown_key) raise HTTPException( status_code = status.HTTP_401_UNAUTHORIZED, - detail = "Incorrect password. Run 'unsloth studio reset-password' in your terminal to reset it.", + detail = f"Incorrect password. To reset it, run this in your terminal: {_reset_password_command()}", ) salt, pwd_hash, _jwt_secret, must_change_password = record @@ -236,7 +268,7 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token: _record_login_failure(key) raise HTTPException( status_code = status.HTTP_401_UNAUTHORIZED, - detail = "Incorrect password. Run 'unsloth studio reset-password' in your terminal to reset it.", + detail = f"Incorrect password. To reset it, run this in your terminal: {_reset_password_command()}", ) _clear_login_bucket(key) diff --git a/studio/backend/routes/chat_history.py b/studio/backend/routes/chat_history.py index e64e5ca5c7..8e24096233 100644 --- a/studio/backend/routes/chat_history.py +++ b/studio/backend/routes/chat_history.py @@ -17,15 +17,21 @@ from storage.studio_db import ( clear_chat_history, count_chat_threads, delete_chat_threads, + delete_chat_project, + ensure_chat_project_workspace, + get_chat_project, get_chat_thread, get_chat_message, + list_chat_projects, list_chat_legacy_imports, list_chat_settings, list_chat_messages, list_chat_messages_for_threads, list_chat_threads, sync_chat_messages, + update_chat_project, update_chat_thread, + upsert_chat_project, upsert_chat_legacy_imports, upsert_chat_message, upsert_chat_settings_merge, @@ -41,6 +47,7 @@ class ChatThread(BaseModel): modelType: Literal["base", "lora", "model1", "model2"] modelId: str = "" pairId: Optional[str] = None + projectId: Optional[str] = None archived: bool = False createdAt: int openaiCodeExecContainerId: Optional[str] = None @@ -52,6 +59,7 @@ class ChatThreadPatch(BaseModel): modelType: Optional[Literal["base", "lora", "model1", "model2"]] = None modelId: Optional[str] = None pairId: Optional[str] = None + projectId: Optional[str] = None archived: Optional[bool] = None createdAt: Optional[int] = None openaiCodeExecContainerId: Optional[str] = None @@ -69,10 +77,33 @@ class ChatMessage(BaseModel): createdAt: int +class ChatProject(BaseModel): + id: str + name: str + instructions: str = "" + rootPath: Optional[str] = None + sandboxPath: Optional[str] = None + archived: bool = False + createdAt: int + updatedAt: int + + +class ChatProjectPatch(BaseModel): + name: Optional[str] = None + instructions: Optional[str] = None + archived: Optional[bool] = None + createdAt: Optional[int] = None + updatedAt: Optional[int] = None + + class ChatThreadListResponse(BaseModel): threads: list[ChatThread] +class ChatProjectListResponse(BaseModel): + projects: list[ChatProject] + + class ChatMessageListResponse(BaseModel): messages: list[ChatMessage] @@ -94,6 +125,7 @@ class ChatExportResponse(BaseModel): exportedAt: str version: int threadCount: int + projects: list[ChatProject] = Field(default_factory = list) threads: list[ChatThread] messages: list[ChatMessage] @@ -177,12 +209,14 @@ class ChatImportLedgerRecordResponse(BaseModel): async def list_threads( model_type: Optional[str] = Query(None), pair_id: Optional[str] = Query(None), + project_id: Optional[str] = Query(None), include_archived: bool = Query(True), current_subject: str = Depends(get_current_subject), ): threads = list_chat_threads( model_type = model_type, pair_id = pair_id, + project_id = project_id, include_archived = include_archived, ) return ChatThreadListResponse(threads = [ChatThread(**t) for t in threads]) @@ -193,6 +227,11 @@ async def save_thread( payload: ChatThread, current_subject: str = Depends(get_current_subject), ): + if payload.projectId and get_chat_project(payload.projectId) is None: + raise HTTPException( + status_code = 404, + detail = f"Project {payload.projectId} not found", + ) return ChatThread(**upsert_chat_thread(payload.model_dump())) @@ -217,6 +256,11 @@ async def patch_thread( for field in ("title", "modelType", "modelId", "archived", "createdAt"): if field in patch and patch[field] is None: raise HTTPException(status_code = 400, detail = f"{field} cannot be null") + if patch.get("projectId") and get_chat_project(patch["projectId"]) is None: + raise HTTPException( + status_code = 404, + detail = f"Project {patch['projectId']} not found", + ) thread = update_chat_thread( thread_id, patch, @@ -235,6 +279,77 @@ async def delete_threads( return {"status": "deleted"} +@router.get("/projects", response_model = ChatProjectListResponse) +async def list_projects( + include_archived: bool = Query(False), + current_subject: str = Depends(get_current_subject), +): + return ChatProjectListResponse( + projects = [ + ChatProject(**(ensure_chat_project_workspace(project["id"]) or project)) + for project in list_chat_projects(include_archived = include_archived) + ] + ) + + +@router.post("/projects", response_model = ChatProject) +async def save_project( + payload: ChatProject, + current_subject: str = Depends(get_current_subject), +): + return ChatProject(**upsert_chat_project(payload.model_dump())) + + +@router.get("/projects/{project_id}", response_model = ChatProject) +async def get_project( + project_id: str, + current_subject: str = Depends(get_current_subject), +): + project = ensure_chat_project_workspace(project_id) + if project is None: + raise HTTPException( + status_code = 404, + detail = f"Project {project_id} not found", + ) + return ChatProject(**project) + + +@router.patch("/projects/{project_id}", response_model = ChatProject) +async def patch_project( + project_id: str, + payload: ChatProjectPatch, + current_subject: str = Depends(get_current_subject), +): + patch = payload.model_dump(exclude_unset = True) + for field in ("name", "archived", "createdAt", "updatedAt"): + if field in patch and patch[field] is None: + raise HTTPException(status_code = 400, detail = f"{field} cannot be null") + project = update_chat_project(project_id, patch) + if project is not None: + project = ensure_chat_project_workspace(project_id) + if project is None: + raise HTTPException( + status_code = 404, + detail = f"Project {project_id} not found", + ) + return ChatProject(**project) + + +@router.delete("/projects/{project_id}", response_model = ChatProject) +async def delete_project( + project_id: str, + delete_files: bool = Query(False), + current_subject: str = Depends(get_current_subject), +): + project = delete_chat_project(project_id, delete_files = delete_files) + if project is None: + raise HTTPException( + status_code = 404, + detail = f"Project {project_id} not found", + ) + return ChatProject(**project) + + @router.get("/threads/{thread_id}/messages", response_model = ChatMessageListResponse) async def get_thread_messages( thread_id: str, @@ -389,11 +504,13 @@ async def export_history(current_subject: str = Depends(get_current_subject)): from datetime import datetime, timezone threads = list_chat_threads(include_archived = True) + projects = list_chat_projects(include_archived = True) messages = list_chat_messages_for_threads([thread["id"] for thread in threads]) return ChatExportResponse( exportedAt = datetime.now(timezone.utc).isoformat(), version = 1, threadCount = len(threads), + projects = [ChatProject(**project) for project in projects], threads = [ChatThread(**thread) for thread in threads], messages = [ChatMessage(**message) for message in messages], ) diff --git a/studio/backend/routes/data_recipe/seed.py b/studio/backend/routes/data_recipe/seed.py index 91cf718e6e..a18f8f3e32 100644 --- a/studio/backend/routes/data_recipe/seed.py +++ b/studio/backend/routes/data_recipe/seed.py @@ -31,6 +31,14 @@ except ImportError: resolve_chunking = None from core.data_recipe.jsonable import to_preview_jsonable from utils.paths import ensure_dir, seed_uploads_root, unstructured_uploads_root +from utils.upload_limits import ( + LOCAL_SEED_UPLOAD_MAX_BYTES, + LOCAL_SEED_UPLOAD_MAX_LABEL, + UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES, + UNSTRUCTURED_RECIPE_UPLOAD_MAX_LABEL, + UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES, + UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL, +) from models.data_recipe import ( SeedInspectRequest, @@ -47,9 +55,6 @@ LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl"} UNSTRUCTURED_ALLOWED_EXTS = {".pdf", ".docx", ".txt", ".md"} SEED_UPLOAD_DIR = seed_uploads_root() UNSTRUCTURED_UPLOAD_ROOT = unstructured_uploads_root() -MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB -MAX_TOTAL_SIZE = 100 * 1024 * 1024 # 100MB - _SAFE_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$") @@ -405,20 +410,17 @@ def _extract_text_from_file(file_path: Path, ext: str) -> str: return normalize_unstructured_text(raw) -def _get_block_total_size(block_dir: Path, file_ids: list[str]) -> int: - """Sum raw upload sizes for tracked file IDs only.""" - if not block_dir.exists() or not file_ids: +def _get_block_total_size(block_dir: Path) -> int: + """Sum raw upload sizes for the whole block from server-owned files.""" + if not block_dir.exists(): return 0 - id_set = set(file_ids) total = 0 for f in block_dir.iterdir(): if not f.is_file(): continue if f.name.endswith(".extracted.txt") or f.name.endswith(".meta.json"): continue - stem = f.name.split(".")[0] - if stem in id_set: - total += f.stat().st_size + total += f.stat().st_size return total @@ -426,12 +428,9 @@ def _get_block_total_size(block_dir: Path, file_ids: list[str]) -> int: async def upload_unstructured_file( file: UploadFile = FastAPIFile(...), block_id: str = Form(...), - existing_file_ids: str = Form(""), ) -> UnstructuredFileUploadResponse: _validate_safe_id(block_id, "block_id") - tracked_ids = [fid.strip() for fid in existing_file_ids.split(",") if fid.strip()] - original_filename = file.filename or "upload" ext = Path(original_filename).suffix.lower() if ext not in UNSTRUCTURED_ALLOWED_EXTS: @@ -446,17 +445,19 @@ async def upload_unstructured_file( if size_bytes == 0: raise HTTPException(400, "Empty file not allowed") - if size_bytes > MAX_FILE_SIZE: + if size_bytes > UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES: raise HTTPException( - 413, f"File too large ({size_bytes} bytes). Maximum is 50MB." + 413, + f"File too large ({size_bytes} bytes). Maximum is {UNSTRUCTURED_RECIPE_UPLOAD_MAX_LABEL}.", ) block_dir = UNSTRUCTURED_UPLOAD_ROOT / block_id ensure_dir(block_dir) - current_total = _get_block_total_size(block_dir, file_ids = tracked_ids) - if current_total + size_bytes > MAX_TOTAL_SIZE: + current_total = _get_block_total_size(block_dir) + if current_total + size_bytes > UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES: raise HTTPException( - 413, f"Total upload limit ({MAX_TOTAL_SIZE // (1024 * 1024)}MB) exceeded" + 413, + f"Total upload limit ({UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL}) exceeded", ) file_id = uuid4().hex @@ -594,8 +595,11 @@ def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectRespons file_bytes = _decode_base64_payload(payload.content_base64) if not file_bytes: raise HTTPException(status_code = 400, detail = "empty upload payload") - if len(file_bytes) > MAX_FILE_SIZE: - raise HTTPException(status_code = 413, detail = "file too large (max 50MB)") + if len(file_bytes) > LOCAL_SEED_UPLOAD_MAX_BYTES: + raise HTTPException( + status_code = 413, + detail = f"file too large (max {LOCAL_SEED_UPLOAD_MAX_LABEL})", + ) ensure_dir(SEED_UPLOAD_DIR) stored_name = f"{uuid4().hex}_{filename}" diff --git a/studio/backend/routes/datasets.py b/studio/backend/routes/datasets.py index 206af2a66f..c34d6d8732 100644 --- a/studio/backend/routes/datasets.py +++ b/studio/backend/routes/datasets.py @@ -9,6 +9,7 @@ import base64 import io import json import sys +from contextlib import suppress from pathlib import Path from uuid import uuid4 from typing import Optional @@ -67,6 +68,7 @@ if str(backend_path) not in sys.path: # Import dataset utilities from utils.datasets import check_dataset_format +from utils.upload_limits import get_upload_limit_bytes, get_upload_limit_label from auth.authentication import get_current_subject router = APIRouter() @@ -138,6 +140,7 @@ _ARCHIVE_EXTS = (".tar", ".tar.gz", ".tgz", ".gz", ".zst", ".zip", ".txt") DATA_EXTS = _TABULAR_EXTS + _ARCHIVE_EXTS LOCAL_FILE_EXTS = (".json", ".jsonl", ".csv", ".parquet") LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl", ".parquet"} +# sync: training dataset upload limits are exposed by /api/settings/upload-limit LOCAL_DATASETS_ROOT = recipe_datasets_root() DATASET_UPLOAD_DIR = dataset_uploads_root() @@ -334,10 +337,30 @@ async def upload_dataset( stored_name = f"{uuid4().hex}_{stem}{ext}" stored_path = DATASET_UPLOAD_DIR / stored_name - # Stream file to disk in chunks to avoid holding entire file in memory - with open(stored_path, "wb") as f: - while chunk := await file.read(1024 * 1024): - f.write(chunk) + # Stream file to disk in chunks to avoid holding entire file in memory. + # Keep a route-level cap so users get a clear training-dataset-specific + # error and oversized partial files are not left in the Studio uploads directory. + upload_limit_bytes = get_upload_limit_bytes() + total_bytes = 0 + upload_complete = False + try: + with open(stored_path, "wb") as f: + while chunk := await file.read(1024 * 1024): + total_bytes += len(chunk) + if total_bytes > upload_limit_bytes: + raise HTTPException( + status_code = 413, + detail = ( + "Training dataset upload too large. " + f"Maximum is {get_upload_limit_label()}." + ), + ) + f.write(chunk) + upload_complete = True + finally: + if not upload_complete: + with suppress(OSError): + stored_path.unlink(missing_ok = True) if stored_path.stat().st_size == 0: stored_path.unlink(missing_ok = True) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index fbe8cf6a92..2964a0801f 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3837,16 +3837,11 @@ async def serve_sandbox_file( ) # ── Path containment check ────────────────────────────────── - home = os.path.expanduser("~") - sandbox_root = os.path.realpath(os.path.join(home, "studio_sandbox")) - safe_session = os.path.basename(session_id.replace("..", "")) - if not safe_session: - raise HTTPException(status_code = 404, detail = "Not found") + from core.inference.tools import get_sandbox_workdir - file_path = os.path.realpath( - os.path.join(sandbox_root, safe_session, safe_filename) - ) - if not file_path.startswith(sandbox_root + os.sep): + sandbox_dir = os.path.realpath(get_sandbox_workdir(session_id)) + file_path = os.path.realpath(os.path.join(sandbox_dir, safe_filename)) + if file_path != sandbox_dir and not file_path.startswith(sandbox_dir + os.sep): raise HTTPException( status_code = status.HTTP_403_FORBIDDEN, detail = "Access denied", diff --git a/studio/backend/routes/settings.py b/studio/backend/routes/settings.py new file mode 100644 index 0000000000..275fbb678b --- /dev/null +++ b/studio/backend/routes/settings.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from auth.authentication import get_current_subject +from utils.upload_limits import ( + MAX_UPLOAD_LIMIT_MB, + MIN_UPLOAD_LIMIT_MB, + default_upload_limit_mb, + get_upload_limit_mb, + set_upload_limit_mb, + upload_limit_bytes, + upload_limit_label, +) + +router = APIRouter() + + +class UploadLimitPayload(BaseModel): + max_upload_size_mb: int = Field(..., ge = MIN_UPLOAD_LIMIT_MB, le = MAX_UPLOAD_LIMIT_MB) + + +class UploadLimitResponse(BaseModel): + max_upload_size_mb: int + max_upload_size_bytes: int + max_upload_size_label: str + default_upload_size_mb: int + min_upload_size_mb: int = MIN_UPLOAD_LIMIT_MB + max_allowed_upload_size_mb: int = MAX_UPLOAD_LIMIT_MB + + +def _upload_limit_response(limit_mb: int) -> UploadLimitResponse: + return UploadLimitResponse( + max_upload_size_mb = limit_mb, + max_upload_size_bytes = upload_limit_bytes(limit_mb), + max_upload_size_label = upload_limit_label(limit_mb), + default_upload_size_mb = default_upload_limit_mb(), + ) + + +@router.get("/upload-limit", response_model = UploadLimitResponse) +def get_upload_limit( + current_subject: str = Depends(get_current_subject), +) -> UploadLimitResponse: + return _upload_limit_response(get_upload_limit_mb()) + + +@router.put("/upload-limit", response_model = UploadLimitResponse) +def update_upload_limit( + payload: UploadLimitPayload, + current_subject: str = Depends(get_current_subject), +) -> UploadLimitResponse: + try: + limit_mb = set_upload_limit_mb(payload.max_upload_size_mb) + except ValueError as exc: + raise HTTPException(status_code = 400, detail = str(exc)) from exc + return _upload_limit_response(limit_mb) diff --git a/studio/backend/storage/studio_db.py b/studio/backend/storage/studio_db.py index de89b6cbd2..0175e54b35 100644 --- a/studio/backend/storage/studio_db.py +++ b/studio/backend/storage/studio_db.py @@ -14,15 +14,18 @@ import json import logging import os import platform +import re +import shutil import sqlite3 import threading from datetime import datetime, timezone +from pathlib import Path logger = logging.getLogger(__name__) from typing import Any, Iterable, Optional -from utils.paths import studio_db_path, ensure_dir +from utils.paths import project_workspaces_root, studio_db_path, ensure_dir def _denied_path_prefixes() -> list[str]: @@ -55,6 +58,77 @@ def _denied_path_prefixes() -> list[str]: _schema_lock = threading.Lock() _schema_ready = False _SQLITE_IN_CHUNK_SIZE = 900 +_PROJECT_WORKSPACE_SUBDIRS = ("sandbox",) + + +def _project_slug(name: str) -> str: + slug = re.sub(r"[^A-Za-z0-9._-]+", "-", name.strip()).strip(".-_") + return slug[:48] or "project" + + +def _default_project_root(project: dict) -> str: + project_id = str(project["id"]) + suffix = re.sub(r"[^A-Za-z0-9_-]+", "-", project_id)[:8].strip("-_") or "project" + folder_name = f"{_project_slug(str(project.get('name') or 'Project'))}-{suffix}" + return str(project_workspaces_root() / folder_name) + + +def _ensure_project_workspace(root_path: str) -> str: + root = Path(root_path).expanduser() + root_resolved = ensure_dir(root).resolve() + for subdir in _PROJECT_WORKSPACE_SUBDIRS: + ensure_dir(root_resolved / subdir) + return str(root_resolved) + + +def _delete_project_workspace(project: dict) -> None: + root_path = project.get("rootPath") + if not root_path: + return + root = Path(root_path).expanduser() + try: + root_resolved = root.resolve(strict = False) + except (OSError, RuntimeError, ValueError): + logger.warning( + "Skipping project workspace delete for invalid path %r", root_path + ) + return + + project_id = str(project["id"]) + suffix = re.sub(r"[^A-Za-z0-9_-]+", "-", project_id)[:8].strip("-_") or "project" + if not root_resolved.name.endswith(f"-{suffix}"): + logger.warning( + "Skipping project workspace delete for unexpected project path %s", + root_resolved, + ) + return + if root_resolved.parent == root_resolved or root_resolved == Path.home().resolve(): + logger.warning( + "Skipping project workspace delete for unsafe project path %s", + root_resolved, + ) + return + check = ( + os.path.normcase(str(root_resolved)) + if platform.system() == "Windows" + else str(root_resolved) + ) + for prefix in _denied_path_prefixes(): + if check == prefix or check.startswith(prefix + os.sep): + logger.warning( + "Skipping project workspace delete under denied path %s", + root_resolved, + ) + return + if not root_resolved.exists(): + return + if root_resolved.is_symlink() or not root_resolved.is_dir(): + logger.warning( + "Skipping project workspace delete for non-directory path %s", + root_resolved, + ) + return + shutil.rmtree(root_resolved) def _ensure_schema(conn: sqlite3.Connection) -> None: @@ -119,6 +193,27 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chat_projects ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + instructions TEXT, + root_path TEXT, + archived INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + chat_project_cols = { + row[1] for row in conn.execute("PRAGMA table_info(chat_projects)").fetchall() + } + if "root_path" not in chat_project_cols: + conn.execute("ALTER TABLE chat_projects ADD COLUMN root_path TEXT") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_projects_archived_updated_at ON chat_projects(archived, updated_at)" + ) conn.execute( """ CREATE TABLE IF NOT EXISTS chat_threads ( @@ -127,16 +222,20 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: model_type TEXT NOT NULL, model_id TEXT, pair_id TEXT, + project_id TEXT, archived INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, openai_code_exec_container_id TEXT, - anthropic_code_exec_container_id TEXT + anthropic_code_exec_container_id TEXT, + FOREIGN KEY(project_id) REFERENCES chat_projects(id) ON DELETE CASCADE ) """ ) chat_thread_cols = { row[1] for row in conn.execute("PRAGMA table_info(chat_threads)").fetchall() } + if "project_id" not in chat_thread_cols: + conn.execute("ALTER TABLE chat_threads ADD COLUMN project_id TEXT") if "openai_code_exec_container_id" not in chat_thread_cols: conn.execute( "ALTER TABLE chat_threads ADD COLUMN openai_code_exec_container_id TEXT" @@ -165,6 +264,9 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: conn.execute( "CREATE INDEX IF NOT EXISTS idx_chat_threads_pair_id ON chat_threads(pair_id)" ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_threads_project_id ON chat_threads(project_id)" + ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_chat_messages_thread_id_created_at ON chat_messages(thread_id, created_at)" ) @@ -177,6 +279,15 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT NOT NULL PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) conn.execute( """ CREATE TABLE IF NOT EXISTS chat_settings_quarantine ( @@ -681,6 +792,7 @@ def _chat_thread_from_row(row: sqlite3.Row) -> dict: "modelType": data["model_type"], "modelId": data.get("model_id") or "", "pairId": data.get("pair_id") or None, + "projectId": data.get("project_id") or None, "archived": bool(data["archived"]), "createdAt": data["created_at"], "openaiCodeExecContainerId": data.get("openai_code_exec_container_id"), @@ -688,6 +800,21 @@ def _chat_thread_from_row(row: sqlite3.Row) -> dict: } +def _chat_project_from_row(row: sqlite3.Row) -> dict: + data = dict(row) + root_path = data.get("root_path") + return { + "id": data["id"], + "name": data["name"], + "instructions": data.get("instructions") or "", + "rootPath": root_path or None, + "sandboxPath": os.path.join(root_path, "sandbox") if root_path else None, + "archived": bool(data["archived"]), + "createdAt": data["created_at"], + "updatedAt": data["updated_at"], + } + + def _chat_message_from_row(row: sqlite3.Row) -> dict: data = dict(row) message = { @@ -713,13 +840,14 @@ def upsert_chat_thread(thread: dict) -> dict: conn.execute( """ INSERT INTO chat_threads - (id, title, model_type, model_id, pair_id, archived, created_at, openai_code_exec_container_id, anthropic_code_exec_container_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, title, model_type, model_id, pair_id, project_id, archived, created_at, openai_code_exec_container_id, anthropic_code_exec_container_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, model_type = excluded.model_type, model_id = excluded.model_id, pair_id = excluded.pair_id, + project_id = excluded.project_id, archived = excluded.archived, created_at = excluded.created_at, openai_code_exec_container_id = excluded.openai_code_exec_container_id, @@ -731,6 +859,7 @@ def upsert_chat_thread(thread: dict) -> dict: thread["modelType"], thread.get("modelId") or "", thread.get("pairId"), + thread.get("projectId"), 1 if thread.get("archived") else 0, int(thread["createdAt"]), thread.get("openaiCodeExecContainerId"), @@ -749,6 +878,7 @@ def update_chat_thread(id: str, patch: dict) -> Optional[dict]: "modelType": ("model_type", patch.get("modelType")), "modelId": ("model_id", patch.get("modelId")), "pairId": ("pair_id", patch.get("pairId")), + "projectId": ("project_id", patch.get("projectId")), "archived": ("archived", 1 if patch.get("archived") else 0), "createdAt": ("created_at", patch.get("createdAt")), "openaiCodeExecContainerId": ( @@ -794,6 +924,7 @@ def get_chat_thread(id: str) -> Optional[dict]: def list_chat_threads( model_type: str | None = None, pair_id: str | None = None, + project_id: str | None = None, include_archived: bool = True, ) -> list[dict]: clauses = [] @@ -804,6 +935,9 @@ def list_chat_threads( if pair_id is not None: clauses.append("pair_id = ?") values.append(pair_id) + if project_id is not None: + clauses.append("project_id = ?") + values.append(project_id) if not include_archived: clauses.append("archived = 0") where = f"WHERE {' AND '.join(clauses)}" if clauses else "" @@ -846,6 +980,136 @@ def count_chat_threads() -> int: conn.close() +def upsert_chat_project(project: dict) -> dict: + existing = get_chat_project(project["id"]) + root_path = existing.get("rootPath") if existing else None + if not root_path: + root_path = _default_project_root(project) + root_path = _ensure_project_workspace(root_path) + conn = get_connection() + try: + conn.execute( + """ + INSERT INTO chat_projects + (id, name, instructions, root_path, archived, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + instructions = excluded.instructions, + root_path = COALESCE(chat_projects.root_path, excluded.root_path), + archived = excluded.archived, + created_at = excluded.created_at, + updated_at = excluded.updated_at + """, + ( + project["id"], + project["name"], + project.get("instructions") or "", + root_path, + 1 if project.get("archived") else 0, + int(project["createdAt"]), + int(project["updatedAt"]), + ), + ) + conn.commit() + return get_chat_project(project["id"]) or project + finally: + conn.close() + + +def update_chat_project(id: str, patch: dict) -> Optional[dict]: + allowed = { + "name": ("name", patch.get("name")), + "instructions": ("instructions", patch.get("instructions")), + "archived": ("archived", 1 if patch.get("archived") else 0), + "createdAt": ("created_at", patch.get("createdAt")), + "updatedAt": ("updated_at", patch.get("updatedAt")), + } + assignments = [] + values = [] + for key, (column, value) in allowed.items(): + if key in patch: + assignments.append(f"{column} = ?") + values.append(value) + if not assignments: + return get_chat_project(id) + + conn = get_connection() + try: + conn.execute( + f"UPDATE chat_projects SET {', '.join(assignments)} WHERE id = ?", + (*values, id), + ) + conn.commit() + row = conn.execute("SELECT * FROM chat_projects WHERE id = ?", (id,)).fetchone() + return _chat_project_from_row(row) if row is not None else None + finally: + conn.close() + + +def ensure_chat_project_workspace(id: str) -> Optional[dict]: + project = get_chat_project(id) + if project is None: + return None + root_path = project.get("rootPath") or _default_project_root(project) + root_path = _ensure_project_workspace(root_path) + if project.get("rootPath") == root_path: + return project + conn = get_connection() + try: + conn.execute( + "UPDATE chat_projects SET root_path = ? WHERE id = ?", + (root_path, id), + ) + conn.commit() + finally: + conn.close() + return get_chat_project(id) + + +def get_chat_project(id: str) -> Optional[dict]: + conn = get_connection() + try: + row = conn.execute("SELECT * FROM chat_projects WHERE id = ?", (id,)).fetchone() + return _chat_project_from_row(row) if row is not None else None + finally: + conn.close() + + +def list_chat_projects(include_archived: bool = False) -> list[dict]: + conn = get_connection() + try: + where = "" if include_archived else "WHERE archived = 0" + rows = conn.execute( + f"SELECT * FROM chat_projects {where} ORDER BY updated_at DESC" + ).fetchall() + return [_chat_project_from_row(row) for row in rows] + finally: + conn.close() + + +def delete_chat_project(id: str, delete_files: bool = False) -> Optional[dict]: + conn = get_connection() + try: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute("SELECT * FROM chat_projects WHERE id = ?", (id,)).fetchone() + if row is None: + conn.rollback() + return None + project = _chat_project_from_row(row) + conn.execute("DELETE FROM chat_threads WHERE project_id = ?", (id,)) + conn.execute("DELETE FROM chat_projects WHERE id = ?", (id,)) + conn.commit() + if delete_files: + _delete_project_workspace(project) + return project + except Exception: + conn.rollback() + raise + finally: + conn.close() + + class ChatMessageConflictError(RuntimeError): """Raised when a chat message id already belongs to another thread.""" @@ -1088,6 +1352,44 @@ def list_chat_messages_for_threads(thread_ids: list[str]) -> list[dict]: conn.close() +def get_app_setting(key: str, fallback = None): + conn = get_connection() + try: + row = conn.execute( + "SELECT value_json FROM app_settings WHERE key = ?", (key,) + ).fetchone() + if row is None: + return fallback + return _json_loads(row["value_json"], fallback) + finally: + conn.close() + + +def upsert_app_settings(settings: dict[str, Any]) -> dict[str, Any]: + if not settings: + return {} + conn = get_connection() + try: + now = datetime.now(timezone.utc).isoformat() + conn.executemany( + """ + INSERT INTO app_settings (key, value_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value_json = excluded.value_json, + updated_at = excluded.updated_at + """, + [(key, json.dumps(value), now) for key, value in settings.items()], + ) + conn.commit() + rows = conn.execute( + "SELECT key, value_json FROM app_settings ORDER BY key" + ).fetchall() + return {row["key"]: _json_loads(row["value_json"], None) for row in rows} + finally: + conn.close() + + def list_chat_settings() -> dict[str, Any]: conn = get_connection() try: diff --git a/studio/backend/tests/test_chat_history_storage.py b/studio/backend/tests/test_chat_history_storage.py index 123dbf1b96..e88cac3276 100644 --- a/studio/backend/tests/test_chat_history_storage.py +++ b/studio/backend/tests/test_chat_history_storage.py @@ -1,18 +1,49 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +import os +import platform +import shutil import threading +import uuid +from pathlib import Path import pytest from storage import studio_db -def _reset_studio_db(tmp_path, monkeypatch): +def _reset_studio_db(tmp_path, monkeypatch, projects_home = None): monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path)) + monkeypatch.setenv( + "UNSLOTH_STUDIO_PROJECTS_HOME", + str(projects_home if projects_home is not None else tmp_path / "Projects"), + ) monkeypatch.setattr(studio_db, "_schema_ready", False) +@pytest.fixture +def workspace_projects_home(tmp_path): + """Projects root outside the platform delete denylist. + + tmp_path resolves under /private/tmp on macOS, which the workspace + delete guard refuses by design. Linux/Windows tmp is not denied and is + used as-is; only the denied case falls back to a home subdir. + """ + candidate = tmp_path / "Projects" + resolved = str(candidate.resolve()) + check = os.path.normcase(resolved) if platform.system() == "Windows" else resolved + denied = studio_db._denied_path_prefixes() + if any(check == p or check.startswith(p + os.sep) for p in denied): + candidate = Path.home() / ".unsloth-studio-tests" / uuid.uuid4().hex + candidate.mkdir(parents = True, exist_ok = True) + try: + yield candidate + finally: + if ".unsloth-studio-tests" in candidate.parts: + shutil.rmtree(candidate, ignore_errors = True) + + def _thread(thread_id: str = "thread-1") -> dict: return { "id": thread_id, @@ -41,6 +72,17 @@ def _message( } +def _project(project_id: str = "project-1") -> dict: + return { + "id": project_id, + "name": "Research", + "instructions": "Use terse answers.", + "archived": False, + "createdAt": 1_700_000_000_000, + "updatedAt": 1_700_000_000_000, + } + + def test_sync_chat_messages_upserts_without_pruning(tmp_path, monkeypatch): _reset_studio_db(tmp_path, monkeypatch) studio_db.upsert_chat_thread(_thread()) @@ -63,6 +105,53 @@ def test_sync_chat_messages_upserts_without_pruning(tmp_path, monkeypatch): assert by_id["msg-2"]["content"] == [{"type": "text", "text": "updated text"}] +def test_chat_projects_delete_cascades_threads_and_messages( + tmp_path, + monkeypatch, +): + _reset_studio_db(tmp_path, monkeypatch) + project = studio_db.upsert_chat_project(_project()) + assert project["rootPath"].startswith(str(tmp_path / "Projects")) + assert (tmp_path / "Projects" / "Research-project").exists() + assert (tmp_path / "Projects" / "Research-project" / "sandbox").is_dir() + assert not (tmp_path / "Projects" / "Research-project" / "chats").exists() + assert not (tmp_path / "Projects" / "Research-project" / "files").exists() + assert not (tmp_path / "Projects" / "Research-project" / "exports").exists() + studio_db.upsert_chat_thread({**_thread(), "projectId": "project-1"}) + studio_db.upsert_chat_message(_message("msg-1", 1, "delete with project")) + + [thread] = studio_db.list_chat_threads(project_id = "project-1") + assert thread["projectId"] == "project-1" + + deleted = studio_db.delete_chat_project("project-1") + + assert deleted is not None + assert deleted["id"] == "project-1" + assert studio_db.get_chat_project("project-1") is None + assert studio_db.list_chat_threads(project_id = "project-1") == [] + assert studio_db.get_chat_thread("thread-1") is None + assert studio_db.list_chat_messages("thread-1") == [] + assert (tmp_path / "Projects" / "Research-project").exists() + + +def test_chat_project_delete_files_removes_workspace( + tmp_path, monkeypatch, workspace_projects_home +): + _reset_studio_db(tmp_path, monkeypatch, projects_home = workspace_projects_home) + project = studio_db.upsert_chat_project(_project()) + # Derive root from the created project so it tracks the projects home. + root = Path(project["rootPath"]) + marker = root / "sandbox" / "marker.txt" + marker.write_text("created by code execution", encoding = "utf-8") + + deleted = studio_db.delete_chat_project(project["id"], delete_files = True) + + assert deleted is not None + assert deleted["rootPath"] == project["rootPath"] + assert not root.exists() + assert studio_db.get_chat_project(project["id"]) is None + + def test_sync_chat_messages_prunes_when_requested(tmp_path, monkeypatch): _reset_studio_db(tmp_path, monkeypatch) studio_db.upsert_chat_thread(_thread()) diff --git a/studio/backend/tests/test_dataset_upload_limits.py b/studio/backend/tests/test_dataset_upload_limits.py new file mode 100644 index 0000000000..0991059318 --- /dev/null +++ b/studio/backend/tests/test_dataset_upload_limits.py @@ -0,0 +1,67 @@ +# 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 training dataset upload limits and cleanup.""" + +import asyncio +import sys +from pathlib import Path +from typing import cast + +import pytest +from fastapi import HTTPException, UploadFile + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) + +from routes import datasets as datasets_route # noqa: E402 + + +class FakeUploadFile: + def __init__(self, filename: str, chunks: list[bytes]): + self.filename = filename + self._chunks = list(chunks) + + async def read(self, _size: int = -1) -> bytes: + if not self._chunks: + return b"" + return self._chunks.pop(0) + + +@pytest.fixture(autouse = True) +def isolate_upload_dir(tmp_path, monkeypatch): + monkeypatch.setattr(datasets_route, "DATASET_UPLOAD_DIR", tmp_path) + monkeypatch.setattr(datasets_route, "get_upload_limit_bytes", lambda: 1024 * 1024) + monkeypatch.setattr(datasets_route, "get_upload_limit_label", lambda: "1MB") + return tmp_path + + +def test_dataset_upload_under_configured_cap_succeeds(isolate_upload_dir): + upload = FakeUploadFile("sample.csv", [b"a,b\n1,2\n"]) + response = asyncio.run( + datasets_route.upload_dataset( + cast(UploadFile, upload), current_subject = "test-user" + ) + ) + stored = Path(response.stored_path) + assert response.filename == "sample.csv" + assert stored.exists() + assert stored.parent == isolate_upload_dir + assert stored.read_bytes() == b"a,b\n1,2\n" + + +def test_dataset_upload_over_configured_cap_removes_partial_file(isolate_upload_dir): + upload = FakeUploadFile( + "sample.csv", + [b"x" * (1024 * 1024), b"y"], + ) + with pytest.raises(HTTPException) as exc: + asyncio.run( + datasets_route.upload_dataset( + cast(UploadFile, upload), current_subject = "test-user" + ) + ) + assert exc.value.status_code == 413 + assert "Maximum is 1MB" in exc.value.detail + assert list(isolate_upload_dir.iterdir()) == [] diff --git a/studio/backend/tests/test_desktop_auth.py b/studio/backend/tests/test_desktop_auth.py index b1522dd382..3d01342a5c 100644 --- a/studio/backend/tests/test_desktop_auth.py +++ b/studio/backend/tests/test_desktop_auth.py @@ -9,7 +9,7 @@ import sqlite3 import subprocess import sys from pathlib import Path -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace import jwt import pytest @@ -429,21 +429,31 @@ def test_desktop_capabilities_json_reports_rollout_safe_flags(): def test_health_response_reports_desktop_capability_fields(monkeypatch): - router_stub = SimpleNamespace( - auth_router = APIRouter(), - chat_history_router = APIRouter(), - data_recipe_router = APIRouter(), - datasets_router = APIRouter(), - export_router = APIRouter(), - inference_router = APIRouter(), - inference_studio_router = APIRouter(), - mcp_servers_router = APIRouter(), - models_router = APIRouter(), - providers_router = APIRouter(), - training_history_router = APIRouter(), - training_router = APIRouter(), - ) - monkeypatch.setitem(sys.modules, "routes", router_stub) + routes_module = ModuleType("routes") + routes_module.__path__ = [] + settings_module = ModuleType("routes.settings") + settings_module.router = APIRouter() + + for name, router in { + "auth_router": APIRouter(), + "chat_history_router": APIRouter(), + "data_recipe_router": APIRouter(), + "datasets_router": APIRouter(), + "export_router": APIRouter(), + "inference_router": APIRouter(), + "inference_studio_router": APIRouter(), + "mcp_servers_router": APIRouter(), + "models_router": APIRouter(), + "providers_router": APIRouter(), + "settings_router": settings_module.router, + "training_history_router": APIRouter(), + "training_router": APIRouter(), + }.items(): + setattr(routes_module, name, router) + routes_module.settings = settings_module + + monkeypatch.setitem(sys.modules, "routes", routes_module) + monkeypatch.setitem(sys.modules, "routes.settings", settings_module) import studio.backend.main as backend_main diff --git a/studio/backend/tests/test_middleware.py b/studio/backend/tests/test_middleware.py index bbaf20298d..5e5b5a3c3b 100644 --- a/studio/backend/tests/test_middleware.py +++ b/studio/backend/tests/test_middleware.py @@ -33,12 +33,19 @@ def main_module(): # ===================================================================== -def _make_protected_app(max_bytes: int, main_module): +def _make_protected_app( + max_bytes: int, + main_module, + upload_passthrough_prefixes: tuple = (), + upload_passthrough_max_bytes_getter = None, +): app = FastAPI() app.add_middleware( main_module.MaxBodyMiddleware, - max_bytes = max_bytes, - protected_prefixes = ("/v1/chat/completions", "/api/train"), + max_bytes_getter = lambda: max_bytes, + protected_prefixes = ("/v1/chat/completions", "/api/settings", "/api/train"), + upload_passthrough_prefixes = upload_passthrough_prefixes, + upload_passthrough_max_bytes_getter = upload_passthrough_max_bytes_getter, ) @app.post("/v1/chat/completions") @@ -49,6 +56,20 @@ def _make_protected_app(max_bytes: int, main_module): async def other(payload: dict): return {"ok": True, "unprotected": True} + @app.put("/api/settings/upload-limit") + async def update_upload_limit(payload: dict): + return {"ok": True, "limit": payload.get("max_upload_size_mb")} + + @app.post("/api/train/upload") + async def upload(request: Request): + total = 0 + chunks = 0 + async for chunk in request.stream(): + if chunk: + chunks += 1 + total += len(chunk) + return {"ok": True, "chunks": chunks, "total": total} + @app.get("/api/train/status") async def status_get(): return {"ok": True, "get": True} @@ -78,6 +99,16 @@ class TestMaxBodyMiddleware: assert r.status_code == 200 assert r.json()["unprotected"] is True + def test_settings_put_body_over_cap_rejected(self, main_module): + app = _make_protected_app(1024, main_module) + c = TestClient(app) + r = c.put( + "/api/settings/upload-limit", + json = {"max_upload_size_mb": 500, "padding": "x" * 5000}, + ) + assert r.status_code == 413 + assert "too large" in r.json()["detail"].lower() + def test_chunked_upload_over_cap_rejected(self, main_module): # Regression: declared-Content-Length-only check could be bypassed # by chunked transfer-encoding. @@ -121,6 +152,61 @@ class TestMaxBodyMiddleware: r = c.get("/api/train/status") assert r.status_code == 200 + def test_upload_passthrough_uses_dedicated_declared_cap(self, main_module): + app = _make_protected_app( + 128, + main_module, + upload_passthrough_prefixes = ("/api/train/upload",), + upload_passthrough_max_bytes_getter = lambda: 1024, + ) + c = TestClient(app) + r = c.post( + "/api/train/upload", + content = b"x" * 512, + headers = {"content-type": "application/octet-stream"}, + ) + assert r.status_code == 200 + assert r.json()["total"] == 512 + + def test_upload_passthrough_rejects_declared_body_over_dedicated_cap( + self, main_module + ): + app = _make_protected_app( + 128, + main_module, + upload_passthrough_prefixes = ("/api/train/upload",), + upload_passthrough_max_bytes_getter = lambda: 256, + ) + c = TestClient(app) + r = c.post( + "/api/train/upload", + content = b"x" * 512, + headers = {"content-type": "application/octet-stream"}, + ) + assert r.status_code == 413 + assert "256" in r.json()["detail"] + + def test_upload_passthrough_requires_content_length(self, main_module): + app = _make_protected_app( + 128, + main_module, + upload_passthrough_prefixes = ("/api/train/upload",), + upload_passthrough_max_bytes_getter = lambda: 1024, + ) + c = TestClient(app) + + def gen(): + yield b"x" * 64 + yield b"y" * 64 + + r = c.post( + "/api/train/upload", + content = gen(), + headers = {"content-type": "application/octet-stream"}, + ) + assert r.status_code == 411 + assert "Content-Length" in r.json()["detail"] + # ===================================================================== # SecurityHeadersMiddleware / CSP @@ -174,7 +260,10 @@ class TestSecurityHeadersMiddleware: assert r.headers["x-frame-options"] == "DENY" assert r.headers["x-content-type-options"] == "nosniff" assert r.headers["referrer-policy"] == "no-referrer" - assert "camera=()" in r.headers["permissions-policy"] + permissions_policy = r.headers["permissions-policy"] + assert "camera=()" in permissions_policy + assert "microphone=(self)" in permissions_policy + assert "geolocation=()" in permissions_policy assert r.headers["server"] == "unsloth-studio" def test_internal_nonce_header_is_spliced_into_csp_and_stripped(self, main_module): diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py index 57007a5f66..cd8957c3dd 100644 --- a/studio/backend/tests/test_sandbox_tools.py +++ b/studio/backend/tests/test_sandbox_tools.py @@ -345,8 +345,9 @@ class TestSandboxCpuRlimitDefault: class TestMaxBodyDefault: def test_default_is_500_mb(self): - src = (_BACKEND_ROOT / "main.py").read_text() - assert 'UNSLOTH_STUDIO_MAX_BODY_MB", "500"' in src + src = (_BACKEND_ROOT / "utils" / "upload_limits.py").read_text() + assert "DEFAULT_UPLOAD_LIMIT_MB = 500" in src + assert "UNSLOTH_STUDIO_MAX_BODY_MB" in src class TestBashBlocklistPosition: diff --git a/studio/backend/utils/datasets/dataset_none_detect.py b/studio/backend/utils/datasets/dataset_none_detect.py new file mode 100644 index 0000000000..1e884271cc --- /dev/null +++ b/studio/backend/utils/datasets/dataset_none_detect.py @@ -0,0 +1,888 @@ +""" +dataset_none_detect.py + +Detect None/empty content turns in conversation datasets. +Reports findings without modifying data. + +Usage: + from .dataset_none_detect import scan_dataset, print_report + stats = scan_dataset(dataset) # auto-detect + scan + stats = scan_dataset(dataset, fmt="chatml") # explicit format + print_report(stats, stats["format"]) + +Dependencies: only `datasets` (already in studio/unsloth) + stdlib. + +Supported formats (via FORMAT_REGISTRY): + alpaca instruction/output instruction + output must be set + chatml messages/conversations/texts role + content per turn + sharegpt conversations from/value per turn + gptoss messages (alias: gpt-oss) role/content; has a developer turn + +Any role/content chat template matches the chatml entry, so new templates need +no change; add a FORMAT_REGISTRY entry only for a genuinely new column/turn shape. +""" + +from datasets import Dataset + +# --------------------------------------------------------------------------- +# Conversation column probing (shared by detection + scanning) +# --------------------------------------------------------------------------- + +# Candidate column names for conversational datasets, checked in priority order. +CONVERSATION_COLUMNS = ("messages", "conversations", "texts") + +# Minimum turn key sets that identify a column as conversational (not e.g. messages=[{"id":1}]). +_CHAT_KEY_SETS = (frozenset({"role", "content"}), frozenset({"from", "value"})) + + +def _probe_conversation(dataset: Dataset, candidates = None): + """ + Probe a dataset for its conversation column and turn structure. + + candidates - iterable of column names to try, in priority order. + Defaults to CONVERSATION_COLUMNS when None. + + Returns a dict with: + column - name of the conversation column found + turn_keys - set of keys present in the first turn dict + roles - set of all role values seen across the first few samples + + Returns None if no conversation column is found. + """ + if candidates is None: + candidates = CONVERSATION_COLUMNS + columns = set(dataset.column_names) + # Remember the first all-corrupt candidate, but keep probing: a later column + # may be healthy and should win (e.g. bad messages, good conversations). + all_corrupt_fallback = None + for col in candidates: + if col not in columns: + continue + # Scan up to 100 rows - row 0 alone may be empty or malformed. + first = None + for i in range(min(len(dataset), 100)): + sample = dataset[i][col] + if not isinstance(sample, list) or len(sample) == 0: + continue + # Skip non-dict leading turns (e.g. [None, {"role": ...}]). + first_turn = next((t for t in sample if isinstance(t, dict)), None) + if first_turn is not None: + first = first_turn + break + if first is None: + # No usable dict turn in 100 rows. Record an all_corrupt fallback, + # marking it plausible only if we saw turn-shaped data (a None cell or + # a list holding a dict/None turn); scalars and list-of-strings must + # not look like chatml. Upgrade a non-plausible fallback when a later + # candidate is plausible, so probe order keeps the best match. + if all_corrupt_fallback is None or not all_corrupt_fallback.get( + "has_plausible_turns" + ): + has_plausible_turns = False + for i in range(min(len(dataset), 100)): + cell = dataset[i][col] + if cell is None: + has_plausible_turns = True + break + # A struct-typed cell (single dict, not a list) is metadata, + # not chat: leave it for "unknown format", matching + # format_detection.py. + if isinstance(cell, list): + # Plausible only if the list holds a dict/None turn; empty + # lists and list-of-strings are not chat data. + if any(t is None or isinstance(t, dict) for t in cell): + has_plausible_turns = True + break + all_corrupt_fallback = { + "column": col, + "turn_keys": set(), + "roles": set(), + "all_corrupt": True, + "has_plausible_turns": has_plausible_turns, + } + continue + + # Use the same 100-row window to gather keys/roles. + turn_keys = set() + roles = set() + for i in range(min(len(dataset), 100)): + conv = dataset[i][col] + if isinstance(conv, list): + for t in conv: + if isinstance(t, dict): + turn_keys.update(t.keys()) + r = t.get("role") or t.get("from") + if r: + roles.add(str(r)) + # Column lacks a full chat key pair. If it still has a conversational key + # (role/from/content/value) it is a corrupt-but-real chat column, so save + # a plausible fallback for find_none_chatml to flag. Pure metadata (e.g. + # [{"id":1}]) is not plausible, so a later real-but-corrupt column (e.g. + # conversations=None) can still win. + _CONV_KEYS = {"role", "from", "content", "value"} + if not any(keys <= turn_keys for keys in _CHAT_KEY_SETS): + schema_less_plausible = bool(turn_keys & _CONV_KEYS) + if all_corrupt_fallback is None or not all_corrupt_fallback.get( + "has_plausible_turns" + ): + all_corrupt_fallback = { + "column": col, + "turn_keys": turn_keys, + "roles": roles, + "all_corrupt": True, + "has_plausible_turns": schema_less_plausible, + } + continue + return {"column": col, "turn_keys": turn_keys, "roles": roles} + # No healthy column found; return the all_corrupt fallback if any. + return all_corrupt_fallback + + +# None-detection helpers +# --------------------------------------------------------------------------- + + +def is_none_or_empty(value) -> bool: + """True if value is None, empty string, whitespace-only, or an empty/whitespace-only VLM content block list.""" + if value is None: + return True + if isinstance(value, str): + # Treat zero-width/BOM chars (U+FEFF/200B/200C/200D/2060) as empty too; + # they render invisibly. Two-pass strip (ws, invisibles, ws) catches + # mixed cases like "\u200b \u200b". + stripped = value.strip().strip("\ufeff\u200b\u200c\u200d\u2060").strip() + if not stripped: + return True + if isinstance(value, list): + # VLM content blocks, e.g. [{"type":"text",...}, {"type":"image",...}]. + # Empty list -> empty. A non-text block (image/audio/tool) is real + # content; only flag when every text block is blank and no such block + # exists (an image-only turn is valid). + if len(value) == 0: + return True + # No dict blocks at all (e.g. [None], [' ']) -> malformed/empty. + dict_blocks = [item for item in value if isinstance(item, dict)] + if not dict_blocks: + return True + non_text_blocks = [item for item in dict_blocks if item.get("type") != "text"] + if non_text_blocks: + return False + text_values = [ + item.get("text") for item in dict_blocks if item.get("type") == "text" + ] + if text_values and all( + t is None + or ( + isinstance(t, str) + and not t.strip().strip("\ufeff\u200b\u200c\u200d\u2060").strip() + ) + for t in text_values + ): + return True + return False + + +def _classify_empty(value) -> str: + """Return a human-readable label for why this value is considered empty.""" + if value is None: + return "None" + if isinstance(value, str): + if len(value) == 0: + return "empty_string" + # Only-whitespace or only-invisible (BOM/zero-width) strings render empty. + if not value.strip().strip("\ufeff\u200b\u200c\u200d\u2060").strip(): + return "whitespace_only" + if isinstance(value, list): + # Mirrors the VLM/OpenAI content-block handling in is_none_or_empty. + if len(value) == 0: + return "empty_list" + return "empty_vlm_content" + return "valid" # should not reach here if is_none_or_empty was True + + +# --------------------------------------------------------------------------- +# Alpaca detection +# --------------------------------------------------------------------------- + + +def find_none_alpaca(dataset: Dataset) -> dict: + """ + Scan alpaca dataset for None/empty instruction or output fields. + Returns stats dict with a detailed 'findings' list. + """ + stats = { + "total_rows": len(dataset), + "none_instruction": 0, + "none_output": 0, + "bad_row_indices": [], + "findings": [], # [{row, field, value_type, raw_value}, ...] + } + + for i, row in enumerate(dataset): + bad = False + for field in ("instruction", "output"): + val = row.get(field) + if is_none_or_empty(val): + stats[f"none_{field}"] = stats.get(f"none_{field}", 0) + 1 + bad = True + stats["findings"].append( + { + "row_index": i, + "field": field, + "value_type": _classify_empty(val), + "raw_value": repr(val), + } + ) + if bad: + stats["bad_row_indices"].append(i) + + return stats + + +# --------------------------------------------------------------------------- +# ChatML / conversational detection +# --------------------------------------------------------------------------- + + +def find_none_chatml(dataset: Dataset, col: str = None) -> dict: + """ + Scan chatml/sharegpt/gptoss dataset for turns with None/empty content. + Auto-detects the conversation column if col=None. + + Returns a stats dict that includes a complete 'findings' list - one entry + per bad turn with row_index, turn_index, role, value_type, and raw_value. + """ + if col is None: + # Reuse _probe_conversation so the all_corrupt path is handled here too. + _cinfo = _probe_conversation(dataset) + if _cinfo is not None: + col = _cinfo["column"] + + if col is None or col not in dataset.column_names: + raise ValueError( + f"No conversation column found. " + f"Expected one of {CONVERSATION_COLUMNS}, got columns: {dataset.column_names}" + ) + + stats = { + "total_rows": len(dataset), + "column": col, + "rows_with_none_turns": 0, + "total_none_turns": 0, + "none_by_role": {}, # role -> count of None turns + "none_by_type": {}, # "None" | "empty_string" | "whitespace_only" -> count + "rows_all_none": 0, # rows where every turn is bad + "bad_row_indices": [], # every row index that has at least one bad turn + "findings": [], # detailed per-turn list + } + + for i, row in enumerate(dataset): + conversation = row[col] + if not isinstance(conversation, list): + # Non-list conversation: unusable for training, flag as bad. + vtype = "None" if conversation is None else "invalid_type" + stats["bad_row_indices"].append(i) + stats["rows_with_none_turns"] += 1 + stats["total_none_turns"] += 1 + stats["rows_all_none"] += 1 + stats["none_by_role"]["unknown"] = ( + stats["none_by_role"].get("unknown", 0) + 1 + ) + stats["none_by_type"][vtype] = stats["none_by_type"].get(vtype, 0) + 1 + stats["findings"].append( + { + "row_index": i, + "turn_index": 0, + "role": "unknown", + "value_type": vtype, + "raw_value": repr(conversation), + } + ) + continue + + if len(conversation) == 0: + # Zero-turn conversation: flag so it does not scan as clean. + stats["bad_row_indices"].append(i) + stats["rows_with_none_turns"] += 1 + stats["total_none_turns"] += 1 + stats["rows_all_none"] += 1 + stats["none_by_role"]["unknown"] = ( + stats["none_by_role"].get("unknown", 0) + 1 + ) + stats["none_by_type"]["empty_conversation"] = ( + stats["none_by_type"].get("empty_conversation", 0) + 1 + ) + stats["findings"].append( + { + "row_index": i, + "turn_index": 0, + "role": "unknown", + "value_type": "empty_conversation", + "raw_value": "[]", + } + ) + continue + + row_findings = [] + for turn_idx, turn in enumerate(conversation): + # Non-dict turn - record it rather than crash or silently skip. + if not isinstance(turn, dict): + row_findings.append( + { + "row_index": i, + "turn_index": turn_idx, + "role": "unknown", + "value_type": "None" if turn is None else "invalid_type", + "raw_value": repr(turn), + } + ) + stats["none_by_role"]["unknown"] = ( + stats["none_by_role"].get("unknown", 0) + 1 + ) + vtype = "None" if turn is None else "invalid_type" + stats["none_by_type"][vtype] = stats["none_by_type"].get(vtype, 0) + 1 + continue + # Explicit None check so falsy roles (0, "", False) are kept, not + # collapsed to "unknown". + r = turn.get("role") + if r is None: + r = turn.get("from") + if r is None: + role = "unknown" + elif isinstance(r, str): + role = r + else: + role = str(r) + # Pick the content key: from+value -> value (ShareGPT, even if role is + # also set); role -> content (or value); from only -> value (None when + # missing, so it is flagged); neither -> content then value. + if "from" in turn and "value" in turn: + content = turn.get("value") + elif "role" in turn: + content = ( + turn.get("content") if "content" in turn else turn.get("value") + ) + elif "from" in turn: + content = turn.get("value") + else: + content = ( + turn.get("content") if "content" in turn else turn.get("value") + ) + # Assistant tool-call turns carry empty content + tool_calls and are + # valid; the exemption is assistant-only. + if is_none_or_empty(content) and not ( + role == "assistant" and turn.get("tool_calls") + ): + vtype = _classify_empty(content) + row_findings.append( + { + "row_index": i, + "turn_index": turn_idx, + "role": role, + "value_type": vtype, + "raw_value": repr(content), + } + ) + stats["none_by_role"][role] = stats["none_by_role"].get(role, 0) + 1 + stats["none_by_type"][vtype] = stats["none_by_type"].get(vtype, 0) + 1 + + if row_findings: + stats["rows_with_none_turns"] += 1 + stats["total_none_turns"] += len(row_findings) + stats["bad_row_indices"].append(i) + stats["findings"].extend(row_findings) + + if len(row_findings) == len(conversation): + stats["rows_all_none"] += 1 + + return stats + + +# --------------------------------------------------------------------------- +# Convenience wrappers per format (all delegate to the same scan logic) +# --------------------------------------------------------------------------- + + +def find_none_sharegpt(dataset: Dataset, col: str = None) -> dict: + """ShareGPT uses 'from'/'value' keys - same scan logic handles both.""" + if col is None: + # ShareGPT lives in 'conversations'; probe only that column so a corrupt + # one is still scanned, not replaced by a healthy 'messages' (P1 fix). + conv_info = _probe_conversation(dataset, candidates = ("conversations",)) + if conv_info is None: + raise ValueError( + f"No valid conversation column found in {dataset.column_names}. " + "Expected a 'conversations' column with 'from'/'value' or 'role'/'content' turn keys." + ) + col = conv_info["column"] + return find_none_chatml(dataset, col = col) + + +def find_none_gptoss(dataset: Dataset, col: str = None) -> dict: + """gptoss: role/content plus optional thinking/tool_calls. Only content checked.""" + if col is None: + # gptoss lives in 'messages': target it whenever present (even if + # corrupt), and fall back to 'conversations' only if 'messages' is absent. + if "messages" in dataset.column_names: + conv_info = _probe_conversation(dataset, candidates = ("messages",)) + else: + conv_info = _probe_conversation(dataset, candidates = ("conversations",)) + if conv_info is None: + raise ValueError( + f"No valid conversation column found in {dataset.column_names}. " + "Expected a 'messages' or 'conversations' column with 'role'/'content' turn keys." + ) + col = conv_info["column"] + return find_none_chatml(dataset, col = col) + + +# --------------------------------------------------------------------------- +# Format registry - first match wins; detect_format() auto-scales. +# Each entry: name (label/--format value), match(dataset, conv_info) -> bool, +# scan (find_none_* function). Put specific formats before generalisations +# (gptoss before chatml, since gptoss is chatml with a 'developer' role). +# To add a format: write find_none_() (or reuse find_none_chatml) and +# append an entry; detect_format(), --format, and scan_dataset() pick it up. +# --------------------------------------------------------------------------- + +FORMAT_REGISTRY = [ + { + "name": "alpaca", + # instruction/output present and no usable chat column: either none + # exists, or the only one is fully corrupt (e.g. a stray all-None or + # metadata `messages` column). A healthy chat column falls through to + # the conversational scanners below. + "match": lambda ds, conv: ( + {"instruction", "output"}.issubset(ds.column_names) + and (conv is None or conv.get("all_corrupt")) + ), + "scan": find_none_alpaca, + }, + { + "name": "gptoss", + "match": lambda ds, conv: ( + conv is not None + and {"role", "content"} <= conv["turn_keys"] + and "developer" in conv["roles"] + ), + "scan": find_none_gptoss, + }, + { + "name": "sharegpt", + "match": lambda ds, conv: ( + conv is not None and {"from", "value"} <= conv["turn_keys"] + ), + "scan": find_none_sharegpt, + }, + { + "name": "chatml", + "match": lambda ds, conv: ( + conv is not None + and ( + {"role", "content"} <= conv["turn_keys"] + # all_corrupt: column found but every row malformed; require + # has_plausible_turns so scalar/string columns are not chatml. + or (conv.get("all_corrupt") and conv.get("has_plausible_turns")) + ) + ), + "scan": find_none_chatml, + }, +] + +# Derived list of known format names (used by CLI --format choices). +FORMAT_NAMES = [entry["name"] for entry in FORMAT_REGISTRY] + +# Documented aliases accepted by both the Python API and the CLI. +FORMAT_ALIASES = {"gpt-oss": "gptoss"} + + +def detect_format(dataset: Dataset) -> str: + """ + Auto-detect dataset format by probing columns and turn structure. + + Returns one of the format names in FORMAT_REGISTRY, or 'unknown'. + Walks the registry in order; first match wins. + """ + conv_info = _probe_conversation(dataset) + for entry in FORMAT_REGISTRY: + if entry["match"](dataset, conv_info): + return entry["name"] + return "unknown" + + +def get_scanner(fmt: str): + """Return the scanner function for a format name, or None if unknown.""" + for entry in FORMAT_REGISTRY: + if entry["name"] == fmt: + return entry["scan"] + return None + + +def scan_dataset(dataset: Dataset, fmt: str = "auto") -> dict: + """ + One-liner: detect format (if 'auto') and scan for None/empty content. + + Returns the stats dict with an added 'format' key. + Raises ValueError if the format is unknown or unsupported. + """ + # Reject a DatasetDict / IterableDatasetDict (load_dataset without split): + # its column_names is a split map and would yield a confusing "unknown + # format". Check both (IterableDatasetDict is not a DatasetDict subclass); + # import locally so this module never hard-requires those symbols. + _dict_types = [] + try: + from datasets import DatasetDict as _DatasetDict + + _dict_types.append(_DatasetDict) + except ImportError: + pass + try: + from datasets import IterableDatasetDict as _IterableDatasetDict + + _dict_types.append(_IterableDatasetDict) + except ImportError: + pass + if _dict_types and isinstance(dataset, tuple(_dict_types)): + raise ValueError( + "scan_dataset requires a single Dataset split, not a DatasetDict. " + f"Available splits: {list(dataset.keys())}. " + "Pass dataset[] or use load_dataset(..., split='train')." + ) + # Streaming IterableDataset has no len()/column_names; give a clear error + # instead of a confusing TypeError downstream. + try: + from datasets import IterableDataset as _IterableDataset + + if isinstance(dataset, _IterableDataset): + raise ValueError( + "scan_dataset requires a materialized Dataset, not an IterableDataset. " + "Load without streaming=True, or materialize a slice first: " + "Dataset.from_list(list(dataset.take(N)))." + ) + except ImportError: + pass + fmt = FORMAT_ALIASES.get(fmt, fmt) + was_auto = fmt == "auto" + # Zero-row dataset: return a trivially clean stats dict. + if was_auto and len(dataset) == 0: + return { + "format": "unknown", + "total_rows": 0, + "findings": [], + "bad_row_indices": [], + } + # Always probe so detection and column selection share one scan pass. + conv_info = _probe_conversation(dataset) + if was_auto: + fmt = "unknown" + for entry in FORMAT_REGISTRY: + if entry["match"](dataset, conv_info): + fmt = entry["name"] + break + # No format matched: return clean stats (format="unknown") rather than + # raise, so callers can branch on stats["format"]. + if fmt == "unknown": + return { + "format": "unknown", + "total_rows": len(dataset), + "findings": [], + "bad_row_indices": [], + } + scanner = get_scanner(fmt) + if scanner is None: + raise ValueError(f"Unknown or unsupported format: '{fmt}'") + # Column forwarding: on auto-detect pass the probed column (already the best + # choice). On an explicit format let that scanner pick its own column, so + # e.g. fmt='sharegpt' always scans 'conversations', not 'messages' (P1 fix); + # gptoss has its own messages-first rule. alpaca never takes a column. + use_probed_col = conv_info is not None and fmt != "alpaca" and was_auto + if use_probed_col: + stats = scanner(dataset, col = conv_info["column"]) + else: + stats = scanner(dataset) + stats["format"] = fmt + return stats + + +# --------------------------------------------------------------------------- +# Report printing +# --------------------------------------------------------------------------- + + +def _print_summary_header(stats: dict, fmt: str) -> bool: + """Print the top-level stats block (shared by all report modes). Returns True if findings exist.""" + total = stats["total_rows"] + findings = stats.get("findings", []) + + print(f"\n{'=' * 64}") + print(f" None / Empty Detection Report") + print(f"{'=' * 64}") + print(f" Format: {fmt}") + print(f" Total rows: {total}") + + if not findings: + if fmt == "unknown": + print(f" Result: NOT SCANNED -- format could not be detected") + else: + print(f" Result: CLEAN -- no None or empty values found") + print(f"{'=' * 64}") + return False + + if fmt == "alpaca": + bad_rows = len(stats.get("bad_row_indices", [])) + print(f" Rows with Nones: {bad_rows} / {total}") + print(f" None instruction: {stats.get('none_instruction', 0)}") + print(f" None output: {stats.get('none_output', 0)}") + else: + col = stats.get("column", "?") + print(f" Column: {col}") + print(f" Rows with bad turns: {stats['rows_with_none_turns']} / {total}") + print(f" Total bad turns: {len(findings)}") + print(f" By type: {stats.get('none_by_type', {})}") + print(f" By role: {stats.get('none_by_role', {})}") + rows_all = stats.get("rows_all_none", 0) + if rows_all: + print(f" Rows ALL bad: {rows_all} (every turn is None/empty)") + + # Rows with no Nones - compute the count directly instead of allocating a + # full set of row indices, which OOMs on large (10M+ row) datasets. + bad_indices = set(stats.get("bad_row_indices", [])) + clean_count = total - len(bad_indices) + if 0 < clean_count <= 20: + clean_indices = [i for i in range(total) if i not in bad_indices] + print(f" Rows with no Nones: {clean_count} / {total} {clean_indices}") + else: + print(f" Rows with no Nones: {clean_count} / {total}") + + print(f"{'=' * 64}") + return True + + +def print_report(stats: dict, fmt: str, summary_only: bool = False): + """Print a human-readable summary, optionally with full findings list.""" + has_findings = _print_summary_header(stats, fmt) + if not has_findings or summary_only: + return + + findings = stats.get("findings", []) + print(f"\n {'-' * 60}") + print(f" Findings ({len(findings)} total):") + print(f" {'-' * 60}") + + for f in findings: + if fmt == "alpaca": + print( + f" row {f['row_index']:>5d} " + f"field={f['field']:<12s} " + f"type={f['value_type']:<16s} " + f"raw={f['raw_value']}" + ) + else: + print( + f" row {f['row_index']:>5d} " + f"turn {f['turn_index']} " + f"role={str(f['role']):<12s} " + f"type={f['value_type']:<16s} " + f"raw={f['raw_value']}" + ) + + print(f"{'=' * 64}") + + +def show_row(dataset: Dataset, row_indices: list[int], fmt: str, col: str = None): + """Print the full contents of specific rows for inspection. + + Used by test_codex_fixes.py to verify row rendering behaviour. + Not part of the production API. + """ + if col is None: + for candidate in ("messages", "conversations", "texts"): + if candidate in dataset.column_names: + col = candidate + break + + for ri in row_indices: + if ri < 0 or ri >= len(dataset): + print(f"\n [ERROR] Row {ri} out of range (0-{len(dataset)-1})") + continue + + row = dataset[ri] + print(f"\n{'=' * 64}") + print(f" Row {ri}") + print(f"{'=' * 64}") + + # Print non-conversation columns. For alpaca, skip the fields the + # alpaca block below prints with status markers (avoid double render). + _ALPACA_FIELDS = {"instruction", "input", "output"} + for key in dataset.column_names: + if key == col: + continue + if fmt == "alpaca" and key in _ALPACA_FIELDS: + continue + val = row[key] + if isinstance(val, str) and len(val) > 120: + val = val[:120] + "..." + print(f" {key}: {val}") + + if fmt == "alpaca": + for field in ("instruction", "input", "output"): + val = row.get(field) + status = " [NONE]" if is_none_or_empty(val) else "" + if val and len(str(val)) > 200: + val = str(val)[:200] + "..." + print(f" {field}: {val}{status}") + elif col: + conversation = row[col] + if isinstance(conversation, list): + + def _is_bad_turn(t): + if not isinstance(t, dict): + return True + # Mirror scanner logic: from+value wins, then role, then from alone. + if "from" in t and "value" in t: + c = t.get("value") + elif "role" in t: + c = t.get("content") if "content" in t else t.get("value") + elif "from" in t: + c = t.get("value") + else: + c = t.get("content") if "content" in t else t.get("value") + # Mirror scanner logic: tool_calls exemption is assistant-only; + # other roles with empty content + tool_calls are still bad. + r = t.get("role") if t.get("role") is not None else t.get("from") + if is_none_or_empty(c) and not ( + str(r) == "assistant" and t.get("tool_calls") + ): + return True + return False + + none_count = sum(1 for t in conversation if _is_bad_turn(t)) + print(f" {col}: {len(conversation)} turns ({none_count} None)") + print(f" {'-' * 60}") + for i, turn in enumerate(conversation): + # Non-dict turn - can't extract role or content normally. + if not isinstance(turn, dict): + label = "None" if turn is None else "invalid_type" + print(f" [{i:>3d}] {'unknown':<12s} [{label}] << NONE") + continue + r = turn.get("role") + if r is None: + r = turn.get("from") + role = "?" if r is None else str(r) + # Mirror scanner logic: from+value wins, then role, then from alone. + if "from" in turn and "value" in turn: + content = turn.get("value") + elif "role" in turn: + content = ( + turn.get("content") + if "content" in turn + else turn.get("value") + ) + elif "from" in turn: + content = turn.get("value") + else: + content = ( + turn.get("content") + if "content" in turn + else turn.get("value") + ) + if is_none_or_empty(content) and not ( + role == "assistant" and turn.get("tool_calls") + ): + status = " << NONE" + else: + status = "" + if content is None: + preview = "None" + else: + preview_str = str(content) # cast: content may not be a string + if len(preview_str) > 150: + preview = preview_str[:150].replace("\n", "\\n") + "..." + else: + preview = preview_str.replace("\n", "\\n") + print(f" [{i:>3d}] {role:<12s} {preview}{status}") + + print(f"{'=' * 64}") + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import argparse + import os + import sys + + parser = argparse.ArgumentParser( + prog = "dataset_none_detect", + description = "Scan a HuggingFace dataset for None/empty content turns.", + formatter_class = argparse.RawDescriptionHelpFormatter, + epilog = """ +examples: + python dataset_none_detect.py org/my-dataset + python dataset_none_detect.py org/my-dataset --split train + python dataset_none_detect.py org/my-dataset --format sharegpt + python dataset_none_detect.py org/my-dataset --summary-only + python dataset_none_detect.py org/my-dataset --token hf_... + """, + ) + parser.add_argument( + "dataset", help = "HuggingFace dataset repo id (e.g. org/my-dataset)" + ) + parser.add_argument( + "--split", default = "train", help = "Dataset split to load (default: train)" + ) + parser.add_argument( + "--format", + default = "auto", + choices = ["auto"] + FORMAT_NAMES + list(FORMAT_ALIASES), + help = "Force a specific format instead of auto-detecting (default: auto). " + "Documented aliases (e.g. 'gpt-oss' for 'gptoss') are also accepted.", + ) + parser.add_argument( + "--summary-only", + action = "store_true", + help = "Print summary header only - skip the per-turn findings list", + ) + parser.add_argument( + "--token", + default = os.environ.get("HF_TOKEN"), + help = ( + "HuggingFace API token for private datasets (default: $HF_TOKEN). " + "Prefer setting $HF_TOKEN; passing --token on the command line " + "exposes it in process listings." + ), + ) + args = parser.parse_args() + + try: + from datasets import load_dataset + except ImportError: + print( + "Error: 'datasets' package not found. Install with: pip install datasets", + file = sys.stderr, + ) + sys.exit(1) + + print(f"Loading {args.dataset!r} (split={args.split!r})...") + try: + ds = load_dataset(args.dataset, split = args.split, token = args.token) + except Exception as exc: + # Some `datasets` / `requests` versions include the Authorization + # header in exception messages. Redact the token before printing. + msg = str(exc) + if args.token: + msg = msg.replace(args.token, "hf_***REDACTED***") + print(f"Error loading dataset: {msg}", file = sys.stderr) + sys.exit(1) + + print(f"Loaded {len(ds)} rows, columns: {ds.column_names}") + + try: + stats = scan_dataset(ds, fmt = args.format) + except ValueError as exc: + print(f"Error: {exc}", file = sys.stderr) + sys.exit(1) + + print_report(stats, stats["format"], summary_only = args.summary_only) diff --git a/studio/backend/utils/paths/__init__.py b/studio/backend/utils/paths/__init__.py index 92191dccdd..eba7a9de6c 100644 --- a/studio/backend/utils/paths/__init__.py +++ b/studio/backend/utils/paths/__init__.py @@ -25,6 +25,8 @@ from .storage_roots import ( auth_root, auth_db_path, studio_db_path, + documents_root, + project_workspaces_root, tmp_root, seed_uploads_root, unstructured_seed_cache_root, @@ -44,6 +46,10 @@ from .storage_roots import ( resolve_dataset_path, ) +# Re-export shim: name-load the project-path helpers so the import-hoist +# safety net sees them used here, not just listed in __all__ as strings. +_REEXPORTED = (documents_root, project_workspaces_root) + __all__ = [ "normalize_path", "is_local_path", @@ -62,6 +68,8 @@ __all__ = [ "auth_root", "auth_db_path", "studio_db_path", + "documents_root", + "project_workspaces_root", "tmp_root", "seed_uploads_root", "unstructured_seed_cache_root", diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py index 763d18bf3e..6319452ed2 100644 --- a/studio/backend/utils/paths/storage_roots.py +++ b/studio/backend/utils/paths/storage_roots.py @@ -96,6 +96,38 @@ def studio_db_path() -> Path: return studio_root() / "studio.db" +def _xdg_user_dir(key: str) -> Path | None: + config = Path.home() / ".config" / "user-dirs.dirs" + try: + lines = config.read_text(encoding = "utf-8").splitlines() + except OSError: + return None + prefix = f"{key}=" + for line in lines: + line = line.strip() + if not line.startswith(prefix): + continue + value = line[len(prefix) :].strip().strip('"') + if not value: + return None + return Path(value.replace("$HOME", str(Path.home()))).expanduser() + return None + + +def documents_root() -> Path: + override = (os.environ.get("UNSLOTH_STUDIO_DOCUMENTS_HOME") or "").strip() + if override: + return Path(override).expanduser() + return _xdg_user_dir("XDG_DOCUMENTS_DIR") or (Path.home() / "Documents") + + +def project_workspaces_root() -> Path: + override = (os.environ.get("UNSLOTH_STUDIO_PROJECTS_HOME") or "").strip() + if override: + return Path(override).expanduser() + return documents_root() / "Unsloth Studio" / "Projects" + + def tmp_root() -> Path: return Path(tempfile.gettempdir()) / "unsloth-studio" diff --git a/studio/backend/utils/upload_limits.py b/studio/backend/utils/upload_limits.py new file mode 100644 index 0000000000..b8a6a2474b --- /dev/null +++ b/studio/backend/utils/upload_limits.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Shared Studio upload/request size limits.""" + +from __future__ import annotations + +import os +from typing import Any + +UPLOAD_LIMIT_SETTING_KEY = "max_upload_size_mb" +DEFAULT_UPLOAD_LIMIT_MB = 500 +MIN_UPLOAD_LIMIT_MB = 1 +MAX_UPLOAD_LIMIT_MB = 8192 +_BYTES_PER_MB = 1024 * 1024 +MULTIPART_OVERHEAD_BYTES = 10 * _BYTES_PER_MB + +LOCAL_SEED_UPLOAD_MAX_BYTES = 100 * _BYTES_PER_MB +LOCAL_SEED_UPLOAD_MAX_LABEL = "100MB" +UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES = 500 * _BYTES_PER_MB +UNSTRUCTURED_RECIPE_UPLOAD_MAX_LABEL = "500MB" +UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES = 1024 * _BYTES_PER_MB +UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL = "1GB" + + +def _coerce_upload_limit_mb(value: Any) -> int | None: + if isinstance(value, bool): + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + if parsed < MIN_UPLOAD_LIMIT_MB or parsed > MAX_UPLOAD_LIMIT_MB: + return None + return parsed + + +def default_upload_limit_mb() -> int: + env_value = _coerce_upload_limit_mb(os.environ.get("UNSLOTH_STUDIO_MAX_BODY_MB")) + return env_value or DEFAULT_UPLOAD_LIMIT_MB + + +def validate_upload_limit_mb(value: Any) -> int: + parsed = _coerce_upload_limit_mb(value) + if parsed is None: + raise ValueError( + f"Upload limit must be a whole number from {MIN_UPLOAD_LIMIT_MB} to {MAX_UPLOAD_LIMIT_MB} MB." + ) + return parsed + + +def get_upload_limit_mb() -> int: + try: + from storage.studio_db import get_app_setting + + stored = get_app_setting(UPLOAD_LIMIT_SETTING_KEY, None) + except Exception: + stored = None + return _coerce_upload_limit_mb(stored) or default_upload_limit_mb() + + +def set_upload_limit_mb(value: Any) -> int: + parsed = validate_upload_limit_mb(value) + from storage.studio_db import upsert_app_settings + + upsert_app_settings({UPLOAD_LIMIT_SETTING_KEY: parsed}) + return parsed + + +def upload_limit_bytes(limit_mb: int | None = None) -> int: + return (limit_mb if limit_mb is not None else get_upload_limit_mb()) * _BYTES_PER_MB + + +def get_upload_limit_bytes() -> int: + return upload_limit_bytes() + + +def upload_limit_label(limit_mb: int | None = None) -> str: + return f"{limit_mb if limit_mb is not None else get_upload_limit_mb()}MB" + + +def get_upload_limit_label() -> str: + return upload_limit_label() + + +def default_request_body_limit_bytes() -> int: + """Default protected-route body cap for non-upload requests.""" + + return default_upload_limit_mb() * _BYTES_PER_MB + + +def upload_request_limit_bytes(file_limit_bytes: int | None = None) -> int: + """Request cap for upload routes, including multipart field overhead.""" + + return ( + file_limit_bytes if file_limit_bytes is not None else get_upload_limit_bytes() + ) + MULTIPART_OVERHEAD_BYTES diff --git a/studio/frontend/package-lock.json b/studio/frontend/package-lock.json index 80f5d0a701..f36f2d8e79 100644 --- a/studio/frontend/package-lock.json +++ b/studio/frontend/package-lock.json @@ -37,6 +37,7 @@ "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", + "@tauri-apps/plugin-window-state": "^2.4.1", "@toolwind/corner-shape": "^0.0.8-3", "@xyflow/react": "^12.10.0", "assistant-stream": "0.3.12", @@ -855,41 +856,10 @@ "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", "license": "MIT" }, - "node_modules/@chevrotain/cst-dts-gen": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", - "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/gast": "12.0.0", - "@chevrotain/types": "12.0.0" - } - }, - "node_modules/@chevrotain/gast": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", - "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/types": "12.0.0" - } - }, - "node_modules/@chevrotain/regexp-to-ast": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", - "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", - "license": "Apache-2.0" - }, "node_modules/@chevrotain/types": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", - "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", - "license": "Apache-2.0" - }, - "node_modules/@chevrotain/utils": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", - "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, "node_modules/@dagrejs/dagre": { @@ -1597,12 +1567,12 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", - "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", + "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", "license": "MIT", "dependencies": { - "langium": "^4.0.0" + "@chevrotain/types": "~11.1.1" } }, "node_modules/@modelcontextprotocol/sdk": { @@ -6239,6 +6209,15 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@tauri-apps/plugin-window-state": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz", + "integrity": "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@toolwind/corner-shape": { "version": "0.0.8-3", "resolved": "https://registry.npmjs.org/@toolwind/corner-shape/-/corner-shape-0.0.8-3.tgz", @@ -6284,9 +6263,9 @@ } }, "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -6932,9 +6911,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -7627,34 +7606,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chevrotain": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", - "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/cst-dts-gen": "12.0.0", - "@chevrotain/gast": "12.0.0", - "@chevrotain/regexp-to-ast": "12.0.0", - "@chevrotain/types": "12.0.0", - "@chevrotain/utils": "12.0.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/chevrotain-allstar": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.3.tgz", - "integrity": "sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==", - "license": "MIT", - "dependencies": { - "lodash-es": "^4.18.1" - }, - "peerDependencies": { - "chevrotain": "^12.0.0" - } - }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -10034,9 +9985,9 @@ } }, "node_modules/hono": { - "version": "4.12.17", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.17.tgz", - "integrity": "sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -10189,9 +10140,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.1.tgz", + "integrity": "sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==", "license": "MIT", "engines": { "node": ">= 12" @@ -10617,24 +10568,6 @@ "node": ">=6" } }, - "node_modules/langium": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.3.tgz", - "integrity": "sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng==", - "license": "MIT", - "dependencies": { - "@chevrotain/regexp-to-ast": "~12.0.0", - "chevrotain": "~12.0.0", - "chevrotain-allstar": "~0.4.3", - "vscode-languageserver": "~9.0.1", - "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.1.0" - }, - "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" - } - }, "node_modules/layout-base": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", @@ -11438,14 +11371,14 @@ } }, "node_modules/mermaid": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", - "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", + "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.1.0", + "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", @@ -11456,14 +11389,14 @@ "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", + "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", - "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", - "uuid": "^11.1.0" + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "node_modules/micromark": { @@ -12932,9 +12865,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -13229,9 +13162,9 @@ } }, "node_modules/react-is": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", - "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", "license": "MIT", "peer": true }, @@ -15292,55 +15225,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", - "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vscode-languageserver": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", - "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", - "license": "MIT", - "dependencies": { - "vscode-languageserver-protocol": "3.17.5" - }, - "bin": { - "installServerIntoExtension": "bin/installServerIntoExtension" - } - }, - "node_modules/vscode-languageserver-protocol": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", - "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", - "license": "MIT", - "dependencies": { - "vscode-jsonrpc": "8.2.0", - "vscode-languageserver-types": "3.17.5" - } - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "license": "MIT" - }, - "node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "license": "MIT" - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "license": "MIT" - }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", diff --git a/studio/frontend/package.json b/studio/frontend/package.json index b43e174889..5ba0db143f 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -46,6 +46,7 @@ "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", + "@tauri-apps/plugin-window-state": "^2.4.1", "@toolwind/corner-shape": "^0.0.8-3", "@xyflow/react": "^12.10.0", "assistant-stream": "0.3.12", @@ -81,15 +82,20 @@ "overrides": { "@tanstack/react-router": "1.169.2", "@tanstack/router-core": "1.169.2", - "@tanstack/history": "1.161.6" + "@tanstack/history": "1.161.6", + "mermaid": "11.15.0", + "hono": "4.12.18", + "qs": "6.15.2", + "ip-address": "10.1.1", + "brace-expansion@5.0.5": "5.0.6" }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@eslint/js": "^9.39.1", "@types/canvas-confetti": "^1.9.0", "@types/js-yaml": "^4.0.9", - "@types/node-forge": "^1.3.14", "@types/node": "^25.5.2", + "@types/node-forge": "^1.3.14", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx index 83238dadf0..6f3c7618be 100644 --- a/studio/frontend/src/app/provider.tsx +++ b/studio/frontend/src/app/provider.tsx @@ -26,6 +26,9 @@ interface AppProviderProps { type TauriWindowMode = "setup" | "app"; type WindowLayoutGuard = () => boolean; +const MIN_WINDOW_WIDTH = 900; +const MIN_WINDOW_HEIGHT = 600; + async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise { const { getCurrentWindow } = await import("@tauri-apps/api/window"); if (!isCurrent()) return; @@ -39,35 +42,54 @@ async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise { async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise { const { getCurrentWindow, currentMonitor, LogicalSize } = await import("@tauri-apps/api/window"); + const { invoke } = await import("@tauri-apps/api/core"); + const { restoreStateCurrent, StateFlags } = await import("@tauri-apps/plugin-window-state"); if (!isCurrent()) return; const win = getCurrentWindow(); - const monitor = await currentMonitor(); + // Decide first-launch vs restore from the on-disk state file BEFORE touching the + // window. Probing the window itself after restoreStateCurrent is unreliable: + // on GTK, set_size against a hidden window is deferred until show(), so + // innerSize() reads a stale value and any baseline fallback would overwrite the + // queued restore. On macOS the same probe works, hence the inconsistency + // between previous iterations of this code. + const hasSavedState = await invoke("has_saved_window_state"); if (!isCurrent()) return; - let finalW = 900; - let finalH = 600; - - if (monitor) { - const scale = monitor.scaleFactor; - const screenW = monitor.size.width / scale; - const screenH = monitor.size.height / scale; - - finalW = Math.max(900, Math.round(screenW * 0.75)); - const targetH = Math.max(600, Math.round(finalW / 1.618)); - finalH = Math.min(targetH, Math.round(screenH * 0.85)); - } - - if (!isCurrent()) return; - await win.setSize(new LogicalSize(finalW, finalH)); - if (!isCurrent()) return; - await win.setSizeConstraints({ minWidth: 900, minHeight: 600 }); - if (!isCurrent()) return; await win.setResizable(true); if (!isCurrent()) return; - await win.center(); + + if (hasSavedState) { + // Subsequent launch: the plugin handles size, position, and maximized, + // with built-in off-screen protection (monitor-intersection check) for + // positions saved on a now-disconnected display. + await restoreStateCurrent( + StateFlags.SIZE | StateFlags.POSITION | StateFlags.MAXIMIZED, + ); + } else { + // First launch: fit to the current monitor and center. + const monitor = await currentMonitor(); + if (!isCurrent()) return; + let finalW = MIN_WINDOW_WIDTH; + let finalH = MIN_WINDOW_HEIGHT; + if (monitor) { + const scale = monitor.scaleFactor; + const screenW = monitor.size.width / scale; + const screenH = monitor.size.height / scale; + finalW = Math.max(MIN_WINDOW_WIDTH, Math.round(screenW * 0.75)); + const targetH = Math.max(MIN_WINDOW_HEIGHT, Math.round(finalW / 1.618)); + finalH = Math.min(targetH, Math.round(screenH * 0.85)); + } + await win.setSize(new LogicalSize(finalW, finalH)); + if (!isCurrent()) return; + await win.center(); + } if (!isCurrent()) return; await win.show(); + if (!isCurrent()) return; + // Apply constraints after restore/show. Setting constraints before plugin restore + // can emit a Resized event and overwrite the plugin's cached saved size. + await win.setSizeConstraints({ minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT }); } async function showWindowFallback(): Promise { diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx index f0a417638d..a0ca1e8cdb 100644 --- a/studio/frontend/src/app/router.tsx +++ b/studio/frontend/src/app/router.tsx @@ -13,6 +13,7 @@ import { Route as gridTestRoute } from "./routes/grid-test"; import { Route as indexRoute } from "./routes/index"; import { Route as loginRoute } from "./routes/login"; import { Route as onboardingRoute } from "./routes/onboarding"; +import { Route as projectsRoute } from "./routes/projects"; import { Route as changePasswordRoute } from "./routes/change-password"; import { Route as settingsRoute } from "./routes/settings"; import { Route as studioRoute } from "./routes/studio"; @@ -26,6 +27,7 @@ const routeTree = rootRoute.addChildren([ settingsRoute, studioRoute, chatRoute, + projectsRoute, exportRoute, dataRecipesRoute, dataRecipeRoute, diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index 57ed233d51..da74a9e8a7 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -6,6 +6,7 @@ import { Navbar } from "@/components/navbar"; import { fetchDeviceType, usePlatformStore } from "@/config/env"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SettingsDialog, useSettingsDialogStore } from "@/features/settings"; +import { useChatRuntimeStore } from "@/features/chat"; import { useTrainingUnloadGuard } from "@/features/training"; import { useSidebarPin } from "@/hooks/use-sidebar-pin"; import { useT, type TranslationKey } from "@/i18n"; @@ -40,6 +41,7 @@ function RouteFallback() { const CHAT_ONLY_ALLOWED = new Set([ "/", "/chat", + "/projects", "/login", "/signup", "/change-password", @@ -110,6 +112,13 @@ function RootLayout() { return () => window.removeEventListener("keydown", handler); }, []); + useEffect(() => { + if (isChatRoute) return; + const chatRuntime = useChatRuntimeStore.getState(); + chatRuntime.setActiveProjectId(null); + chatRuntime.setActiveThreadId(null); + }, [isChatRoute]); + return ( diff --git a/studio/frontend/src/app/routes/chat.tsx b/studio/frontend/src/app/routes/chat.tsx index 98c73aa7e0..a5514cdee1 100644 --- a/studio/frontend/src/app/routes/chat.tsx +++ b/studio/frontend/src/app/routes/chat.tsx @@ -10,6 +10,7 @@ export type ChatSearch = { thread?: string; compare?: string; new?: string; + project?: string; }; export const Route = createRoute({ @@ -21,6 +22,7 @@ export const Route = createRoute({ thread: typeof search.thread === "string" ? search.thread : undefined, compare: typeof search.compare === "string" ? search.compare : undefined, new: typeof search.new === "string" ? search.new : undefined, + project: typeof search.project === "string" ? search.project : undefined, }), component: ChatPage, }); diff --git a/studio/frontend/src/app/routes/projects.tsx b/studio/frontend/src/app/routes/projects.tsx new file mode 100644 index 0000000000..c63b1d5838 --- /dev/null +++ b/studio/frontend/src/app/routes/projects.tsx @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute } from "@tanstack/react-router"; +import { lazy } from "react"; +import { requireAuth } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +const ProjectsPage = lazy(() => + import("@/features/chat/projects-page").then((m) => ({ + default: m.ProjectsPage, + })), +); + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/projects", + staticData: { title: "Projects" }, + beforeLoad: () => requireAuth(), + component: ProjectsPage, +}); diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 4e4a6130cd..941c2e8a74 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -26,6 +26,9 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -38,18 +41,22 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler"; import { cn } from "@/lib/utils"; import { ChefHatIcon, - ColumnInsertIcon, CursorInfo02Icon, Delete02Icon, DownloadSquare01Icon, Edit03Icon, + FolderAddIcon, + FolderExportIcon, + Folder01Icon, Globe02Icon, HelpCircleIcon, Logout05Icon, + MoreVerticalIcon, Search01Icon, PowerIcon, PencilEdit02Icon, @@ -68,11 +75,17 @@ import { ChevronDown, ChevronsUpDown, MoreHorizontalIcon, Moon, Sun } from "luci import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; import { ChatSearchDialog, + createChatProject, + deleteChatProject, deleteChatItem, + moveChatItemToProject, renameChatItem, + renameChatProject, useChatRuntimeStore, + useChatProjects, useChatSearchStore, useChatSidebarItems, + type ProjectRecord, type SidebarItem, } from "@/features/chat"; import { useSettingsDialogStore } from "@/features/settings"; @@ -90,7 +103,7 @@ import { useTrainingRuntimeStore, } from "@/features/training"; import type { TrainingRunSummary } from "@/features/training"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { toast } from "@/lib/toast"; import { ShutdownDialog } from "@/components/shutdown-dialog"; import { translate, useT, type TranslationKey } from "@/i18n"; @@ -179,17 +192,19 @@ function NavItem({ onClick, children, dataTour, + className, }: { icon: typeof ZapIcon; label: string; active: boolean; disabled?: boolean; onClick: () => void; - children?: React.ReactNode; + children?: ReactNode; dataTour?: string; + className?: string; }) { return ( - +
{label} @@ -231,22 +246,53 @@ export function AppSidebar() { const isChatRoute = pathname.startsWith("/chat"); const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/"); + const [chatOpen, setChatOpen] = useState(true); + const [trainOpen, setTrainOpen] = useState(true); + const [runsOpen, setRunsOpen] = useState(true); + + useEffect(() => { + if (!isChatRoute) return; + queueMicrotask(() => setChatOpen(true)); + }, [isChatRoute]); + useEffect(() => { + if (!isStudioRoute) return; + queueMicrotask(() => setRunsOpen(true)); + }, [isStudioRoute]); const scrollRef = useRef(null); const [scrolled, setScrolled] = useState(false); - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - const handler = () => setScrolled(el.scrollTop > 0); - handler(); - el.addEventListener("scroll", handler, { passive: true }); - return () => el.removeEventListener("scroll", handler); - }, []); + // Bottom fade hides at the very bottom (and for short, non-scrolling lists) + // so the last row isn't washed out - Gemini-style. + const [canScrollDown, setCanScrollDown] = useState(false); + // Driven only from onScroll + a content-change effect below. Deliberately NO + // ResizeObserver: its callback-driven setState created a render loop (React + // #185). Both setters bail out when unchanged, so neither path can loop. + const syncScrollState = (el: HTMLDivElement) => { + const nextScrolled = el.scrollTop > 0; + setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled)); + const nextCanScrollDown = + el.scrollHeight - el.scrollTop - el.clientHeight > 1; + setCanScrollDown((prev) => + prev === nextCanScrollDown ? prev : nextCanScrollDown, + ); + }; const isRecipesRoute = pathname.startsWith("/data-recipes"); const { displayTitle, avatarDataUrl } = useEffectiveProfile(); - const { items: chatItems } = useChatSidebarItems(); + const { projects } = useChatProjects(); + const activeProjectId = isChatRoute + ? ((search.project as string | undefined) ?? null) + : null; + const { items: allChatItems } = useChatSidebarItems({ + enabled: !isStudioRoute, + requireMessages: false, + }); + const recentChatItems = useMemo( + () => allChatItems.filter((item) => !item.projectId), + [allChatItems], + ); + const chatItems = allChatItems; const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId); const activeThreadId = isChatRoute @@ -261,33 +307,87 @@ export function AppSidebar() { !chatOnly && isStudioRoute, ); const activeJobId = useTrainingRuntimeStore((s) => s.jobId); + const currentRunViewActive = useTrainingRuntimeStore((s) => s.currentRunViewActive); const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId); const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId); + // Recompute the bottom-fade state on mount and whenever the list height can + // change (items load, sections collapse/expand, route switches the visible + // list) - onScroll never fires for short, non-scrolling lists. Guarded + // setState below means this can't loop even if a dep is a fresh reference. + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const next = el.scrollHeight - el.scrollTop - el.clientHeight > 1; + setCanScrollDown((prev) => (prev === next ? prev : next)); + }, [ + recentChatItems.length, + runItems.length, + projects.length, + chatOpen, + trainOpen, + runsOpen, + isStudioRoute, + ]); + const chatDisabled = isTrainingRunning; + function chatSearchForProject(projectId: string | null) { + if (projectId) { + return { project: projectId }; + } + return { + new: createNavigationNonce(), + }; + } + + function openNewChat(projectId = activeProjectId) { + if (chatDisabled) return; + setActiveThreadId(null); + useChatRuntimeStore.getState().setActiveProjectId(projectId); + navigate({ to: "/chat", search: chatSearchForProject(projectId) }); + closeMobileIfOpen(); + } + + function openProject(projectId: string) { + if (chatDisabled) return; + setActiveThreadId(null); + useChatRuntimeStore.getState().setActiveProjectId(projectId); + navigate({ to: "/chat", search: { project: projectId } }); + closeMobileIfOpen(); + } + async function handleDeleteThread(item: Parameters[0]) { await deleteChatItem(item, activeThreadId, (view) => { navigate({ to: "/chat", - search: { new: view.newThreadNonce }, + search: item.projectId + ? { project: item.projectId } + : { new: view.newThreadNonce }, }); }); } type RenameTarget = | { kind: "chat"; item: SidebarItem; current: string } + | { kind: "project"; project: ProjectRecord; current: string } | { kind: "run"; run: TrainingRunSummary; current: string }; const [renamingTarget, setRenamingTarget] = useState( null, ); const [renameDraft, setRenameDraft] = useState(""); + const [creatingProject, setCreatingProject] = useState(false); + const [projectNameDraft, setProjectNameDraft] = useState(""); + const [projectCreateMoveTarget, setProjectCreateMoveTarget] = + useState(null); const renameTrimmed = renameDraft.trim(); const nextRunDisplayName = renameTrimmed.length > 0 ? renameTrimmed : null; const renameDirty = renamingTarget !== null && (renamingTarget.kind === "chat" ? renameTrimmed.length > 0 && renameTrimmed !== renamingTarget.current + : renamingTarget.kind === "project" + ? renameTrimmed.length > 0 && renameTrimmed !== renamingTarget.current : renameTrimmed.length > 0 ? renameTrimmed !== renamingTarget.current : renamingTarget.run.display_name != null); @@ -315,6 +415,16 @@ export function AppSidebar() { } return; } + if (target.kind === "project") { + try { + await renameChatProject(target.project.id, renameTrimmed); + } catch (err) { + toast.error("Failed to rename project", { + description: err instanceof Error ? err.message : undefined, + }); + } + return; + } try { const updated = await renameTrainingRun(target.run.id, nextRunDisplayName); emitTrainingRunUpdated(updated); @@ -327,13 +437,23 @@ export function AppSidebar() { type DeleteTarget = | { kind: "chat"; item: SidebarItem } + | { kind: "project"; project: ProjectRecord } | { kind: "run"; run: TrainingRunSummary }; const [confirmingDelete, setConfirmingDelete] = useState(null); + const [deleteProjectFiles, setDeleteProjectFiles] = useState(false); + + useEffect(() => { + if (confirmingDelete?.kind !== "project") { + setDeleteProjectFiles(false); + } + }, [confirmingDelete]); async function commitDelete() { const target = confirmingDelete; if (!target) return; + const shouldDeleteProjectFiles = + target.kind === "project" && deleteProjectFiles; setConfirmingDelete(null); if (target.kind === "chat") { try { @@ -345,6 +465,22 @@ export function AppSidebar() { } return; } + if (target.kind === "project") { + try { + await deleteChatProject(target.project.id, { + deleteFiles: shouldDeleteProjectFiles, + }); + if (activeProjectId === target.project.id) { + useChatRuntimeStore.getState().setActiveProjectId(null); + navigate({ to: "/chat", search: { new: createNavigationNonce() } }); + } + } catch (err) { + toast.error("Failed to delete project", { + description: err instanceof Error ? err.message : undefined, + }); + } + return; + } if (target.run.status === "running") { toast.error(t("shell.toast.cannotDeleteRunningRun")); return; @@ -362,6 +498,168 @@ export function AppSidebar() { } } + async function commitCreateProject() { + const name = projectNameDraft.trim(); + if (!name) return; + const moveTarget = projectCreateMoveTarget; + try { + const project = await createChatProject(name); + if (moveTarget) { + await moveChatItemToProject(moveTarget, project.id); + if (activeThreadId === moveTarget.id) { + useChatRuntimeStore.getState().setActiveProjectId(project.id); + } + } + setCreatingProject(false); + setProjectNameDraft(""); + setProjectCreateMoveTarget(null); + if (moveTarget) { + return; + } else { + openProject(project.id); + } + } catch (err) { + toast.error(moveTarget ? "Failed to create and move chat" : "Failed to create project", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + async function moveChatToProject(item: SidebarItem, projectId: string | null) { + if (item.projectId === projectId) return; + try { + await moveChatItemToProject(item, projectId); + if (activeThreadId === item.id) { + useChatRuntimeStore.getState().setActiveProjectId(projectId); + } + } catch (err) { + toast.error("Failed to move chat", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + function renderChatSidebarItem( + item: SidebarItem, + variant: "project" | "recent", + ) { + const itemClass = + variant === "project" + ? "group/project-chat-item relative" + : "group/recent-item relative"; + const actionClass = + variant === "project" + ? "sidebar-row-action group-hover/project-chat-item:opacity-100 group-hover/project-chat-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto" + : "sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"; + const buttonClass = cn( + "sidebar-nav-btn h-[33px] cursor-pointer rounded-[11px] pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium", + variant === "project" ? "pl-[37px]" : "pl-2.5", + variant === "project" + ? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8" + : "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8", + ); + + return ( + + { + navigate({ + to: "/chat", + search: + item.type === "single" + ? { + thread: item.id, + ...(item.projectId ? { project: item.projectId } : {}), + } + : { + compare: item.id, + ...(item.projectId ? { project: item.projectId } : {}), + }, + }); + closeMobileIfOpen(); + }} + > + {item.title} + + + + + + + openRenameChat(item)}> + + Rename + + + + + Move to project + + + { + setProjectCreateMoveTarget(item); + setProjectNameDraft(""); + setCreatingProject(true); + }} + > + + New project + + void moveChatToProject(item, null)} + > + Recents + + {projects.map((project) => ( + void moveChatToProject(item, project.id)} + > + + {project.name} + + ))} + + + setConfirmingDelete({ kind: "chat", item })} + > + + Delete + + + + + ); + } + return ( <> - + {/* Expanded: compact logo + close toggle */}
{ event.preventDefault(); if (chatDisabled) return; - setActiveThreadId(null); - closeMobileIfOpen(); - void navigate({ - to: "/chat", - search: { new: createNavigationNonce() }, - }); + openNewChat(null); }} className="flex items-center gap-[6px] select-none" aria-label={t("shell.aria.home")} @@ -392,7 +685,7 @@ export function AppSidebar() { alt="Unsloth" className="h-[34px] w-[34px] rounded-full object-cover" /> - + unsloth @@ -405,7 +698,7 @@ export function AppSidebar() { - - - openRenameChat(item)}> - - {t("common.rename")} - - setConfirmingDelete({ kind: "chat", item })} - > - - {t("common.delete")} - - - - - ))} - - + + + { + if (chatOnly) return; + navigate({ to: "/studio" }); + closeMobileIfOpen(); + }} + /> + { + navigate({ to: "/data-recipes" }); + closeMobileIfOpen(); + }} + /> + { + if (chatOnly) return; + navigate({ to: "/export" }); + closeMobileIfOpen(); + }} + /> + + + + + {!isStudioRoute && ( + + + + + {t("shell.navigation.recents")} + + + + + + + {recentChatItems.map((item) => + renderChatSidebarItem(item, "recent"), + )} + + + + )} {isStudioRoute && runItems.length > 0 && !chatOnly && ( - + - - + + {t("shell.navigation.recents")} - + {runItems.map((run) => { + // An explicit sidebar selection wins. Otherwise highlight + // the active job only while the "Current Run" tab is the + // view - that covers a live run (it auto-switches there) and + // a just-finished/errored run you're still viewing, while + // keeping the Configure tab unhighlighted even though + // `activeJobId` stays pinned to the last job. const isActiveRun = - selectedHistoryRunId === run.id || activeJobId === run.id; + selectedHistoryRunId != null + ? run.id === selectedHistoryRunId + : currentRunViewActive && run.id === activeJobId; return ( { setSelectedHistoryRunId(run.id); closeMobileIfOpen(); @@ -703,7 +987,18 @@ export function AppSidebar() { )} - + + {/* Fade above the profile box, shown only while there's more list below + the fold; at the very bottom (or for short lists) it fades out so the + last row shows fully (Gemini-style). `right-2` keeps it clear of the + 8px scrollbar gutter so the scrollbar isn't faded out. */} +
@@ -382,11 +381,60 @@ function StreamdownBlock(props: BlockProps) { } const AUDIO_PLAYER_RE = //; +// Coalesce markdown re-parses to one per animation frame while streaming: the +// runtime notifies on every token (hundreds/sec) and the monitor can't paint +// that fast. When not streaming we return live text rather than the throttled +// state, so the final text never lags and a reused instance (parts are keyed by +// index) shows a completed message's text immediately instead of a stale frame. +function useRafCoalescedText(text: string, isStreaming: boolean): string { + const [displayed, setDisplayed] = useState(text); + const pendingRef = useRef(text); + const rafRef = useRef(null); + + useEffect(() => { + pendingRef.current = text; + if (!isStreaming) { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + return; + } + if (rafRef.current === null) { + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + setDisplayed(pendingRef.current); + }); + } + }, [text, isStreaming]); + + // Unmount cleanup. Cancel the in-flight rAF and null the handle so a + // StrictMode remount isn't gated out by a stale id. Kept separate from the + // scheduling effect so it doesn't cancel mid-stream and defeat the throttle. + useEffect(() => { + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, []); + + if (isStreaming && text.startsWith(displayed)) { + return displayed; + } + return text; +} + const MarkdownTextImpl = () => { const { text, status } = useMessagePartText(); - const processedText = useMemo(() => preprocessLaTeX(text), [text]); + const displayText = useRafCoalescedText(text, status.type === "running"); + const processedText = useMemo( + () => preprocessLaTeX(displayText), + [displayText], + ); - const audioMatch = text.match(AUDIO_PLAYER_RE); + const audioMatch = displayText.match(AUDIO_PLAYER_RE); if (audioMatch) { return ; } diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx index 0fcbefdabd..f8a3ce4e20 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx @@ -146,7 +146,7 @@ function ModelSelectorTrigger({ type="button" data-tour={dataTour} className={cn( - "flex min-w-0 items-center gap-2 transition-colors", + "unsloth-model-selector-trigger flex min-w-0 items-center gap-2 transition-colors", variant === "outline" && "rounded-[10px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2d2e32]", variant === "ghost" && "rounded-[10px] hover:bg-[#ececec] dark:hover:bg-[#2d2e32]", @@ -246,7 +246,7 @@ function ModelSelectorContent({ align="start" data-tour={dataTour} className={cn( - "menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2", + "unsloth-model-selector-menu menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-3", className, )} > diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index ea40c260c5..2d715bf8de 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -38,11 +38,11 @@ import { import { cn, formatCompact } from "@/lib/utils"; import type { VramFitStatus } from "@/lib/vram"; import { checkVramFit, estimateLoadingVram } from "@/lib/vram"; -import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons"; +import { Add01Icon, Cancel01Icon, Download01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { FolderBrowser } from "./folder-browser"; import { ModelDeleteAction } from "./model-delete-action"; -import { ChevronDownIcon, ChevronRightIcon, DownloadIcon, StarIcon } from "lucide-react"; +import { ChevronDownIcon, ChevronRightIcon, StarIcon } from "lucide-react"; import { type ReactNode, useCallback, @@ -145,8 +145,8 @@ function ModelRow({ type="button" onClick={onClick} className={cn( - "flex w-full items-center gap-2 rounded-[6px] px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035]", - selected && "bg-[#ececec] dark:bg-[#2e3035]", + "flex w-full items-center gap-2 rounded-[8px] px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#3a3d44]", + selected && "bg-[#ececec] dark:bg-[#3a3d44]", )} > @@ -925,7 +925,7 @@ export function HubModelPicker({ (!chatOnly && cachedModels.length > 0)) ? ( <> } + icon={} collapsed={downloadedCollapsed} onToggle={() => setDownloadedCollapsed((v) => !v)} >Downloaded diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index b7e0f35d35..83057074ba 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -2,11 +2,9 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { - ComposerAddAttachment, ComposerAttachments, UserMessageAttachments, } from "@/components/assistant-ui/attachment"; -import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon"; import { GeneratedImageOverlayProvider, useGeneratedImageOverlay, @@ -40,15 +38,23 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { sentAudioNames } from "@/features/chat/api/chat-adapter"; +import { useChatProjects } from "@/features/chat/hooks/use-chat-projects"; +import { NewProjectDialog } from "@/features/chat/components/new-project-dialog"; import { parseExternalModelId } from "@/features/chat/external-providers"; import { McpComposerButton } from "@/features/chat/mcp-composer-button"; import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities"; import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store"; import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message"; +import { useUserProfileStore } from "@/features/profile/stores/user-profile-store"; import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params"; import { isTauri } from "@/lib/api-base"; import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils"; @@ -69,44 +75,58 @@ import { useAuiState, } from "@assistant-ui/react"; import { flushResourcesSync } from "@assistant-ui/tap"; +import { + AttachmentIcon, + CodeIcon, + Copy01Icon, + Delete02Icon, + Download01Icon, + Edit03Icon, + Folder01Icon, + FolderAddIcon, + Image03Icon, + McpServerIcon, + PencilRulerIcon, + Tick02Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useNavigate } from "@tanstack/react-router"; import { ArrowDownIcon, ArrowUpIcon, + CheckIcon, ChevronLeftIcon, ChevronRightIcon, - DownloadIcon, - FileTextIcon, + Columns2Icon, GlobeIcon, HeadphonesIcon, - LightbulbIcon, - LightbulbOffIcon, - MicIcon, MoreHorizontalIcon, + PlusIcon, RefreshCwIcon, SquareIcon, TerminalIcon, XIcon, } from "lucide-react"; -import { - Copy01Icon, - Delete02Icon, - Edit03Icon, - Image03Icon, - Tick02Icon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; import { type ChangeEvent, type ComponentProps, type CompositionEvent, type FC, type KeyboardEvent, + type DragEvent as ReactDragEvent, + type ReactNode, + createContext, useCallback, + useContext, useEffect, useRef, useState, } from "react"; +// True while a file is dragged anywhere over the chat page (not just the +// composer), so the composer can show its "Drop files here" affordance. +const PageDragContext = createContext(false); + export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean; @@ -125,8 +145,55 @@ export const Thread: FC<{ const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const threadId = targetThreadId ?? activeThreadId ?? null; + // Page-wide drag-and-drop: dropping a file anywhere on the chat page (not + // just on the composer) attaches it and shows the composer drop affordance. + // The composer's own dropzone still handles drops on the box itself; its + // handler calls preventDefault, so the page handler skips them (no double-add). + const aui = useAui(); + const [pageDragging, setPageDragging] = useState(false); + const dragDepth = useRef(0); + const hasFiles = (e: ReactDragEvent) => + Array.from(e.dataTransfer?.types ?? []).includes("Files"); + const onDragEnter = (e: ReactDragEvent) => { + if (isTauri || !hasFiles(e)) return; + dragDepth.current += 1; + setPageDragging(true); + }; + const onDragOver = (e: ReactDragEvent) => { + if (isTauri || !hasFiles(e)) return; + e.preventDefault(); + }; + const onDragLeave = (e: ReactDragEvent) => { + if (isTauri || !hasFiles(e)) return; + dragDepth.current = Math.max(0, dragDepth.current - 1); + if (dragDepth.current === 0) setPageDragging(false); + }; + const onDrop = (e: ReactDragEvent) => { + if (isTauri) return; + dragDepth.current = 0; + setPageDragging(false); + // Compare panes hide this composer and use the shared composer's own + // dropzone, so don't capture drops into a hidden composer here. + if (hideComposer) return; + // Drops on the composer box are handled by its own dropzone, which calls + // preventDefault; skip those here so the file isn't added twice. + if (e.defaultPrevented) return; + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + e.preventDefault(); + for (const file of files) { + aui + .composer() + .addAttachment(file) + .catch(() => { + // Adapter shows its own toast (e.g. "Load a model before adding images"). + }); + } + }; + return ( + + ); }; @@ -250,7 +322,7 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({ } aria-label="Download generated image" > - + - - {effectiveSupportsReasoningOff && ( - { - setReasoningEnabled(false); - applyQwenThinkingParams(false); - }} - > - None - {!effectiveReasoningVisualEnabled ? " \u2713" : ""} - - )} - {effectiveReasoningEffortLevels - .filter((level) => level !== "none") - .map((level) => ( + + {isEffort ? ( + <> + {effectiveSupportsReasoningOff && ( + { + setReasoningEnabled(false); + applyQwenThinkingParams(false); + // Preserve thinking needs thinking on, so turn it off too. + setPreserveThinking(false); + }} + > + + None + + )} + {effectiveReasoningEffortLevels + .filter((level) => level !== "none") + .map((level) => ( + { + setReasoningEffort(level); + setReasoningEnabled(true); + applyQwenThinkingParams(true); + // Kimi's $web_search builtin forbids thinking, so + // enabling thinking flips the Search pill off. + if (isKimiExternal && toolsEnabled) { + setToolsEnabled(false, { persist: false }); + } + }} + > + + {formatEffortLabel(level)} + + ))} + + ) : ( + effectiveSupportsReasoningOff && + !reasoningLockedOn && ( { - setReasoningEffort(level); - setReasoningEnabled(true); - applyQwenThinkingParams(true); - // Kimi's $web_search builtin forbids thinking, so - // enabling thinking flips the Search pill off. - if (isKimiExternal && toolsEnabled) { - setToolsEnabled(false); + const next = !reasoningEnabled; + setReasoningEnabled(next); + applyQwenThinkingParams(next); + // Preserve thinking cannot run without thinking. + if (!next) setPreserveThinking(false); + if (isKimiExternal && next && toolsEnabled) { + setToolsEnabled(false, { persist: false }); } }} > - {formatEffortLabel(level)} - {effectiveReasoningVisualEnabled && reasoningEffort === level - ? " \u2713" - : ""} + + Thinking - ))} + ) + )} + {supportsPreserveThinking && ( + { + e.preventDefault(); + const next = !preserveThinking; + setPreserveThinking(next); + // Preserve thinking requires thinking on. + if (next) { + setReasoningEnabled(true); + applyQwenThinkingParams(true); + } + }} + > + + Preserve thinking + + )} ); @@ -925,18 +1329,13 @@ const ReasoningToggle: FC = () => { const next = !reasoningEnabled; setReasoningEnabled(next); applyQwenThinkingParams(next); - // Mutual exclusion with the Search pill on Kimi — see the - // dropdown branch above and shared-composer for the same rule. + // Mutually exclusive with Search on Kimi (see dropdown branch). if (isKimiExternal && next && toolsEnabled) { - setToolsEnabled(false); + setToolsEnabled(false, { persist: false }); } }} - className="composer-pill-btn" - data-active={ - reasoningLockedOn || (effectiveReasoningEnabled && !disabled) - ? "true" - : "false" - } + className="unsloth-thinking-pill" + data-active={activeLook ? "true" : "false"} aria-label={thinkToggleAriaLabel({ reasoningLockedOn, modelLoaded, @@ -944,53 +1343,21 @@ const ReasoningToggle: FC = () => { effectiveReasoningEnabled, })} > - {reasoningLockedOn || (effectiveReasoningEnabled && !disabled) ? ( - - ) : ( - - )} - Think + + + + {activeLook ? Thinking : null} ); }; -const PreserveThinkingToggle: FC = () => { - const modelLoaded = useChatRuntimeStore( - (s) => !!s.params.checkpoint && !s.modelLoading, - ); - const supportsPreserveThinking = useChatRuntimeStore( - (s) => s.supportsPreserveThinking, - ); - const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking); - const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking); - if (!supportsPreserveThinking) return null; - const disabled = !modelLoaded; - return ( - - ); -}; +// Tool icon plus an X overlay the CSS reveals on hover when the pill is active. +const PillGlyph: FC<{ children: ReactNode }> = ({ children }) => ( + + {children} + + +); const WebSearchToggle: FC = () => { const modelLoaded = useChatRuntimeStore( @@ -1019,7 +1386,9 @@ const WebSearchToggle: FC = () => { ? externalProviders.find((p) => p.id === externalSelection.providerId) : undefined; const isKimiExternal = selectedExternalProvider?.providerType === "kimi"; - const disabled = !modelLoaded || !(supportsTools || supportsBuiltinWebSearch); + // Disable only when a loaded model lacks the capability; with no model the + // tool can still be pre-selected and reflected, matching the + menu. + const disabled = modelLoaded && !(supportsTools || supportsBuiltinWebSearch); return ( ); @@ -1062,8 +1433,9 @@ const CodeToolsToggle: FC = () => { ); const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled); const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled); - const disabled = - !modelLoaded || !(supportsTools || supportsBuiltinCodeExecution); + // Disable only when a loaded model lacks the capability; with no model the + // tool can still be pre-selected and reflected, matching the + menu. + const disabled = modelLoaded && !(supportsTools || supportsBuiltinCodeExecution); return ( ); @@ -1114,31 +1492,36 @@ const ImagesToggle: FC = () => { : "Enable image generation" } > - + + + Images ); }; 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; + // Canvas is opt-in; the pill only shows once it is toggled on from the menu. + if (!artifactsEnabled) return null; return ( ); }; @@ -1200,84 +1583,309 @@ const ToolStatusDisplay: FC = () => {
); }; +// Plus menu: attachment and workflow actions. Opens downward in the centered +// welcome composer; the docked composer passes side="top" to open upward. +const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({ + side = "bottom", +}) => { + const navigate = useNavigate(); + const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled); + const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled); + const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled); + const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled); + const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled); + const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled); + const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat); + const setMcpEnabledForChat = useChatRuntimeStore( + (s) => s.setMcpEnabledForChat, + ); + // Capability gating, mirroring the visible pills so menu and pills agree on + // what a loaded model supports (a tool the backend drops must not look on). + const modelLoaded = useChatRuntimeStore( + (s) => !!s.params.checkpoint && !s.modelLoading, + ); + const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint); + const supportsTools = useChatRuntimeStore((s) => s.supportsTools); + const supportsBuiltinWebSearch = useChatRuntimeStore( + (s) => s.supportsBuiltinWebSearch, + ); + const supportsBuiltinCodeExecution = useChatRuntimeStore( + (s) => s.supportsBuiltinCodeExecution, + ); + const supportsBuiltinImageGeneration = useChatRuntimeStore( + (s) => s.supportsBuiltinImageGeneration, + ); + const imageToolsEnabled = useChatRuntimeStore((s) => s.imageToolsEnabled); + const setImageToolsEnabled = useChatRuntimeStore( + (s) => s.setImageToolsEnabled, + ); + const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled); + const connectionsEnabled = useExternalProvidersStore( + (s) => s.connectionsEnabled, + ); + const externalProvidersAll = useExternalProvidersStore((s) => s.providers); + const externalProviders = connectionsEnabled ? externalProvidersAll : []; + const externalSelection = parseExternalModelId(checkpoint); + const selectedExternalProvider = + externalSelection != null + ? externalProviders.find((p) => p.id === externalSelection.providerId) + : undefined; + const isKimiExternal = selectedExternalProvider?.providerType === "kimi"; + // Disable only when a loaded model lacks the capability; with no model the + // tool can still be pre-selected, matching the pill logic above. + const searchDisabled = + modelLoaded && !(supportsTools || supportsBuiltinWebSearch); + const codeDisabled = + modelLoaded && !(supportsTools || supportsBuiltinCodeExecution); + const imageDisabled = !modelLoaded; + // Like Search/Code: disabled only when a loaded model lacks tool support. + const mcpDisabled = modelLoaded && !supportsTools; + // Three most recently updated projects for the quick-access submenu. + const { projects } = useChatProjects(); + const recentProjects = [...projects] + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, 3); + const openProject = (projectId: string) => { + useChatRuntimeStore.getState().setActiveProjectId(projectId); + navigate({ to: "/chat", search: { project: projectId } }); + }; -const ComposerAction: FC<{ + const startCompare = useCallback(() => { + const store = useChatRuntimeStore.getState(); + store.setActiveThreadId(null); + store.setContextUsage(null); + // crypto.randomUUID is undefined in non-secure contexts (HTTP over a LAN IP). + const compareId = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + navigate({ to: "/chat", search: { compare: compareId } }); + }, [navigate]); + + const [newProjectOpen, setNewProjectOpen] = useState(false); + + return ( + <> + + + + + event.preventDefault()} + > + + + + Add photos & files + + + + { + const next = !toolsEnabled; + setToolsEnabled(next); + // Mirror the Search pill: Kimi forbids search + thinking together. + if (isKimiExternal) { + setReasoningEnabled(!next, { persist: false }); + applyQwenThinkingParams(!next); + } + }} + > + + Web search + {toolsEnabled && !searchDisabled ? ( + + ) : null} + + setCodeToolsEnabled(!codeToolsEnabled)} + > + + Code + {codeToolsEnabled && !codeDisabled ? ( + + ) : null} + + {supportsBuiltinImageGeneration && ( + setImageToolsEnabled(!imageToolsEnabled)} + > + + Images + {imageToolsEnabled && !imageDisabled ? ( + + ) : null} + + )} + + setArtifactsEnabled(!artifactsEnabled)} + > + + Canvas + {artifactsEnabled ? : null} + + setMcpEnabledForChat(!mcpEnabledForChat)} + > + + MCP + {mcpEnabledForChat && !mcpDisabled ? ( + + ) : null} + + {/* RAG hidden temporarily */} + startCompare()}> + + Compare chat + + + + + + Projects + + + setNewProjectOpen(true)}> + + New project + + Recents + {recentProjects.length > 0 ? ( + recentProjects.map((project) => ( + openProject(project.id)} + > + + {project.name} + + )) + ) : ( + + No recent projects + + )} + + + + + + + ); +}; + +const ComposerRightControls: FC<{ disabled?: boolean; shouldBlockSend?: () => boolean; -}> = ({ disabled, shouldBlockSend }) => { + menuSide?: "top" | "bottom"; +}> = ({ disabled, shouldBlockSend, menuSide }) => { return ( -
-
- - - - - - - - - -
-
- - - - - - - - - - - - - - - !thread.isRunning}> - - { - if (shouldBlockSend?.()) { - event.preventDefault(); - } - }} - className="aui-composer-send size-8 rounded-full" - aria-label="Send message" - > - - - - - thread.isRunning}> - - - - -
+
+ + + + + + + + + + + + + + + + !thread.isRunning}> + + { + if (shouldBlockSend?.()) { + event.preventDefault(); + } + }} + className="aui-composer-send ml-1.5 size-8 rounded-full" + aria-label="Send message" + > + + + + + thread.isRunning}> + + + +
); }; @@ -1459,11 +2067,11 @@ const AssistantActionBar: FC = () => { side="bottom" align="start" onCloseAutoFocus={(e) => e.preventDefault()} - className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md" + className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md [--radius:1.1rem] bg-popover p-1 text-popover-foreground shadow-[0_2px_8px_-2px_rgba(27,27,31,0.16)] dark:shadow-none" > - + Export as Markdown diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx index a9919708d0..c0ced3b844 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx @@ -6,7 +6,9 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; -import { DownloadIcon, ImageIcon, PencilIcon } from "lucide-react"; +import { ImageIcon, PencilIcon } from "lucide-react"; +import { Download01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import type { CSSProperties, MouseEvent } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { useGeneratedImageOverlay } from "./generated-image-overlay-context"; @@ -374,7 +376,7 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({ onClick={handleDownload} aria-label="Download generated image" > - +
diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx index bab735104c..ce15d3e440 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx @@ -78,7 +78,7 @@ function HighlightedCode({ code: source, language }: { code: string; language: s [source, language], ); return ( -
+
- - + + ); diff --git a/studio/frontend/src/components/ui/dropdown-menu.tsx b/studio/frontend/src/components/ui/dropdown-menu.tsx index e2f1c5fc6d..0f67194ee3 100644 --- a/studio/frontend/src/components/ui/dropdown-menu.tsx +++ b/studio/frontend/src/components/ui/dropdown-menu.tsx @@ -1,6 +1,6 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import type * as React from "react"; @@ -46,7 +46,7 @@ function DropdownMenuContent({ sideOffset={sideOffset} align={align} className={cn( - "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-border ring-1 ring-border bg-popover text-popover-foreground min-w-48 rounded-lg p-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden", + "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-48 rounded-lg p-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden", className, )} {...props} @@ -78,7 +78,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-lg px-3 py-2 text-sm [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-lg px-3 py-2 text-sm [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0", className, )} {...props} @@ -96,7 +96,7 @@ function DropdownMenuCheckboxItem({ {} -const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH = "17.5rem" const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_KEYBOARD_SHORTCUT = "b" @@ -472,7 +472,7 @@ function SidebarGroupLabel({ data-slot="sidebar-group-label" data-sidebar="group-label" className={cn( - "text-[#94a3b8] dark:text-[#666] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0.08em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0", + "text-[#94a3b8] dark:text-[#666] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0", className )} {...props} diff --git a/studio/frontend/src/components/ui/sonner.tsx b/studio/frontend/src/components/ui/sonner.tsx index 5bd3078761..6380a7bfc8 100644 --- a/studio/frontend/src/components/ui/sonner.tsx +++ b/studio/frontend/src/components/ui/sonner.tsx @@ -63,15 +63,15 @@ const Toaster = ({ ...props }: ToasterProps) => { { "--normal-bg": "var(--popover)", "--normal-text": "var(--popover-foreground)", - "--normal-border": "var(--border)", + // No border line; elevation comes from the composer's drop shadow. + "--normal-border": "transparent", "--border-radius": "var(--radius)", - // Pin close button to the top-right corner inside the toast. - // Overrides sonner's default left placement and outside-corner - // translate; top offset is set via a rule in index.css since sonner - // hardcodes `top: 0` (not a CSS variable). - "--toast-close-button-start": "unset", - "--toast-close-button-end": "8px", - "--toast-close-button-transform": "none", + // Pin the close button inside the toast's top-right corner. + // Sonner defaults to the left/outside edge, so keep the horizontal + // override here and the top offset in index.css. + "--toast-close-button-start": "unset", + "--toast-close-button-end": "8px", + "--toast-close-button-transform": "none", } as React.CSSProperties } // No swipe gestures; keeps toast text selectable. diff --git a/studio/frontend/src/components/ui/tabs.tsx b/studio/frontend/src/components/ui/tabs.tsx index 07167ddf36..1fa8274573 100644 --- a/studio/frontend/src/components/ui/tabs.tsx +++ b/studio/frontend/src/components/ui/tabs.tsx @@ -1,6 +1,6 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + "use client"; /* eslint-disable react-refresh/only-export-components */ @@ -54,7 +54,7 @@ export const tabsListVariants = cva( variants: { variant: { default: "bg-muted", - line: "gap-1 bg-transparent", + line: "gap-2 bg-transparent group-data-horizontal/tabs:h-auto", }, }, defaultVariants: { @@ -95,8 +95,10 @@ export function TabsTrigger({ className={cn( "gap-1.5 rounded-xl corner-squircle border border-transparent px-2 py-1 text-sm font-medium group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-colors group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", + // Line variant is a roomier pill (no underline); padding overrides px-2 py-1. + "group-data-[variant=line]/tabs-list:px-3.5 group-data-[variant=line]/tabs-list:py-2.5", "data-active:text-foreground dark:data-active:text-foreground", - "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", + "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5", className, )} {...props} @@ -104,7 +106,7 @@ export function TabsTrigger({ {isActive && ( void __sequence?: boolean + __instant?: boolean } function useStartOnView(enabled: boolean): { @@ -65,15 +73,18 @@ export function Terminal({ className, sequence = true, startOnView = true, + instant = false, }: TerminalProps): ReactElement { const { ref, started } = useStartOnView(startOnView) const childElements = Children.toArray(children).filter(isValidElement) const [activeIndex, setActiveIndex] = useState(0) - const visibleIndex = sequence - ? started - ? activeIndex - : -1 - : Number.MAX_SAFE_INTEGER + const visibleIndex = instant + ? Number.MAX_SAFE_INTEGER + : sequence + ? started + ? activeIndex + : -1 + : Number.MAX_SAFE_INTEGER function handleLineDone(index: number): void { if (!sequence) { @@ -99,7 +110,8 @@ export function Terminal({ {childElements.map((child, index) => cloneElement(child, { __sequence: sequence, - __isActive: !sequence || visibleIndex >= index, + __isActive: instant || !sequence || visibleIndex >= index, + __instant: instant, __onDone: () => handleLineDone(index), key: child.key ?? index, } as InternalLineProps) @@ -122,11 +134,12 @@ export function AnimatedSpan({ startOnView = false, __isActive, __sequence, + __instant, __onDone, }: AnimatedSpanProps): ReactElement { const { ref, started } = useStartOnView(startOnView) - const [visible, setVisible] = useState(false) - const doneRef = useRef(false) + const [visible, setVisible] = useState(Boolean(__instant)) + const doneRef = useRef(Boolean(__instant)) const onDoneRef = useRef(__onDone) const shouldStart = __sequence ? __isActive : started @@ -180,11 +193,12 @@ export function TypingAnimation({ startOnView = true, __isActive, __sequence, + __instant, __onDone, }: TypingAnimationProps): ReactElement { const { ref, started } = useStartOnView(startOnView) - const [typed, setTyped] = useState("") - const doneRef = useRef(false) + const [typed, setTyped] = useState(__instant ? children : "") + const doneRef = useRef(Boolean(__instant)) const onDoneRef = useRef(__onDone) const shouldStart = __sequence ? __isActive : started diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index d753e1aab5..c1606c7020 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -10,7 +10,6 @@ import { Eye, EyeOff } from "lucide-react"; import { useEffect, useState } from "react"; import type { ReactElement } from "react"; import type { SyntheticEvent } from "react"; -import { usePlatformStore } from "@/config/env"; import { refreshSession } from "../api"; // Bootstrap credentials injected into index.html by the backend @@ -294,13 +293,13 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { storeAuthTokens(token.access_token, token.refresh_token); navigate({ to: getPostAuthRoute() }); } catch (err: unknown) { - let msg = err instanceof Error ? err.message : "Auth failed."; - if (msg.includes("unsloth studio reset-password") && usePlatformStore.getState().deviceType === "windows") { - msg = msg.replace( - "unsloth studio reset-password", - ".\\unsloth_studio\\Scripts\\unsloth.exe studio reset-password", - ); - } + // The backend already returns the correct, PATH-based command + // ("unsloth studio reset-password"), which the installer puts on PATH on + // every platform. Do NOT rewrite it to a relative Windows path like + // ".\unsloth_studio\Scripts\unsloth.exe ..." -- that only resolves when the + // terminal happens to be inside the Studio home dir, so it fails with + // CommandNotFoundException everywhere else. Show the backend message as-is. + const msg = err instanceof Error ? err.message : "Auth failed."; setError(msg); } finally { setLoading(false); diff --git a/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts b/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts new file mode 100644 index 0000000000..a679e8ed83 --- /dev/null +++ b/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts @@ -0,0 +1,230 @@ +// 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 { DictationAdapter } from "@assistant-ui/react"; +import { toast } from "sonner"; + +const getSpeechRecognitionAPI = (): SpeechRecognitionConstructor | undefined => { + if (typeof window === "undefined") return undefined; + return window.SpeechRecognition ?? window.webkitSpeechRecognition; +}; + +const stopStream = (stream: MediaStream | null) => { + stream?.getTracks().forEach((track) => track.stop()); +}; + +const describeMediaError = (error: unknown): string => { + if (!(error instanceof DOMException)) { + return "Dictation could not access the microphone."; + } + if (error.name === "NotAllowedError") { + return "Microphone access is blocked. Allow microphone access for this Studio page, then try again."; + } + if (error.name === "NotFoundError") { + return "No microphone was found for dictation."; + } + if (error.name === "NotReadableError") { + return "The microphone is already in use or unavailable."; + } + return error.message || "Dictation could not access the microphone."; +}; + +const describeSpeechError = (error: string, message?: string): string => { + if (error === "not-allowed") { + return "Speech recognition was blocked by the browser. Check microphone permissions for this Studio page."; + } + if (error === "service-not-allowed") { + return "Speech recognition is blocked by the browser speech service."; + } + if (error === "network") { + return "Speech recognition could not reach the browser speech service."; + } + if (error === "language-not-supported") { + return "Speech recognition does not support the current language."; + } + return message || `Speech recognition failed: ${error}`; +}; + +export class StudioWebSpeechDictationAdapter implements DictationAdapter { + private readonly language: string; + private readonly continuous: boolean; + private readonly interimResults: boolean; + + constructor( + options: { + language?: string; + continuous?: boolean; + interimResults?: boolean; + } = {}, + ) { + this.language = options.language ?? navigator.language ?? "en-US"; + this.continuous = options.continuous ?? true; + this.interimResults = options.interimResults ?? true; + } + + static isSupported(): boolean { + return ( + typeof window !== "undefined" && + window.isSecureContext && + getSpeechRecognitionAPI() !== undefined && + navigator.mediaDevices?.getUserMedia !== undefined + ); + } + + listen(): DictationAdapter.Session { + const SpeechRecognitionAPI = getSpeechRecognitionAPI(); + if (!SpeechRecognitionAPI || !navigator.mediaDevices?.getUserMedia) { + throw new Error("Speech recognition is not supported in this browser."); + } + + const recognition = new SpeechRecognitionAPI(); + recognition.lang = this.language; + recognition.continuous = this.continuous; + recognition.interimResults = this.interimResults; + + const speechStartCallbacks = new Set<() => void>(); + const speechEndCallbacks = new Set<(result: DictationAdapter.Result) => void>(); + const speechCallbacks = new Set<(result: DictationAdapter.Result) => void>(); + + let stream: MediaStream | null = null; + let finalTranscript = ""; + let ended = false; + let started = false; + let resolveEnded: (() => void) | null = null; + const endedPromise = new Promise((resolve) => { + resolveEnded = resolve; + }); + + const session: DictationAdapter.Session = { + status: { type: "starting" }, + + stop: async () => { + if (!ended && started) { + recognition.stop(); + } else if (!ended) { + finish("stopped"); + } + await endedPromise; + }, + + cancel: () => { + if (!ended && started) { + recognition.abort(); + } else if (!ended) { + finish("cancelled"); + } + }, + + onSpeechStart: (callback) => { + speechStartCallbacks.add(callback); + return () => { + speechStartCallbacks.delete(callback); + }; + }, + + onSpeechEnd: (callback) => { + speechEndCallbacks.add(callback); + return () => { + speechEndCallbacks.delete(callback); + }; + }, + + onSpeech: (callback) => { + speechCallbacks.add(callback); + return () => { + speechCallbacks.delete(callback); + }; + }, + }; + + const finish = (reason: "stopped" | "cancelled" | "error") => { + if (ended) return; + ended = true; + session.status = { type: "ended", reason }; + stopStream(stream); + stream = null; + if (finalTranscript) { + for (const callback of speechEndCallbacks) { + callback({ transcript: finalTranscript }); + } + finalTranscript = ""; + } + resolveEnded?.(); + }; + + recognition.addEventListener("start", () => { + session.status = { type: "running" }; + }); + + recognition.addEventListener("speechstart", () => { + for (const callback of speechStartCallbacks) callback(); + }); + + recognition.addEventListener("result", (event) => { + const speechEvent = event as SpeechRecognitionEvent; + for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i++) { + const result = speechEvent.results[i]; + if (!result) continue; + const transcript = result[0]?.transcript ?? ""; + if (result.isFinal) { + finalTranscript += transcript; + for (const callback of speechCallbacks) { + callback({ transcript, isFinal: true }); + } + } else { + for (const callback of speechCallbacks) { + callback({ transcript, isFinal: false }); + } + } + } + }); + + recognition.addEventListener("end", () => { + finish("stopped"); + }); + + recognition.addEventListener("error", (event) => { + const errorEvent = event as SpeechRecognitionErrorEvent; + if (errorEvent.error === "aborted") { + finish("cancelled"); + return; + } + const description = describeSpeechError(errorEvent.error, errorEvent.message); + console.error("Dictation error:", errorEvent.error, errorEvent.message); + toast.error(description); + finish("error"); + }); + + void (async () => { + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true }, + }); + if (ended) { + stopStream(stream); + stream = null; + return; + } + const audioTrack = stream.getAudioTracks()[0]; + if (!audioTrack || audioTrack.readyState !== "live") { + throw new DOMException("No live microphone track is available.", "NotFoundError"); + } + try { + recognition.start(audioTrack); + } catch (error) { + // Older engines expose only start(); retry without the experimental track overload. + console.debug("Dictation start(audioTrack) failed; retrying start().", error); + recognition.start(); + } + started = true; + } catch (error) { + const description = describeMediaError(error); + console.error("Dictation microphone error:", error); + toast.error(description); + finish("error"); + } + })(); + + return session; + } +} diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index fc77fecaeb..1fcbe7ff70 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -47,6 +47,7 @@ import type { import type { ChatModelSummary } from "../types/runtime"; import { getStoredChatThread, + getStoredChatProject, listStoredChatThreads, updateStoredChatThread, } from "../utils/chat-history-storage"; @@ -866,6 +867,45 @@ async function resolveUseAdapter( } } +async function resolveProjectInstructions( + threadId: string | undefined, +): Promise { + const projectId = await resolveProjectId(threadId); + if (!projectId) { + return ""; + } + + const project = await getStoredChatProject(projectId).catch(() => null); + if (!project || project.archived) { + return ""; + } + return project.instructions?.trim() ?? ""; +} + +async function resolveProjectId( + threadId: string | undefined, +): Promise { + let projectId: string | null | undefined; + if (threadId) { + const thread = await getStoredChatThread(threadId).catch(() => null); + projectId = thread?.projectId ?? null; + } + if (!projectId) { + projectId = useChatRuntimeStore.getState().activeProjectId; + } + if (!projectId) { + return null; + } + return projectId; +} + +async function resolveSandboxSessionId( + threadId: string | undefined, +): Promise { + const projectId = await resolveProjectId(threadId); + return projectId ? `project-${projectId}` : threadId; +} + /** Wait for an in-progress model load to finish (polls store every 500ms). */ function waitForModelReady(abortSignal?: AbortSignal): Promise { return new Promise((resolve, reject) => { @@ -1202,6 +1242,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { // the user switches chats while waiting for model load / auto-load. const resolvedThreadId = (unstable_threadId ?? runtime.activeThreadId) || undefined; + const sandboxSessionId = await resolveSandboxSessionId(resolvedThreadId); const resolvedThreadKey = resolvedThreadId ?? null; const pendingImageEditReferenceForRun = runtime.pendingImageEditReference; const selectedImageEditReference = @@ -1442,10 +1483,20 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { const safeSystemPrompt = typeof params.systemPrompt === "string" ? params.systemPrompt : ""; - if (safeSystemPrompt.trim()) { + const projectInstructions = + await resolveProjectInstructions(resolvedThreadId); + const combinedSystemPrompt = [ + projectInstructions + ? `\n${projectInstructions}\n` + : "", + safeSystemPrompt.trim(), + ] + .filter(Boolean) + .join("\n\n"); + if (combinedSystemPrompt) { outboundMessages.unshift({ role: "system", - content: safeSystemPrompt.trim(), + content: combinedSystemPrompt, }); } let disabledToolGuard: string | null = null; @@ -1762,7 +1813,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { // /inference/cancel explicitly on abort. const onAbortCancel = () => { const body: Record = { cancel_id: cancelId }; - if (resolvedThreadId) body.session_id = resolvedThreadId; + if (sandboxSessionId) body.session_id = sandboxSessionId; // Plain fetch, not authFetch: authFetch redirects to login on // 401, which would kick the user out mid-stop. const token = getAuthToken(); @@ -2077,7 +2128,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { image_base64: imageBase64, audio_base64: audioBase64, cancel_id: cancelId, - ...(resolvedThreadId ? { session_id: resolvedThreadId } : {}), + ...(sandboxSessionId ? { session_id: sandboxSessionId } : {}), ...(useAdapter === undefined ? {} : { use_adapter: useAdapter }), ...(supportsReasoning ? reasoningStyle === "reasoning_effort" @@ -2272,7 +2323,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { const text = rawResult.slice(0, imgIdx); // Fall back to "_default" to match the backend sandbox directory // used when no session_id is provided (see tools.py _get_workdir). - const sessionId = resolvedThreadId || "_default"; + const sessionId = sandboxSessionId || "_default"; try { const images = JSON.parse( rawResult.slice(imgIdx + imgMarker.length), diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 81303d9311..7a71850d66 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -3,7 +3,12 @@ import { authFetch } from "@/features/auth"; import { formatFastApiDetail } from "@/lib/format-fastapi-error"; -import type { MessageRecord, ModelType, ThreadRecord } from "../types"; +import type { + MessageRecord, + ModelType, + ProjectRecord, + ThreadRecord, +} from "../types"; import type { AudioGenerationResponse, GgufVariantsResponse, @@ -287,19 +292,23 @@ export async function listChatThreads( args: { modelType?: ModelType; pairId?: string; + projectId?: string | null; includeArchived?: boolean; } = {}, ): Promise { const params = new URLSearchParams(); if (args.modelType) params.set("model_type", args.modelType); if (args.pairId) params.set("pair_id", args.pairId); + if (args.projectId) params.set("project_id", args.projectId); if (args.includeArchived !== undefined) { params.set("include_archived", String(args.includeArchived)); } const qs = params.toString(); const response = await authFetch(`/api/chat/threads${qs ? `?${qs}` : ""}`); const data = await parseJsonOrThrow<{ threads: ThreadRecord[] }>(response); - return data.threads; + // Always hand back an array: an older or misbehaving backend may omit the + // field or send a non-array, which would crash list consumers. + return Array.isArray(data.threads) ? data.threads : []; } export async function getChatThread( @@ -353,6 +362,76 @@ export async function deleteChatThreads(threadIds: string[]): Promise { notifyChatHistoryUpdated(); } +export async function listChatProjects( + args: { includeArchived?: boolean } = {}, +): Promise { + const params = new URLSearchParams(); + if (args.includeArchived !== undefined) { + params.set("include_archived", String(args.includeArchived)); + } + const qs = params.toString(); + const response = await authFetch(`/api/chat/projects${qs ? `?${qs}` : ""}`); + const data = await parseJsonOrThrow<{ projects: ProjectRecord[] }>(response); + // Always hand back an array: an older or misbehaving backend may omit the + // field or send a non-array, which would crash list consumers. + return Array.isArray(data.projects) ? data.projects : []; +} + +export async function getChatProject( + projectId: string, +): Promise { + const response = await authFetch( + `/api/chat/projects/${encodeURIComponent(projectId)}`, + ); + if (response.status === 404) return null; + return parseJsonOrThrow(response); +} + +export async function saveChatProject( + project: ProjectRecord, +): Promise { + const response = await authFetch("/api/chat/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(project), + }); + const saved = await parseJsonOrThrow(response); + notifyChatHistoryUpdated(); + return saved; +} + +export async function updateChatProject( + projectId: string, + patch: Partial, +): Promise { + const response = await authFetch( + `/api/chat/projects/${encodeURIComponent(projectId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }, + ); + const project = await parseJsonOrThrow(response); + notifyChatHistoryUpdated(); + return project; +} + +export async function deleteChatProject( + projectId: string, + args: { deleteFiles?: boolean } = {}, +): Promise { + const params = new URLSearchParams(); + if (args.deleteFiles) params.set("delete_files", "true"); + const qs = params.toString(); + const response = await authFetch( + `/api/chat/projects/${encodeURIComponent(projectId)}${qs ? `?${qs}` : ""}`, + { method: "DELETE" }, + ); + await parseJsonOrThrow(response); + notifyChatHistoryUpdated(); +} + export async function listChatMessages( threadId: string, ): Promise { @@ -464,6 +543,7 @@ export async function buildBackendChatExport(): Promise<{ exportedAt: string; version: number; threadCount: number; + projects?: ProjectRecord[]; threads: ThreadRecord[]; messages: MessageRecord[]; }> { diff --git a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx index b32d24469b..b4ddf38aaa 100644 --- a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx +++ b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx @@ -15,11 +15,12 @@ import { cn } from "@/lib/utils"; import { CheckIcon, CopyIcon, - DownloadIcon, EyeIcon, Maximize2Icon, XIcon, } from "lucide-react"; +import { Download01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { type KeyboardEvent, useEffect, @@ -201,10 +202,10 @@ export function ArtifactSurface({ tabIndex={variant === "overlay" ? -1 : undefined} onKeyDown={handleDialogKeyDown} className={cn( - "relative flex min-h-0 flex-col border border-border bg-background", + "relative flex min-h-0 flex-col bg-background", variant === "panel" - ? "artifact-panel-shell mx-2 mt-[72px] mb-8 h-[calc(100%_-_104px)] overflow-visible rounded-[28px] border-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", + ? "artifact-panel-shell mx-2 mt-[72px] mb-8 h-[calc(100%_-_104px)] overflow-visible rounded-[28px] border-t border-border/70 bg-card/95" + : "h-[min(92vh,900px)] w-[min(96vw,1200px)] overflow-hidden rounded-2xl border border-border shadow-xl", )} aria-label={`${artifact.title} artifact`} > @@ -266,7 +267,7 @@ export function ArtifactSurface({ onClick={() => downloadTextFile(filename, artifact.code)} aria-label="Download artifact HTML" > - + + +
+ + {projectTab === "sources" ? ( +
+ + + +
+

+ Give this project context +

+

+ Upload PDFs, documents, or other text. The model can + reference them in every chat in this project. +

+
+ +

Coming soon

+
+ ) : ( +
+ {items.map((item) => { + const preview = previews[item.id]; + return ( + + ); + })} +
+ )} +
+ + )} + + ); +} + export function ChatPage(): ReactElement { const search = useSearch({ from: "/chat" }); const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const isCurrentChatRoute = pathname.startsWith("/chat"); const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen); const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen); @@ -769,7 +1067,9 @@ export function ChatPage(): ReactElement { }); navigate({ to: "/chat", - search: { new: crypto.randomUUID() }, + search: search.project + ? { project: search.project } + : { new: crypto.randomUUID() }, replace: true, }); }) @@ -787,6 +1087,15 @@ export function ChatPage(): ReactElement { const [modelSelectorOpen, setModelSelectorOpen] = useState(false); const [modelSelectorLocked, setModelSelectorLocked] = useState(false); const viewBeforeCompareRef = useRef(null); + // Tracks the latest non-compare view so exiting compare can restore it even + // when compare was opened from a path that does not set viewBeforeCompareRef + // (e.g. the composer + menu). + const lastNonCompareViewRef = useRef(null); + useEffect(() => { + if (!search.compare) { + lastNonCompareViewRef.current = { ...search }; + } + }, [search]); const inferenceParams = useChatRuntimeStore((state) => state.params); const setInferenceParams = useChatRuntimeStore((state) => state.setParams); const activeGgufVariant = useChatRuntimeStore( @@ -803,6 +1112,30 @@ export function ChatPage(): ReactElement { const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint); const resetArtifacts = useChatArtifactsStore((state) => state.resetArtifacts); const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId); + const [currentProjectId, setCurrentProjectId] = useState( + search.project ?? null, + ); + const { projects, isLoading: projectsLoading } = useChatProjects(); + const currentProject = currentProjectId + ? (projects.find((project) => project.id === currentProjectId) ?? null) + : null; + const { items: currentProjectItems } = useChatSidebarItems({ + projectId: currentProjectId ?? "__no_project_selected__", + }); + const currentChatTitle = activeThreadId + ? currentProjectItems.find((item) => item.id === activeThreadId)?.title + : undefined; + const openProjectLanding = useCallback( + (projectId: string) => { + useChatRuntimeStore.getState().setActiveThreadId(null); + useChatRuntimeStore.getState().setActiveProjectId(projectId); + navigate({ to: "/chat", search: { project: projectId } }); + }, + [navigate], + ); + const openProjectsList = useCallback(() => { + navigate({ to: "/projects" }); + }, [navigate]); const persistedActiveThreadId = isAssistantLocalThreadId(activeThreadId) ? null : activeThreadId; @@ -1043,25 +1376,94 @@ export function ChatPage(): ReactElement { return Boolean(inferenceParams.checkpoint) && !isExternalModel; }, [inferenceParams.checkpoint, isExternalModel]); + useEffect(() => { + let canceled = false; + + async function resolveProjectId(): Promise { + if (search.project) { + setCurrentProjectId(search.project); + useChatRuntimeStore.getState().setActiveProjectId(search.project); + return; + } + + if (search.thread) { + const thread = await getStoredChatThread(search.thread).catch(() => null); + if (!canceled) { + const projectId = thread?.projectId ?? null; + setCurrentProjectId(projectId); + useChatRuntimeStore.getState().setActiveProjectId(projectId); + } + return; + } + + if (search.compare) { + const threads = await listStoredChatThreads({ + pairId: search.compare, + includeArchived: true, + }).catch(() => []); + if (!canceled) { + const projectId = threads[0]?.projectId ?? null; + setCurrentProjectId(projectId); + useChatRuntimeStore.getState().setActiveProjectId(projectId); + } + return; + } + + setCurrentProjectId(null); + useChatRuntimeStore.getState().setActiveProjectId(null); + } + + void resolveProjectId(); + return () => { + canceled = true; + }; + }, [search.compare, search.project, search.thread]); + // Derive view from URL search params const view = useMemo(() => { if (search.compare) { return { mode: "compare", pairId: search.compare, + projectId: currentProjectId, }; } if (search.thread) { - return { mode: "single", threadId: search.thread }; - } - if (persistedActiveThreadId) { - return { mode: "single", threadId: persistedActiveThreadId }; + return { + mode: "single", + threadId: search.thread, + projectId: currentProjectId, + }; } if (search.new) { - return { mode: "single", newThreadNonce: search.new }; + return { + mode: "single", + newThreadNonce: search.new, + projectId: currentProjectId, + }; } - return { mode: "single" }; - }, [search.thread, search.compare, search.new, persistedActiveThreadId]); + if (search.project) { + return { + mode: "project", + projectId: search.project, + }; + } + if (persistedActiveThreadId) { + return { + mode: "single", + threadId: persistedActiveThreadId, + projectId: currentProjectId, + }; + } + return { mode: "single", projectId: currentProjectId }; + }, [ + search.thread, + search.compare, + search.new, + search.project, + persistedActiveThreadId, + currentProjectId, + ]); const selectedArtifact = useSelectedChatArtifact(); const artifactSurface = useChatArtifactsStore((state) => state.surface); @@ -1071,7 +1473,9 @@ export function ChatPage(): ReactElement { const artifactViewKey = view.mode === "single" ? `single:${view.threadId ?? view.newThreadNonce ?? "new"}` - : `compare:${view.pairId}`; + : view.mode === "compare" + ? `compare:${view.pairId}` + : `project:${view.projectId}`; useEffect(() => { clearAutoOpenedArtifacts(); @@ -1393,19 +1797,30 @@ export function ChatPage(): ReactElement { () => setSettingsOpen(false), [setSettingsOpen], ); - const { setPinned, isMobile } = useSidebar(); - const openSidebar = useCallback(() => setPinned(true), [setPinned]); + const { isMobile } = useSidebar(); const enterCompare = useCallback(() => { viewBeforeCompareRef.current = { ...search }; useChatRuntimeStore.getState().setActiveThreadId(null); useChatRuntimeStore.getState().setContextUsage(null); - navigate({ to: "/chat", search: { compare: crypto.randomUUID() } }); - }, [navigate, search]); + navigate({ + to: "/chat", + search: { + compare: crypto.randomUUID(), + ...(currentProjectId ? { project: currentProjectId } : {}), + }, + }); + }, [currentProjectId, navigate, search]); const exitCompare = useCallback(() => { - const saved = viewBeforeCompareRef.current; - if (!saved) return; + // Prefer the explicit save; fall back to the last non-compare view so the + // composer + menu path also returns to where the user started. + const saved = viewBeforeCompareRef.current ?? lastNonCompareViewRef.current; + // No saved view (compare opened by direct URL); fall back to a fresh chat. + if (!saved) { + navigate({ to: "/chat" }); + return; + } viewBeforeCompareRef.current = null; navigate({ to: "/chat", search: saved }); // Restore usage from the last assistant message, but only if it @@ -1657,7 +2072,6 @@ export function ChatPage(): ReactElement { closeModelSelector, openSettings, closeSettings, - openSidebar, enterCompare, exitCompare, }), @@ -1669,7 +2083,6 @@ export function ChatPage(): ReactElement { exitCompare, openModelSelector, openSettings, - openSidebar, ], ); @@ -1693,11 +2106,25 @@ export function ChatPage(): ReactElement { (view.mode === "compare" || artifactSurface === "overlay"), ); + if (!isCurrentChatRoute) { + return ( +
+ ); + } + return (
+ {/* Bottom fade under the top bar so messages dissolve as they scroll + beneath it (Gemini / unsloth-sidebar style), instead of a hard cut. */} + {view.mode !== "compare" && ( +
+ )}
)} + {view.mode !== "compare" && currentProjectId && ( + + )} {pendingNativeModelIntent && view.mode !== "compare" ? ( setSettingsOpen(true)} - className="flex h-[34px] w-[34px] items-center justify-center rounded-[12px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - aria-label="Open configuration" + className="flex h-[34px] w-[34px] translate-x-[2px] cursor-pointer items-center justify-center rounded-[12px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + aria-label="Open run settings" data-tour="chat-settings" > @@ -1802,18 +2253,26 @@ export function ChatPage(): ReactElement { sideOffset={6} className="tooltip-compact" > - Open configuration + Open run settings )}
- {view.mode === "single" ? ( + {view.mode === "project" ? ( + + ) : view.mode === "single" ? ( )} diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx index 3ffb3a1441..c2e4eaab79 100644 --- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx @@ -391,9 +391,22 @@ export function ChatProvidersSettings({ const updatedAt = Number.isFinite(Date.parse(config.updated_at)) ? Date.parse(config.updated_at) : Date.now(); + const registryEntry = + registryRows.find((entry) => entry.provider_type === uiProviderType) ?? + registryRows.find((entry) => entry.provider_type === config.provider_type); + const defaultModels = pruneProviderModelIds( + uiProviderType, + registryEntry?.default_models ?? [], + ); + const savedModels = existing?.models ?? []; + const savedAvailableModels = existing?.availableModels ?? []; const existingModels = pruneProviderModelIds( uiProviderType, - existing?.models ?? [], + savedModels.length > 0 ? savedModels : defaultModels, + ); + const existingAvailableModels = pruneProviderModelIds( + uiProviderType, + savedAvailableModels.length > 0 ? savedAvailableModels : defaultModels, ); return { id: config.id, @@ -401,7 +414,7 @@ export function ChatProvidersSettings({ name: config.display_name, baseUrl: config.base_url ?? "", models: existingModels, - availableModels: existing?.availableModels ?? [], + availableModels: existingAvailableModels, enablePromptCaching: supportsProviderPromptCaching(uiProviderType) ? (existing?.enablePromptCaching ?? true) : undefined, diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index a6e42d18fc..bddcb02c69 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -58,7 +58,7 @@ import { LayoutAlignRightIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, ExternalLink } from "lucide-react"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Fragment, type ReactNode } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -324,11 +324,19 @@ function saveCollapsibleOpen(label: string, open: boolean) { function CollapsibleSection({ label, + labelHref, children, defaultOpen = false, first = false, }: { label: string; + /** + * When set, the label text becomes an external link (e.g. to the feature's + * GitHub PR) instead of part of the collapse toggle. The chevron still + * toggles open/close, so we render the two as siblings rather than nesting + * an inside the + {labelHref ? ( + + ) : ( + + )} {open &&
{children}
}
); @@ -683,26 +719,30 @@ export function ChatSettingsPanel({ } }, [open]); + const settingsScrollRef = useRef(null); + const settingsContent = ( <> -
-
+
+ {/* Header sits outside the scroll area so the scrollbar never shifts the + close button. */} +
{isMobile ? ( - - Configuration + + Run settings ) : ( <> - - Configuration + + Run settings
+
{hasModelContent && ( @@ -1332,6 +1376,7 @@ export function ChatSettingsPanel({ ) : null}
+
{ @@ -1395,7 +1440,7 @@ export function ChatSettingsPanel({ - Configuration + Run settings Chat inference settings
{settingsContent}
diff --git a/studio/frontend/src/features/chat/components/chat-search-dialog.tsx b/studio/frontend/src/features/chat/components/chat-search-dialog.tsx index eca3b4bd9f..3d867b07f7 100644 --- a/studio/frontend/src/features/chat/components/chat-search-dialog.tsx +++ b/studio/frontend/src/features/chat/components/chat-search-dialog.tsx @@ -8,7 +8,6 @@ import { CommandGroup, CommandList, } from "@/components/ui/command"; -import { useTrainingRuntimeStore } from "@/features/training"; import { Cancel01Icon, Message01Icon, SearchIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useNavigate } from "@tanstack/react-router"; @@ -17,6 +16,21 @@ import { useEffect } from "react"; import { useChatSearchIndex } from "../hooks/use-chat-search-index"; import { useChatSearchStore } from "../stores/chat-search-store"; +// cmdk's default fuzzy scorer keeps non-matching rows visible (issue #5572), so +// require every whitespace token to be a substring of the item's keywords. +// `value` is the unique thread id (cmdk selection); title/preview come via keywords. +export function chatSearchFilter( + _value: string, + search: string, + keywords?: string[], +): number { + const query = search.trim().toLowerCase(); + if (query === "") return 1; + const haystack = (keywords ?? []).join(" ").toLowerCase(); + const tokens = query.split(/\s+/); + return tokens.every((token) => haystack.includes(token)) ? 1 : 0; +} + function formatRelative(createdAt: number): string { const diff = Date.now() - createdAt; const day = 86_400_000; @@ -36,7 +50,6 @@ export function ChatSearchDialog() { useEffect(() => { const handler = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "k") return; - if (useTrainingRuntimeStore.getState().isTrainingRunning) return; const el = document.activeElement as HTMLElement | null; const tag = el?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || el?.isContentEditable) return; @@ -51,10 +64,10 @@ export function ChatSearchDialog() { - +
- + {loading ? "Loading…" @@ -86,18 +99,25 @@ export function ChatSearchDialog() { {items.map((item) => ( { navigate({ to: "/chat", search: item.type === "single" - ? { thread: item.id } - : { compare: item.id }, + ? { + thread: item.id, + ...(item.projectId ? { project: item.projectId } : {}), + } + : { + compare: item.id, + ...(item.projectId ? { project: item.projectId } : {}), + }, }); close(); }} - className="relative flex cursor-default select-none items-center gap-3 rounded-lg px-3 py-2.5 text-sm outline-hidden data-selected:bg-muted data-selected:text-foreground" + className="relative flex cursor-pointer select-none items-center gap-3 rounded-lg px-3 py-2.5 text-sm outline-hidden data-selected:bg-muted data-selected:text-foreground" > void; }; function clampProgress(value: number): number { @@ -45,7 +44,6 @@ export function ModelLoadDescription({ message, progressPercent, progressLabel, - onStop, }: ModelLoadDescriptionProps) { const hasProgress = typeof progressPercent === "number"; // Split once at the top of the render so the JSX below stays flat -- @@ -58,7 +56,7 @@ export function ModelLoadDescription({
-
+
{title ?

{title}

: null} {hasProgress ? (
@@ -82,18 +80,6 @@ export function ModelLoadDescription({

{message}

) : null}
- {onStop ? ( - - ) : null}
); } diff --git a/studio/frontend/src/features/chat/components/new-project-dialog.tsx b/studio/frontend/src/features/chat/components/new-project-dialog.tsx new file mode 100644 index 0000000000..38b0983e10 --- /dev/null +++ b/studio/frontend/src/features/chat/components/new-project-dialog.tsx @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/lib/toast"; + +import { createChatProject } from "../hooks/use-chat-projects"; +import { useChatRuntimeStore } from "../stores/chat-runtime-store"; + +// Create-project dialog usable from the composer + menu. Creating opens the new +// project straight away rather than dropping the user on the projects list. +export function NewProjectDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const navigate = useNavigate(); + const [name, setName] = useState(""); + + async function commitCreate() { + const trimmed = name.trim(); + if (!trimmed) return; + try { + const project = await createChatProject(trimmed); + onOpenChange(false); + setName(""); + const runtime = useChatRuntimeStore.getState(); + runtime.setActiveThreadId(null); + runtime.setActiveProjectId(project.id); + navigate({ to: "/chat", search: { project: project.id } }); + } catch (err) { + toast.error("Failed to create project", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + return ( + { + if (!next) setName(""); + onOpenChange(next); + }} + > + + + New project + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void commitCreate(); + } + }} + autoFocus={true} + maxLength={120} + placeholder="Project name" + aria-label="Project name" + className="focus-visible:border-input focus-visible:ring-0" + /> + + + + + + + ); +} diff --git a/studio/frontend/src/features/chat/components/project-switcher.tsx b/studio/frontend/src/features/chat/components/project-switcher.tsx new file mode 100644 index 0000000000..3deaf4701d --- /dev/null +++ b/studio/frontend/src/features/chat/components/project-switcher.tsx @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + ArrowDown01Icon, + Folder01Icon, + Tick02Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { ReactElement } from "react"; +import type { ProjectRecord } from "../types"; + +export function ProjectSwitcher({ + currentProject, + projects, + isLoading, + onSelectProject, + onViewAllProjects, +}: { + currentProject: ProjectRecord | null; + projects: ProjectRecord[]; + isLoading: boolean; + onSelectProject: (projectId: string) => void; + onViewAllProjects: () => void; +}): ReactElement { + const showLoadingRow = isLoading && projects.length === 0; + const showEmptyRow = !isLoading && projects.length === 0; + const label = currentProject?.name ?? (isLoading ? "Project" : "Projects"); + + return ( + + + + + + {showLoadingRow ? ( + + Loading… + + ) : null} + {showEmptyRow ? ( + + No projects yet + + ) : null} + {projects.map((project) => { + const isActive = currentProject?.id === project.id; + return ( + onSelectProject(project.id)} + className="justify-between" + > + + + {project.name} + + {isActive ? ( + + ) : null} + + ); + })} + + + View all projects + + + + ); +} diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 1cac12ab13..e6892b34fd 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -56,10 +56,16 @@ type SelectedModelInput = { }; const MODEL_LOAD_TOAST_CLASSNAMES = { - toast: "items-start gap-2.5", + toast: "chat-model-load-toast items-center gap-2.5", content: "gap-0.5 flex-1 min-w-0", title: "leading-5", description: "mt-0 w-full", + cancelButton: + "!h-auto !rounded-none !border-0 !bg-transparent !px-1 !text-[11px] !font-normal !text-muted-foreground hover:!bg-transparent hover:!text-destructive focus-visible:!text-destructive", +} as const; + +const MODEL_LOADED_TOAST_CLASSNAMES = { + toast: "chat-model-loaded-toast items-center gap-2.5", } as const; const LORA_SUFFIX_RE = /_(\d{9,})$/; @@ -78,6 +84,12 @@ function stripTrailingEpoch(input: string): string { return cleaned || input; } +function shortModelLabel(idOrName: string): string { + const slash = idOrName.lastIndexOf("/"); + const label = slash >= 0 ? idOrName.slice(slash + 1) : idOrName; + return label || idOrName; +} + function describeModel(model: { is_lora?: boolean; is_vision?: boolean; @@ -233,19 +245,18 @@ export function useChatModelRuntime() { message: string, progressPercent?: number | null, progressLabel?: string | null, - onStop?: () => void, ) => createElement(ModelLoadDescription, { title, message, progressPercent, progressLabel, - onStop, }), [], ); - const refresh = useCallback(async () => { + const refresh = useCallback(async (options?: { signal?: AbortSignal }) => { + const signal = options?.signal; setModelsError(null); try { const [listRes, statusRes, lorasRes] = await Promise.all([ @@ -254,6 +265,11 @@ export function useChatModelRuntime() { listLoras(), ]); + // Cancellation can land while the requests above are in flight (e.g. the + // user cancels a load during this refresh). Bail before writing any + // backend state back into the store -- cancelLoading already cleared it. + if (signal?.aborted) return; + setModels(listRes.models.map(toChatModelSummary)); setLoras(lorasRes.loras.map(toLoraSummary)); @@ -395,6 +411,7 @@ export function useChatModelRuntime() { }); } } catch (error) { + if (signal?.aborted) return; const message = error instanceof Error ? error.message : "Failed to load models"; setModelsError(message); @@ -471,10 +488,11 @@ export function useChatModelRuntime() { const isLora = explicitIsLora ?? model?.isLora ?? loraIsAdapter ?? false; const displayName = model?.name || lora?.name || modelId; + const toastDisplayName = shortModelLabel(displayName); const loadAttemptId = ++loadAttemptRef.current; primeNativeNotificationPermission().catch(() => undefined); const notificationModelKey = `${modelId}:${ggufVariant ?? ""}:${loadAttemptId}`; - const safeModelName = safeNotificationLabel(displayName, "The model"); + const safeModelName = safeNotificationLabel(toastDisplayName, "The model"); const currentCheckpoint = useChatRuntimeStore.getState().params.checkpoint; const previousCheckpoint = currentCheckpoint; @@ -750,7 +768,7 @@ export function useChatModelRuntime() { store.setParams({ ...store.params, ...p }); } } - await refresh(); + await refresh({ signal: abortCtrl.signal }); } catch (error) { // Skip rollback if user cancelled -- model is already being unloaded. if (abortCtrl.signal.aborted) throw error; @@ -794,25 +812,32 @@ export function useChatModelRuntime() { const isCachedLoad = isDownloaded || isCachedLora; const toastTitle = isCachedLoad ? "Starting model…" : "Downloading model…"; + const modelLoadToastOptions = (description: ReturnType) => ({ + description, + duration: Infinity, + closeButton: true, + cancel: { + label: "Cancel", + onClick: cancelLoading, + }, + classNames: MODEL_LOAD_TOAST_CLASSNAMES, + onDismiss: (dismissedToast: { id: string | number }) => { + if (loadToastIdRef.current !== dismissedToast.id) { + return; + } + setLoadToastDismissedState(true); + }, + }); const toastId = toast( null, - { - description: renderLoadDescription( + modelLoadToastOptions( + renderLoadDescription( toastTitle, loadingDescription, isCachedLoad ? null : 0, isCachedLoad ? null : "Preparing download", - cancelLoading, ), - duration: Infinity, - classNames: MODEL_LOAD_TOAST_CLASSNAMES, - onDismiss: (dismissedToast) => { - if (loadToastIdRef.current !== dismissedToast.id) { - return; - } - setLoadToastDismissedState(true); - }, - }, + ), ); loadToastIdRef.current = toastId; @@ -919,19 +944,14 @@ export function useChatModelRuntime() { if (loadToastDismissedRef.current) return; toast(null, { id: toastId, - description: renderLoadDescription( - "Downloading model…", - loadingDescription, - pct, - progressLabel, - cancelLoading, + ...modelLoadToastOptions( + renderLoadDescription( + "Downloading model…", + loadingDescription, + pct, + progressLabel, + ), ), - duration: Infinity, - classNames: MODEL_LOAD_TOAST_CLASSNAMES, - onDismiss: (dismissedToast) => { - if (loadToastIdRef.current !== dismissedToast.id) return; - setLoadToastDismissedState(true); - }, }); } else if ( prog.downloaded_bytes > 0 && @@ -958,19 +978,14 @@ export function useChatModelRuntime() { if (!loadToastDismissedRef.current) { toast(null, { id: toastId, - description: renderLoadDescription( - "Starting model…", - "Download complete. Loading the model into memory.", - 100, - "Download complete", - cancelLoading, + ...modelLoadToastOptions( + renderLoadDescription( + "Starting model…", + "Download complete. Loading the model into memory.", + 100, + "Download complete", + ), ), - duration: Infinity, - classNames: MODEL_LOAD_TOAST_CLASSNAMES, - onDismiss: (dismissedToast) => { - if (loadToastIdRef.current !== dismissedToast.id) return; - setLoadToastDismissedState(true); - }, }); } notifyNative({ @@ -1020,19 +1035,14 @@ export function useChatModelRuntime() { if (loadToastDismissedRef.current) return; toast(null, { id: toastId, - description: renderLoadDescription( - "Starting model…", - "Paging weights into memory.", - pct, - label, - cancelLoading, + ...modelLoadToastOptions( + renderLoadDescription( + "Starting model…", + "Paging weights into memory.", + pct, + label, + ), ), - duration: Infinity, - classNames: MODEL_LOAD_TOAST_CLASSNAMES, - onDismiss: (dismissedToast) => { - if (loadToastIdRef.current !== dismissedToast.id) return; - setLoadToastDismissedState(true); - }, }); } catch { // Ignore polling errors. @@ -1053,13 +1063,23 @@ export function useChatModelRuntime() { try { await performLoad(); + // User cancelled mid-refresh; cancelLoading handles teardown. + if (abortCtrl.signal.aborted) return; if (loadToastDismissedRef.current) { - toast.success(`${displayName} loaded`); + toast.success(`${toastDisplayName} loaded`, { + classNames: MODEL_LOADED_TOAST_CLASSNAMES, + closeButton: true, + duration: 8000, + }); } else { - toast.success(`${displayName} loaded`, { + toast.success(`${toastDisplayName} loaded`, { id: toastId, description: undefined, + cancel: undefined, + classNames: MODEL_LOADED_TOAST_CLASSNAMES, + closeButton: true, duration: 8000, + onDismiss: undefined, }); } notifyNative({ @@ -1078,7 +1098,11 @@ export function useChatModelRuntime() { toast.error(message, { id: toastId, description: undefined, + cancel: undefined, + classNames: undefined, + closeButton: true, duration: 8000, + onDismiss: undefined, }); } notifyNative({ diff --git a/studio/frontend/src/features/chat/hooks/use-chat-projects.ts b/studio/frontend/src/features/chat/hooks/use-chat-projects.ts new file mode 100644 index 0000000000..3f0d46982d --- /dev/null +++ b/studio/frontend/src/features/chat/hooks/use-chat-projects.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { useEffect, useState } from "react"; +import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api"; +import type { ProjectRecord } from "../types"; +import { + createStoredChatProject, + deleteStoredChatProject, + isExpectedBackgroundChatStorageError, + listStoredChatProjects, + moveStoredChatItemToProject, + updateStoredChatProject, +} from "../utils/chat-history-storage"; +import type { SidebarItem } from "./use-chat-sidebar-items"; + +let cachedProjects: ProjectRecord[] = []; + +export function useChatProjects(): { + projects: ProjectRecord[]; + isLoading: boolean; + hasLoaded: boolean; +} { + // Stay null-safe even if the cache was poisoned by a bad response. + const cached = Array.isArray(cachedProjects) ? cachedProjects : []; + const [projects, setProjects] = useState(cached); + const [isLoading, setIsLoading] = useState(cached.length === 0); + const [hasLoaded, setHasLoaded] = useState(cached.length > 0); + + useEffect(() => { + let cancelled = false; + + async function load() { + if (!cancelled) setIsLoading(true); + try { + const next = await listStoredChatProjects({ includeArchived: false }); + cachedProjects = Array.isArray(next) ? next : []; + if (!cancelled) setProjects(cachedProjects); + } catch (error) { + if (isExpectedBackgroundChatStorageError(error)) { + return; + } + if (!cancelled) throw error; + } finally { + if (!cancelled) { + setHasLoaded(true); + setIsLoading(false); + } + } + } + + const onHistoryUpdated = () => { + void load(); + }; + + void load(); + window.addEventListener(CHAT_HISTORY_UPDATED_EVENT, onHistoryUpdated); + return () => { + cancelled = true; + window.removeEventListener(CHAT_HISTORY_UPDATED_EVENT, onHistoryUpdated); + }; + }, []); + + return { projects, isLoading, hasLoaded }; +} + +export async function createChatProject(name: string): Promise { + return createStoredChatProject(name); +} + +export async function renameChatProject( + projectId: string, + name: string, +): Promise { + const trimmed = name.trim(); + if (!trimmed) throw new Error("Project name is required."); + await updateStoredChatProject(projectId, { name: trimmed }); +} + +export async function updateChatProjectInstructions( + projectId: string, + instructions: string, +): Promise { + await updateStoredChatProject(projectId, { instructions: instructions.trim() }); +} + +export async function deleteChatProject( + projectId: string, + args: { deleteFiles?: boolean } = {}, +): Promise { + await deleteStoredChatProject(projectId, args); +} + +export async function moveChatItemToProject( + item: SidebarItem, + projectId: string | null, +): Promise { + await moveStoredChatItemToProject(item, projectId); +} diff --git a/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts b/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts index 9fecf99986..f8e1535e04 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts @@ -2,7 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { useEffect, useRef, useState } from "react"; -import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api"; +import { batchListChatMessages, CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api"; import type { MessageRecord } from "../types"; import { listStoredChatMessages, @@ -15,6 +15,7 @@ export interface ChatSearchItem { title: string; preview: string; createdAt: number; + projectId?: string | null; } const THREAD_LIMIT = 200; @@ -68,6 +69,7 @@ async function buildIndex(): Promise { id: t.pairId, title: t.title, createdAt: t.createdAt, + projectId: t.projectId ?? null, }, threadIds: [t.id], }); @@ -78,6 +80,7 @@ async function buildIndex(): Promise { id: t.id, title: t.title, createdAt: t.createdAt, + projectId: t.projectId ?? null, }, threadIds: [t.id], }); @@ -87,26 +90,34 @@ async function buildIndex(): Promise { const allThreadIds = Array.from(itemThreadIds.values()).flatMap( (e) => e.threadIds, ); - const storedMessagesByThread = await Promise.all( - allThreadIds.map(async (threadId) => ({ - threadId, - messages: await listStoredChatMessages(threadId), - })), + let messagesByThread = await batchListChatMessages(allThreadIds).catch( + () => new Map(), ); - const messages = storedMessagesByThread.flatMap((entry) => entry.messages); - const byThreadId = new Map(); - for (const m of messages) { - const arr = byThreadId.get(m.threadId); - if (arr) arr.push(m); - else byThreadId.set(m.threadId, [m]); + // Legacy-only chats can exist before server-side history import finishes. + // Fill just the missing ids from the legacy-aware path instead of issuing + // one request per thread up front. + const missingThreadIds = allThreadIds.filter( + (threadId) => !messagesByThread.has(threadId), + ); + if (missingThreadIds.length > 0) { + const legacyEntries = await Promise.all( + missingThreadIds.map(async (threadId) => [ + threadId, + await listStoredChatMessages(threadId).catch(() => []), + ] as const), + ); + messagesByThread = new Map(messagesByThread); + for (const [threadId, messages] of legacyEntries) { + messagesByThread.set(threadId, messages); + } } const results: ChatSearchItem[] = []; for (const { item, threadIds } of itemThreadIds.values()) { const merged: MessageRecord[] = []; for (const tid of threadIds) { - const arr = byThreadId.get(tid); + const arr = messagesByThread.get(tid); if (arr) merged.push(...arr); } if (merged.length === 0) { @@ -141,6 +152,7 @@ export function useChatSearchIndex(enabled: boolean): { if (!enabled) { // Clear stale results so the next open doesn't flash old items. setItems([]); + setLoading(false); return; } let cancelled = false; diff --git a/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts b/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts index 87a32dcd36..54aa5d82dc 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts @@ -24,6 +24,7 @@ export interface SidebarItem { id: string; title: string; createdAt: number; + projectId?: string | null; } export function groupThreads(threads: ThreadRecord[]): SidebarItem[] { @@ -44,6 +45,7 @@ export function groupThreads(threads: ThreadRecord[]): SidebarItem[] { id: t.pairId, title: t.title, createdAt: t.createdAt, + projectId: t.projectId ?? null, }); } else if (!t.pairId) { items.push({ @@ -51,6 +53,7 @@ export function groupThreads(threads: ThreadRecord[]): SidebarItem[] { id: t.id, title: t.title, createdAt: t.createdAt, + projectId: t.projectId ?? null, }); } } @@ -63,18 +66,32 @@ export function groupThreads(threads: ThreadRecord[]): SidebarItem[] { // discards stale responses. const SIDEBAR_REFRESH_DEBOUNCE_MS = 300; -export function useChatSidebarItems() { +export function useChatSidebarItems(options?: { + projectId?: string | null; + enabled?: boolean; + requireMessages?: boolean; +}) { const [allThreads, setAllThreads] = useState([]); + const enabled = options?.enabled ?? true; + const requireMessages = options?.requireMessages ?? true; useEffect(() => { + if (!enabled) { + return; + } + let cancelled = false; let pendingTimer: ReturnType | null = null; let requestSeq = 0; async function doLoad(seq: number) { try { - const threads = await listStoredChatThreadsWithMessages({ + const listThreads = requireMessages + ? listStoredChatThreadsWithMessages + : listStoredChatThreads; + const threads = await listThreads({ includeArchived: false, + projectId: options?.projectId, }); // Discard the response if a newer request was scheduled while we // were in flight, or if the effect was torn down. @@ -107,7 +124,7 @@ export function useChatSidebarItems() { if (pendingTimer !== null) clearTimeout(pendingTimer); window.removeEventListener(CHAT_HISTORY_UPDATED_EVENT, load); }; - }, []); + }, [enabled, options?.projectId, requireMessages]); const items = groupThreads(allThreads ?? []); const canCompare = useChatRuntimeStore((s) => Boolean(s.params.checkpoint)); diff --git a/studio/frontend/src/features/chat/hooks/use-pill-activation-order.ts b/studio/frontend/src/features/chat/hooks/use-pill-activation-order.ts new file mode 100644 index 0000000000..e159101a7f --- /dev/null +++ b/studio/frontend/src/features/chat/hooks/use-pill-activation-order.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { useEffect, useState } from "react"; + +// Tracks which of the given keys are active and the order in which each became +// active, so opt-in composer pills (Canvas, MCP) render in the order they were +// toggled on rather than a fixed order. +export function usePillActivationOrder(states: Record): string[] { + const [order, setOrder] = useState(() => + Object.keys(states).filter((key) => states[key]), + ); + // Re-run only when the active/inactive set changes, not on every render. + const signature = Object.keys(states) + .map((key) => `${key}:${states[key] ? 1 : 0}`) + .join(","); + useEffect(() => { + setOrder((prev) => { + const next = prev.filter((key) => states[key]); + for (const key of Object.keys(states)) { + if (states[key] && !next.includes(key)) next.push(key); + } + const unchanged = + next.length === prev.length && next.every((key, i) => key === prev[i]); + return unchanged ? prev : next; + }); + // states is read fresh inside; signature captures its boolean values. + }, [signature]); + return order; +} diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index 063def91ae..ac05c8c089 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -21,6 +21,7 @@ export { useChatSearchStore } from "./stores/chat-search-store"; export { useChatModelRuntime } from "./hooks/use-chat-model-runtime"; export { ChatSearchDialog } from "./components/chat-search-dialog"; export { setTrainingCompareHandoff } from "./lib/training-compare-handoff"; +export type { ProjectRecord } from "./types"; export { clearAllChats, countAllChats } from "./utils/clear-all-chats"; export { ArtifactCard } from "./artifacts/artifact-card"; export { @@ -34,3 +35,11 @@ export { useChatSidebarItems, type SidebarItem, } from "./hooks/use-chat-sidebar-items"; +export { + createChatProject, + deleteChatProject, + moveChatItemToProject, + renameChatProject, + updateChatProjectInstructions, + useChatProjects, +} from "./hooks/use-chat-projects"; diff --git a/studio/frontend/src/features/chat/mcp-composer-button.tsx b/studio/frontend/src/features/chat/mcp-composer-button.tsx index b98be8bf69..92d2c319a6 100644 --- a/studio/frontend/src/features/chat/mcp-composer-button.tsx +++ b/studio/frontend/src/features/chat/mcp-composer-button.tsx @@ -1,13 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { - Cancel01Icon, - McpServerIcon, - Tick02Icon, -} from "@hugeicons/core-free-icons"; +import { McpServerIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { useCallback, useEffect, useState } from "react"; +import { CheckIcon } from "lucide-react"; +import { type FC, useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { @@ -23,7 +20,6 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; import { type McpServerConfig, @@ -34,6 +30,23 @@ import { import { ChatMcpServersDialog } from "./chat-mcp-servers-dialog"; import { useChatRuntimeStore } from "./stores/chat-runtime-store"; +// Matches the Thinking pill chevron so the affordance reads the same. +const ArrowDownStandardIcon: FC<{ className?: string }> = ({ className }) => ( + + + +); + type McpPreset = { id: string; displayName: string; // stored row name @@ -76,7 +89,11 @@ function normalizeMcpUrl(url: string): string { // Static, so it is not rebuilt on every render. const PRESET_URLS = new Set(MCP_PRESETS.map((p) => normalizeMcpUrl(p.url))); -export function McpComposerButton() { +export function McpComposerButton({ + side = "bottom", +}: { + side?: "top" | "bottom"; +} = {}) { const modelLoaded = useChatRuntimeStore( (s) => !!s.params.checkpoint && !s.modelLoading, ); @@ -93,32 +110,20 @@ export function McpComposerButton() { const [pendingUrl, setPendingUrl] = useState(null); const [hintKey, setHintKey] = useState(null); - // mcp_enabled only applies on the local tool-capable send path; grey out otherwise. - const usable = modelLoaded && supportsTools; - - // Keep the per-chat flag in step with whether any server is enabled. Reads the - // store directly so the callback stays stable (no refetch loop on mount). - const reconcileFlag = useCallback( - (rows: McpServerConfig[]) => { - const anyEnabled = rows.some((s) => s.is_enabled); - const current = useChatRuntimeStore.getState().mcpEnabledForChat; - if (anyEnabled && !current) setMcpEnabledForChat(true); - else if (!anyEnabled && current) setMcpEnabledForChat(false); - }, - [setMcpEnabledForChat], - ); + // Grey out only when a loaded model lacks tool support; with no model yet MCP + // can still be pre-selected, matching the other composer tools. + const usable = !modelLoaded || supportsTools; const refresh = useCallback(async () => { try { const rows = await listMcpServers(); setServers(rows); - reconcileFlag(rows); } catch { // Keep prior state if the list call fails. } - }, [reconcileFlag]); + }, []); - // Initial load reconciles the pill with already-enabled servers (also on open). + // Load the server list on mount and whenever the menu opens. useEffect(() => { void refresh(); }, [refresh]); @@ -206,27 +211,12 @@ export function McpComposerButton() { ? () => setHintKey((k) => (k === opts.key ? null : k)) : undefined } - className={cn( - "group/mcp relative flex items-center justify-between gap-2", - opts.enabled && - "bg-emerald-500/10 data-[highlighted]:bg-emerald-500/20", - )} + className={ + opts.enabled ? "relative text-primary font-medium" : "relative" + } > {opts.label} - {opts.enabled ? ( - - - - - ) : null} + {opts.enabled ? : null} {opts.hint ? ( @@ -260,28 +250,21 @@ export function McpComposerButton() { > MCP + - -
- MCP Servers - -
+ + MCP Servers {MCP_PRESETS.map((preset) => { const norm = normalizeMcpUrl(preset.url); return renderRow({ @@ -330,7 +313,7 @@ export function McpComposerButton() { > MCP diff --git a/studio/frontend/src/features/chat/projects-page.tsx b/studio/frontend/src/features/chat/projects-page.tsx new file mode 100644 index 0000000000..8c92526b63 --- /dev/null +++ b/studio/frontend/src/features/chat/projects-page.tsx @@ -0,0 +1,401 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "@/lib/toast"; +import { + createChatProject, + deleteChatProject, + renameChatProject, + useChatProjects, + useChatRuntimeStore, + type ProjectRecord, +} from "@/features/chat"; +import { + Delete02Icon, + Edit03Icon, + FolderAddIcon, + Search01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { MoreHorizontalIcon } from "lucide-react"; +import { useNavigate } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; + +type SortMode = "activity" | "name"; + +function formatUpdatedAgo(ts: number): string { + const diff = Date.now() - ts; + if (!Number.isFinite(diff) || diff < 0) return "just now"; + const s = Math.floor(diff / 1000); + if (s < 60) return "just now"; + const m = Math.floor(s / 60); + if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`; + const mo = Math.floor(d / 30); + if (mo < 12) return `${mo} month${mo === 1 ? "" : "s"} ago`; + const y = Math.floor(mo / 12); + return `${y} year${y === 1 ? "" : "s"} ago`; +} + +export function ProjectsPage() { + const navigate = useNavigate(); + const { projects, hasLoaded } = useChatProjects(); + + const [query, setQuery] = useState(""); + const [sortMode, setSortMode] = useState("activity"); + + const [creating, setCreating] = useState(false); + const [nameDraft, setNameDraft] = useState(""); + const [renaming, setRenaming] = useState(null); + const [renameDraft, setRenameDraft] = useState(""); + const [deleting, setDeleting] = useState(null); + + const visibleProjects = useMemo(() => { + const trimmed = query.trim().toLowerCase(); + const filtered = trimmed + ? projects.filter((p) => p.name.toLowerCase().includes(trimmed)) + : projects.slice(); + filtered.sort((a, b) => + sortMode === "name" + ? a.name.localeCompare(b.name) + : b.updatedAt - a.updatedAt, + ); + return filtered; + }, [projects, query, sortMode]); + + function openProject(projectId: string) { + const runtime = useChatRuntimeStore.getState(); + runtime.setActiveThreadId(null); + runtime.setActiveProjectId(projectId); + navigate({ to: "/chat", search: { project: projectId } }); + } + + async function commitCreate() { + const name = nameDraft.trim(); + if (!name) return; + try { + const project = await createChatProject(name); + setCreating(false); + setNameDraft(""); + openProject(project.id); + } catch (err) { + toast.error("Failed to create project", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + async function commitRename() { + const target = renaming; + const name = renameDraft.trim(); + if (!target || !name || name === target.name) { + setRenaming(null); + return; + } + setRenaming(null); + try { + await renameChatProject(target.id, name); + } catch (err) { + toast.error("Failed to rename project", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + async function commitDelete() { + const target = deleting; + if (!target) return; + setDeleting(null); + try { + await deleteChatProject(target.id); + } catch (err) { + toast.error("Failed to delete project", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + return ( +
+
+

+ Projects +

+
+
+ Sort by + +
+ +
+
+ +
+ + + + setQuery(e.target.value)} + placeholder="Search projects..." + className="h-11 pl-10" + aria-label="Search projects" + /> +
+ + {!hasLoaded ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ + + + +
+ ))} +
+ ) : visibleProjects.length === 0 ? ( +
+

+ {projects.length === 0 + ? "No projects yet." + : "No projects match your search."} +

+ {projects.length === 0 && ( + + )} +
+ ) : ( +
+ {visibleProjects.map((project) => ( +
openProject(project.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openProject(project.id); + } + }} + className="group/project-card relative flex min-h-[160px] cursor-pointer flex-col rounded-[14px] border border-border/70 bg-card p-5 text-left transition-colors hover:border-border hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > +
+

+ {project.name} +

+ + + + + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0" + > + { + setRenameDraft(project.name); + setRenaming(project); + }} + > + + Rename + + setDeleting(project)} + > + + Delete + + + +
+ {project.instructions ? ( +

+ {project.instructions} +

+ ) : null} + + Updated {formatUpdatedAgo(project.updatedAt)} + +
+ ))} +
+ )} + + {/* Create project */} + { + if (!open) setCreating(false); + }} + > + + + New project + + setNameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void commitCreate(); + } + }} + autoFocus + maxLength={120} + placeholder="Project name" + aria-label="Project name" + className="focus-visible:border-input focus-visible:ring-0" + /> + + + + + + + + {/* Rename project */} + { + if (!open) setRenaming(null); + }} + > + + + Rename project + + setRenameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void commitRename(); + } + }} + autoFocus + maxLength={120} + placeholder="Project name" + aria-label="Project name" + className="focus-visible:border-input focus-visible:ring-0" + /> + + + + + + + + {/* Delete project */} + { + if (!open) setDeleting(null); + }} + > + + + Delete project + +

+ Are you sure you want to delete {deleting?.name}? Chats in this + project will be moved back to Recents. +

+ + + + +
+
+
+ ); +} diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 21be5f6e3e..979d28a952 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -14,7 +14,6 @@ import { type PendingAttachment, type ThreadHistoryAdapter, type ThreadMessage, - WebSpeechDictationAdapter, type unstable_RemoteThreadListAdapter, useAui, useAuiEvent, @@ -34,6 +33,7 @@ import { } from "react"; import { extractText, getDocumentProxy } from "unpdf"; import { toast } from "sonner"; +import { StudioWebSpeechDictationAdapter } from "./adapters/studio-web-speech-dictation-adapter"; import { createOpenAIStreamAdapter } from "./api/chat-adapter"; import { loadConnectionsEnabled, @@ -66,6 +66,7 @@ import { syncExportedRepositoryToBackend } from "./utils/delete-thread-message"; import { getImageInputUnavailableReason } from "./utils/image-input-support"; const pendingHistoryAppendByMessageId = new Map>(); +const pendingRunStartReadyByMessageId = new Map>(); type TitleResponse = { choices?: Array<{ @@ -189,7 +190,21 @@ class PDFAttachmentAdapter implements AttachmentAdapter { } class TextAttachmentAdapter implements AttachmentAdapter { - accept = "text/plain,text/markdown,text/csv,text/xml,text/json,text/css"; + // MIME is unreliable for source files, so also match by extension + // (assistant-ui's fileMatchesAccept supports ".ext" entries). Covers + // svg, code, config and other plain-text formats; html keeps its own + // adapter below. + accept = [ + "text/plain,text/markdown,text/csv,text/xml,text/json,text/css", + "application/json,application/xml,image/svg+xml", + ".txt,.text,.log,.md,.markdown,.mdx,.rst,.csv,.tsv", + ".json,.jsonl,.ndjson,.xml,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.properties", + ".css,.scss,.sass,.less,.svg", + ".js,.jsx,.mjs,.cjs,.ts,.tsx,.py,.pyi,.ipynb,.rb,.php,.go,.rs,.java,.kt,.kts,.scala,.swift", + ".c,.h,.cc,.cpp,.hpp,.cxx,.cs,.m,.mm", + ".sh,.bash,.zsh,.fish,.ps1,.bat,.lua,.pl,.pm,.r,.jl,.dart,.vue,.svelte,.astro", + ".sql,.graphql,.gql,.proto,.tf,.tfvars,.gradle,.dockerfile,.makefile,.cmake,.diff,.patch", + ].join(","); async add({ file }: { file: File }): Promise { return { @@ -534,10 +549,12 @@ export async function ensureThreadRecord({ threadId, modelType, pairId, + projectId, }: { threadId: string; modelType: ModelType; pairId?: string; + projectId?: string | null; }): Promise { if (isChatThreadDeleted(threadId)) { return; @@ -556,6 +573,7 @@ export async function ensureThreadRecord({ modelType, modelId: currentModelId, pairId, + projectId: projectId ?? null, archived: false, createdAt: Date.now(), }; @@ -579,6 +597,8 @@ export async function ensureThreadRecord({ function createStudioDbAdapter( modelType: ModelType, pairId?: string, + projectId?: string | null, + listThreads = true, ): unstable_RemoteThreadListAdapter { return { async fetch(remoteId: string) { @@ -594,9 +614,16 @@ function createStudioDbAdapter( }, async list() { + if (!listThreads) { + return { threads: [] }; + } let threads: ThreadRecord[]; try { - threads = await listStoredChatThreads({ modelType, pairId }); + threads = await listStoredChatThreads({ + modelType, + pairId, + ...(projectId !== undefined ? { projectId } : {}), + }); } catch (error) { if (!isExpectedBackgroundChatStorageError(error)) { throw error; @@ -615,7 +642,7 @@ function createStudioDbAdapter( }, async initialize(threadId: string) { - await ensureThreadRecord({ threadId, modelType, pairId }); + await ensureThreadRecord({ threadId, modelType, pairId, projectId }); return { remoteId: threadId, externalId: undefined }; }, @@ -696,7 +723,7 @@ function createStudioDbAdapter( const running = useChatRuntimeStore.getState().runningByThreadId; if (running[paired.id]) { setTimeout(() => { - void createStudioDbAdapter(modelType, pairId).generateTitle( + void createStudioDbAdapter(modelType, pairId, projectId).generateTitle( remoteId, messages, ); @@ -740,6 +767,22 @@ function trackHistoryAppend( return write; } +function trackRunStartReady( + messageId: string, + ready: Promise, +): Promise { + pendingRunStartReadyByMessageId.set(messageId, ready); + const cleanup = () => { + setTimeout(() => { + if (pendingRunStartReadyByMessageId.get(messageId) === ready) { + pendingRunStartReadyByMessageId.delete(messageId); + } + }, 30_000); + }; + ready.then(cleanup, cleanup); + return ready; +} + async function waitForRunStartHistoryAppend( messages: Parameters[0]["messages"], ): Promise { @@ -747,20 +790,22 @@ async function waitForRunStartHistoryAppend( if (!lastMessage || lastMessage.role !== "user") { return; } - const write = pendingHistoryAppendByMessageId.get(lastMessage.id); - if (!write) { + const ready = + pendingRunStartReadyByMessageId.get(lastMessage.id) ?? + pendingHistoryAppendByMessageId.get(lastMessage.id); + if (!ready) { return; } - let didPersist = false; + let didBecomeReady = false; try { - await write; - didPersist = true; + await ready; + didBecomeReady = true; } finally { if ( - didPersist && - pendingHistoryAppendByMessageId.get(lastMessage.id) === write + didBecomeReady && + pendingRunStartReadyByMessageId.get(lastMessage.id) === ready ) { - pendingHistoryAppendByMessageId.delete(lastMessage.id); + pendingRunStartReadyByMessageId.delete(lastMessage.id); } } } @@ -783,7 +828,10 @@ function createPersistedRunAdapter(adapter: ChatModelAdapter): ChatModelAdapter }; } -function useStudioRuntimeAdapters(): StudioRuntimeAdapters { +function useStudioRuntimeAdapters( + modelType: ModelType, + pairId?: string, +): StudioRuntimeAdapters { const aui = useAui(); const history = useMemo( @@ -871,14 +919,22 @@ function useStudioRuntimeAdapters(): StudioRuntimeAdapters { }, append({ parentId, message }: ExportedMessageRepositoryItem) { + const initializeThread = aui.threadListItem().initialize(); + trackRunStartReady(message.id, initializeThread.then(() => undefined)); const write = (async () => { - const { remoteId } = await aui.threadListItem().initialize(); + const { remoteId } = await initializeThread; if (isChatThreadDeleted(remoteId)) { await deleteStoredChatThreads([remoteId]); return; } // Keep single-chat runtime state in sync once a new chat is first // persisted. Compare panes intentionally do not write global activeThreadId. + if (modelType === "base" && !pairId) { + const store = useChatRuntimeStore.getState(); + if (store.activeThreadId !== remoteId) { + store.setActiveThreadId(remoteId); + } + } const thread = await getStoredChatThread(remoteId); if (thread) { await ensureStoredChatThread(remoteId, thread); @@ -915,13 +971,13 @@ function useStudioRuntimeAdapters(): StudioRuntimeAdapters { return trackHistoryAppend(message.id, write); }, }), - [aui], + [aui, modelType, pairId], ); const dictation = useMemo( () => - WebSpeechDictationAdapter.isSupported() - ? new WebSpeechDictationAdapter() + StudioWebSpeechDictationAdapter.isSupported() + ? new StudioWebSpeechDictationAdapter() : undefined, [], ); @@ -947,8 +1003,11 @@ function useStudioRuntimeAdapters(): StudioRuntimeAdapters { const chatAdapter = createOpenAIStreamAdapter(); -function useRuntimeHook(): ReturnType { - const adapters = useStudioRuntimeAdapters(); +function useRuntimeHook( + modelType: ModelType, + pairId?: string, +): ReturnType { + const adapters = useStudioRuntimeAdapters(modelType, pairId); const persistedChatAdapter = useMemo( () => createPersistedRunAdapter(chatAdapter), [], @@ -956,6 +1015,12 @@ function useRuntimeHook(): ReturnType { return useLocalRuntime(persistedChatAdapter, { adapters }); } +function createRuntimeHook(modelType: ModelType, pairId?: string) { + return function useConfiguredRuntimeHook(): ReturnType { + return useRuntimeHook(modelType, pairId); + }; +} + function ThreadAutoSwitch({ threadId, syncActiveThreadId = true, @@ -1133,20 +1198,28 @@ export function ChatRuntimeProvider({ children, modelType = "base", pairId, + projectId, initialThreadId, newThreadNonce, syncActiveThreadId = true, + listThreads = true, }: { children: ReactNode; modelType?: ModelType; pairId?: string; + projectId?: string | null; initialThreadId?: string; newThreadNonce?: string; syncActiveThreadId?: boolean; + listThreads?: boolean; }): ReactElement { + const runtimeHook = useMemo( + () => createRuntimeHook(modelType, pairId), + [modelType, pairId], + ); const runtime = useRemoteThreadListRuntime({ - runtimeHook: useRuntimeHook, - adapter: createStudioDbAdapter(modelType, pairId), + runtimeHook, + adapter: createStudioDbAdapter(modelType, pairId, projectId, listThreads), }); const aui = useAui({}); diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index b6dbffd5d0..1717152ba0 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -2,7 +2,6 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; -import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon"; import { thinkEffortAriaLabel, thinkToggleAriaLabel, @@ -13,6 +12,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params"; @@ -23,27 +27,36 @@ import { getImageInputUnavailableReason } from "./utils/image-input-support"; import { useAui } from "@assistant-ui/react"; import { ArrowUpIcon, - DownloadIcon, - FileTextIcon, + CheckIcon, + Columns2Icon, GlobeIcon, HeadphonesIcon, - LightbulbIcon, - LightbulbOffIcon, - MicIcon, PlusIcon, SquareIcon, XIcon, } from "lucide-react"; -import { Image03Icon } from "@hugeicons/core-free-icons"; +import { + AttachmentIcon, + CodeIcon, + Download01Icon, + Folder01Icon, + FolderAddIcon, + Image03Icon, + McpServerIcon, + PencilRulerIcon, +} from "@hugeicons/core-free-icons"; +import { useNavigate } from "@tanstack/react-router"; import { HugeiconsIcon } from "@hugeicons/react"; import { toast } from "@/lib/toast"; +import { McpComposerButton } from "./mcp-composer-button"; +import { NewProjectDialog } from "./components/new-project-dialog"; +import { useChatProjects } from "./hooks/use-chat-projects"; import { loadModel, validateModel } from "./api/chat-api"; import { parseExternalModelId, providerTypeSupportsVision, } from "./external-providers"; import { useExternalProvidersStore } from "./stores/external-providers-store"; -import { McpComposerButton } from "./mcp-composer-button"; import { type ReasoningEffort, useChatRuntimeStore, @@ -56,6 +69,7 @@ import { } from "./provider-capabilities"; import { type CompositionEvent, + type FC, type KeyboardEvent, type MutableRefObject, type ReactElement, @@ -88,6 +102,49 @@ export interface CompareHandle { const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif"; const MAX_IMAGE_SIZE = 20 * 1024 * 1024; +// Inlined to avoid a new icon dependency. Kept in sync with the main composer. +const ArrowDownStandardIcon: FC<{ className?: string }> = ({ className }) => ( + + + +); + +const MicIcon: FC<{ className?: string }> = ({ className }) => ( + + + +); + +const BulbIcon: FC<{ className?: string }> = ({ className }) => ( + + + +); + function isNativeComposing(event: Event) { return "isComposing" in event && (event as InputEvent).isComposing === true; } @@ -317,15 +374,37 @@ type CompareModelSelection = { ggufVariant?: string; }; +// Tool icon plus an X overlay the CSS reveals on hover when the pill is active. +function PillGlyph({ children }: { children: ReactNode }) { + return ( + + {children} + + + ); +} + export function SharedComposer({ handlesRef, model1, model2, + onExitCompare, }: { handlesRef: CompareHandles; model1?: CompareModelSelection; model2?: CompareModelSelection; + onExitCompare?: () => void; }): ReactElement { + const navigate = useNavigate(); + // Exit compare. Uses the parent's restore handler, or a fresh chat when + // compare was opened by direct URL. + const handleExitCompare = useCallback(() => { + if (onExitCompare) { + onExitCompare(); + return; + } + navigate({ to: "/chat" }); + }, [navigate, onExitCompare]); const [text, setText] = useState(""); const [running, setRunning] = useState(false); const [comparing, setComparing] = useState(false); @@ -336,6 +415,7 @@ export function SharedComposer({ } | null>(null); const [dragging, setDragging] = useState(false); const [isComposing, setIsComposing] = useState(false); + const [newProjectOpen, setNewProjectOpen] = useState(false); const textareaRef = useRef(null); const composingRef = useRef(false); const stuckImeTimerRef = useRef | null>(null); @@ -388,6 +468,19 @@ export function SharedComposer({ ); const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled); const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled); + const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat); + const setMcpEnabledForChat = useChatRuntimeStore( + (s) => s.setMcpEnabledForChat, + ); + // Three most recently updated projects for the quick-access submenu. + const { projects } = useChatProjects(); + const recentProjects = [...projects] + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, 3); + const openProject = (projectId: string) => { + useChatRuntimeStore.getState().setActiveProjectId(projectId); + navigate({ to: "/chat", search: { project: projectId } }); + }; const webFetchToolsEnabled = useChatRuntimeStore( (s) => s.webFetchToolsEnabled, ); @@ -466,6 +559,10 @@ export function SharedComposer({ const reasoningDisabled = !modelLoaded || !effectiveSupportsReasoning; const showReasoningControl = effectiveSupportsReasoning || effectiveReasoningAlwaysOn; + const isEffort = effectiveReasoningStyle === "reasoning_effort"; + const thinkingActiveLook = isEffort + ? reasoningLockedOn || (effectiveReasoningVisualEnabled && !reasoningDisabled) + : reasoningLockedOn || (effectiveReasoningEnabled && !reasoningDisabled); // Two-pill gating: Search pill lights up when the runtime has either // a local tool runtime (supportsTools, gives us our Code/python + local // web_search) OR a server-side web_search the provider runs for us @@ -505,24 +602,34 @@ export function SharedComposer({ // and gate strictly on the provider builtin support. const isGeminiImageTier = isExternalGemini && supportsBuiltinImageGeneration; + // Disable only when a loaded model lacks the capability; with no model the + // tool can still be pre-selected and reflected, matching the + menu. const searchDisabled = - !modelLoaded || + modelLoaded && (isGeminiImageTier ? !supportsBuiltinWebSearch : !(supportsTools || supportsBuiltinWebSearch)); const codeDisabled = - !modelLoaded || - (isGeminiImageTier - ? true - : !(supportsTools || supportsBuiltinCodeExecution)) || + (modelLoaded && + (isGeminiImageTier + ? true + : !(supportsTools || supportsBuiltinCodeExecution))) || imageModeDisablesCode; // 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; + // With more than 4 pills showing, collapse them to icons only to cut clutter. + // Compare, Search and Code always show; the rest are conditional. + const pillsCompact = + 3 + + (showImagePill ? 1 : 0) + + (showWebFetchPill ? 1 : 0) + + (artifactsEnabled ? 1 : 0) + + (mcpEnabledForChat ? 1 : 0) > + 4; // Backwards-compatible alias for any other call site that may still // reference `toolsDisabled` (rare; both pills used it before). const toolsDisabled = codeDisabled; @@ -904,7 +1011,7 @@ export function SharedComposer({ return (
{ if (isTauri) return; e.preventDefault(); @@ -920,6 +1027,17 @@ export function SharedComposer({ addFiles(e.dataTransfer.files); }} > + {/* Gemini-style drop affordance, mirrored from the single composer. */} +
+ + Drop files here +
{(pendingImages.length > 0 || pendingAudio) && (
{pendingImages.map(({ id, file }) => ( @@ -980,7 +1098,10 @@ export function SharedComposer({ dir="auto" />
-
+
- { - // The picker accepts both image and audio. Don't gate the - // button on image-availability — addFiles still filters - // image files per-file when the loaded model can't take - // them, while audio attach always works. - fileInputRef.current?.click(); + { + addFiles(e.target.files); + e.target.value = ""; }} - aria-label="Add Attachment" - > - - - {activeModel?.hasAudioInput && ( - <> - { - addFiles(e.target.files); - e.target.value = ""; - }} - /> - audioInputRef.current?.click()} - aria-label="Upload audio" + /> + + {/* Same + side menu as the single-chat composer (ComposerToolsMenu), + wired to the compare composer's own file/audio inputs and tools. */} + + + + + event.preventDefault()} + > + fileInputRef.current?.click()}> + + Add photos & files + + {activeModel?.hasAudioInput && ( + audioInputRef.current?.click()} + > + + Upload audio + + )} + { + const next = !toolsEnabled; + setToolsEnabled(next); + // Mirror the Search pill: Kimi forbids search + thinking together. + if (isKimiExternal) { + setReasoningEnabled(!next, { persist: false }); + applyQwenThinkingParams(!next); + } + }} + > + + Web search + {toolsEnabled && !searchDisabled ? ( + + ) : null} + + setCodeToolsEnabled(!codeToolsEnabled)} + > + + Code + {codeToolsEnabled && !codeDisabled ? ( + + ) : null} + + {showImagePill && ( + setImageToolsEnabled(!imageToolsEnabled)} + > + + Images + {imageToolsEnabled && !imageDisabled ? ( + + ) : null} + + )} + + setArtifactsEnabled(!artifactsEnabled)} + > + + Canvas + {artifactsEnabled ? : null} + + setMcpEnabledForChat(!mcpEnabledForChat)} + > + + MCP + {mcpEnabledForChat ? : null} + + {/* RAG hidden temporarily */} + {/* Always active: this menu only renders in compare mode. + Ticked like Web search/Code; click toggles it off. */} + + + Compare chat + + + + + + + Projects + + + setNewProjectOpen(true)}> + + New project + + Recents + {recentProjects.length > 0 ? ( + recentProjects.map((project) => ( + openProject(project.id)} + > + + {project.name} + + )) + ) : ( + + No recent projects + + )} + + + + + {/* Active in compare mode; sits first. Click to exit back to single chat. */} + + + + {showImagePill && ( + )} + {showWebFetchPill && ( + + )} + {artifactsEnabled ? ( + + ) : null} + {mcpEnabledForChat ? : null} +
+ {/* mr-0.5 matches the send button inset from the edge in normal chat; + gap-1.5 matches its control spacing. */} +
{showReasoningControl ? ( - effectiveReasoningStyle === "reasoning_effort" ? ( + isEffort || supportsPreserveThinking ? ( + + + {isEffort ? ( + <> + {effectiveSupportsReasoningOff && ( + { + setReasoningEnabled(false); + applyQwenThinkingParams(false); + // Preserve thinking needs thinking on, so turn it off too. + setPreserveThinking(false); + }} + > + + {formatReasoningDisabledLabel( effectiveSupportsReasoningOff, isExternalOpenAIReasoning, checkpoint, )} - - - - - {effectiveSupportsReasoningOff && ( - { - setReasoningEnabled(false); - applyQwenThinkingParams(false); - }} - > - {formatReasoningDisabledLabel( - effectiveSupportsReasoningOff, - isExternalOpenAIReasoning, - checkpoint, + )} - {!effectiveReasoningVisualEnabled ? " \u2713" : ""} - - )} - {effectiveReasoningEffortLevels - .filter((level) => level !== "none") - .map((level) => ( + {effectiveReasoningEffortLevels + .filter((level) => level !== "none") + .map((level) => ( + { + 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, + )} + + ))} + + ) : ( + effectiveSupportsReasoningOff && + !reasoningLockedOn && ( { - setReasoningEffort(level); - setReasoningEnabled(true); - applyQwenThinkingParams(true); - // Mutual exclusion: turning thinking on for a - // Kimi model forces the web_search builtin off. - if (isKimiExternal && toolsEnabled) { + const next = !reasoningEnabled; + setReasoningEnabled(next); + applyQwenThinkingParams(next); + // Preserve thinking cannot run without thinking. + if (!next) setPreserveThinking(false); + if (isKimiExternal && next && toolsEnabled) { setToolsEnabled(false, { persist: false }); } }} > - {formatReasoningEffortLabel( - level, - externalSelection?.modelId, - )} - {effectiveReasoningVisualEnabled && - reasoningEffort === level - ? " \u2713" - : ""} + + Thinking - ))} + ) + )} + {supportsPreserveThinking && ( + { + e.preventDefault(); + const next = !preserveThinking; + setPreserveThinking(next); + // Preserve thinking requires thinking on. + if (next) { + setReasoningEnabled(true); + applyQwenThinkingParams(true); + } + }} + > + + Preserve thinking + + )} ) : ( @@ -1141,16 +1571,8 @@ export function SharedComposer({ 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]", - )} + className="unsloth-thinking-pill" + data-active={thinkingActiveLook ? "true" : "false"} aria-label={thinkToggleAriaLabel({ reasoningLockedOn, modelLoaded, @@ -1158,141 +1580,13 @@ export function SharedComposer({ effectiveReasoningEnabled, })} > - {reasoningLockedOn || - (effectiveReasoningEnabled && !reasoningDisabled) ? ( - - ) : ( - - )} - Think + + + + {thinkingActiveLook ? Thinking : null} ) ) : null} - {supportsPreserveThinking && ( - - )} - - - {showImagePill && ( - - )} - - - {showWebFetchPill && ( - - )} -
-
{dictationSupported && ( <> {!isDictating ? ( @@ -1327,7 +1621,7 @@ export function SharedComposer({ type="button" variant="default" size="icon" - className="size-8 rounded-full" + className="ml-1.5 size-8 rounded-full" onClick={stop} > @@ -1338,12 +1632,12 @@ export function SharedComposer({ side="bottom" variant="default" size="icon" - className="size-8 rounded-full" + className="ml-1.5 size-8 rounded-full" onClick={send} disabled={!canSend} aria-label="Send message" > - + )}
diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 5a5928d311..ebcd1892d1 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -332,6 +332,7 @@ type ChatRuntimeStore = { chatTemplateOverride: string | null; loadedChatTemplateOverride: string | null; activeThreadId: string | null; + activeProjectId: string | null; settingsPanelOpen: boolean; pendingAudioBase64: string | null; pendingAudioName: string | null; @@ -363,6 +364,7 @@ type ChatRuntimeStore = { setModelsError: (error: string | null) => void; setCheckpoint: (modelId: string, ggufVariant?: string | null) => void; setActiveThreadId: (threadId: string | null) => void; + setActiveProjectId: (projectId: string | null) => void; setSettingsPanelOpen: (open: boolean) => void; clearCheckpoint: () => void; setReasoningEnabled: ( @@ -662,6 +664,7 @@ export const useChatRuntimeStore = create((set, get) => ({ chatTemplateOverride: null, loadedChatTemplateOverride: null, activeThreadId: null, + activeProjectId: null, settingsPanelOpen: false, pendingAudioBase64: null, pendingAudioName: null, @@ -825,6 +828,7 @@ export const useChatRuntimeStore = create((set, get) => ({ }), setActiveThreadId: (activeThreadId) => set({ activeThreadId, contextUsage: null }), + setActiveProjectId: (activeProjectId) => set({ activeProjectId }), setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }), clearCheckpoint: () => { // Mirror setCheckpoint's persistence behavior: dropping the diff --git a/studio/frontend/src/features/chat/thread-sidebar.tsx b/studio/frontend/src/features/chat/thread-sidebar.tsx index b15c16297e..320668a61c 100644 --- a/studio/frontend/src/features/chat/thread-sidebar.tsx +++ b/studio/frontend/src/features/chat/thread-sidebar.tsx @@ -42,7 +42,11 @@ export function ThreadSidebar({ const { items } = useChatSidebarItems(); const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const activeId = - view.mode === "single" ? (view.threadId ?? storeThreadId) : view.pairId; + view.mode === "single" + ? (view.threadId ?? storeThreadId) + : view.mode === "compare" + ? view.pairId + : view.projectId; function viewForItem(item: SidebarItem): ChatView { return item.type === "single" diff --git a/studio/frontend/src/features/chat/tour/steps.tsx b/studio/frontend/src/features/chat/tour/steps.tsx index e222fdc8a0..e4f37685cc 100644 --- a/studio/frontend/src/features/chat/tour/steps.tsx +++ b/studio/frontend/src/features/chat/tour/steps.tsx @@ -9,7 +9,6 @@ export function buildChatTourSteps({ closeModelSelector, openSettings, closeSettings, - openSidebar, enterCompare, exitCompare, }: { @@ -18,7 +17,6 @@ export function buildChatTourSteps({ closeModelSelector: () => void; openSettings: () => void; closeSettings: () => void; - openSidebar: () => void; enterCompare: () => void; exitCompare: () => void; }): TourStep[] { @@ -64,33 +62,22 @@ export function buildChatTourSteps({ ]; if (canCompare) { - steps.push( - { - id: "compare-btn", - target: "chat-compare", - title: "Compare mode", - body: ( - <> - Compare any two models side-by-side. - Pick a different model for each side and see how they respond to the same prompt. - - ), - onEnter: openSidebar, - }, - { - id: "compare-view", - target: "chat-compare-view", - title: "Side-by-side threads", - body: ( - <> - Same prompt, 2 threads. If LoRA is worse than base, it’s usually - data formatting, too many epochs, or a bad checkpoint choice. - - ), - onEnter: enterCompare, - onExit: exitCompare, - }, - ); + // Compare now lives in the + menu, so there is no sidebar button to anchor + // to; the view step enters compare on its own and explains it. + steps.push({ + id: "compare-view", + target: "chat-compare-view", + title: "Side-by-side threads", + body: ( + <> + Compare any two models side-by-side, available from the + menu. Same + prompt, 2 threads. If LoRA is worse than base, it’s usually data + formatting, too many epochs, or a bad checkpoint choice. + + ), + onEnter: enterCompare, + onExit: exitCompare, + }); } return steps; diff --git a/studio/frontend/src/features/chat/types.ts b/studio/frontend/src/features/chat/types.ts index d0c4e22870..a2bd395065 100644 --- a/studio/frontend/src/features/chat/types.ts +++ b/studio/frontend/src/features/chat/types.ts @@ -4,8 +4,28 @@ export type ModelType = "base" | "lora" | "model1" | "model2"; export type ChatView = - | { mode: "single"; threadId?: string; newThreadNonce?: string } - | { mode: "compare"; pairId: string }; + | { + mode: "project"; + projectId: string; + } + | { + mode: "single"; + threadId?: string; + newThreadNonce?: string; + projectId?: string | null; + } + | { mode: "compare"; pairId: string; projectId?: string | null }; + +export interface ProjectRecord { + id: string; + name: string; + instructions?: string; + rootPath?: string | null; + sandboxPath?: string | null; + archived: boolean; + createdAt: number; + updatedAt: number; +} export interface ThreadRecord { id: string; @@ -13,6 +33,7 @@ export interface ThreadRecord { modelType: ModelType; modelId?: string; pairId?: string; + projectId?: string | null; archived: boolean; createdAt: number; /** diff --git a/studio/frontend/src/features/chat/utils/chat-history-storage.ts b/studio/frontend/src/features/chat/utils/chat-history-storage.ts index 57b303b7af..6dc53cf48e 100644 --- a/studio/frontend/src/features/chat/utils/chat-history-storage.ts +++ b/studio/frontend/src/features/chat/utils/chat-history-storage.ts @@ -4,22 +4,32 @@ import { buildBackendChatExport, clearBackendChats, + deleteChatProject, deleteChatThreads, + getChatProject, getChatMessage, getChatThread, batchListChatMessages, + listChatProjects, listChatImportLedger, listChatMessages, listChatThreads, notifyChatHistoryUpdated, recordChatImportLedger, + saveChatProject, saveChatMessage, saveChatThread, syncChatMessages, + updateChatProject, updateChatThread, } from "../api/chat-api"; import { db, DEXIE_DB_NAME } from "../db"; -import type { MessageRecord, ModelType, ThreadRecord } from "../types"; +import type { + MessageRecord, + ModelType, + ProjectRecord, + ThreadRecord, +} from "../types"; import { isChatThreadDeleted, markChatThreadsDeleted, @@ -28,6 +38,7 @@ import { type ThreadListArgs = { modelType?: ModelType; pairId?: string; + projectId?: string | null; includeArchived?: boolean; }; @@ -45,6 +56,7 @@ interface ExportedChat { exportedAt: string; version: 1; threadCount: number; + projects?: unknown[]; threads: unknown[]; messages: unknown[]; } @@ -82,6 +94,8 @@ function matchesThreadListArgs( return ( !isChatThreadDeleted(thread.id) && (!args.pairId || thread.pairId === args.pairId) && + (args.projectId === undefined || + (thread.projectId ?? null) === args.projectId) && (!args.modelType || thread.modelType === args.modelType) && (args.includeArchived !== false || !thread.archived) ); @@ -584,6 +598,72 @@ export async function listStoredChatThreadsWithMessages( return entries.filter((e) => e.hasContent).map((e) => e.thread); } +export async function listStoredChatProjects( + args: { includeArchived?: boolean } = {}, +): Promise { + return listChatProjects(args); +} + +export async function getStoredChatProject( + projectId: string, +): Promise { + return getChatProject(projectId); +} + +export async function createStoredChatProject( + name: string, +): Promise { + const trimmed = name.trim(); + if (!trimmed) { + throw new Error("Project name is required."); + } + const now = Date.now(); + return saveChatProject({ + id: crypto.randomUUID(), + name: trimmed, + instructions: "", + archived: false, + createdAt: now, + updatedAt: now, + }); +} + +export async function updateStoredChatProject( + projectId: string, + patch: Partial, +): Promise { + return updateChatProject(projectId, { + ...patch, + updatedAt: patch.updatedAt ?? Date.now(), + }); +} + +export async function deleteStoredChatProject( + projectId: string, + args: { deleteFiles?: boolean } = {}, +): Promise { + await deleteChatProject(projectId, args); +} + +export async function moveStoredChatItemToProject( + item: { type: "single" | "compare"; id: string }, + projectId: string | null, +): Promise { + const threadIds = + item.type === "single" + ? [item.id] + : (await listStoredChatThreads({ + pairId: item.id, + includeArchived: true, + })).map((thread) => thread.id); + + await Promise.all( + Array.from(new Set(threadIds)).map((threadId) => + updateStoredChatThread(threadId, { projectId }), + ), + ); +} + export async function saveStoredChatMessage( message: MessageRecord, ): Promise { @@ -767,6 +847,7 @@ export async function buildStoredChatExport(): Promise { exportedAt: new Date().toISOString(), version: 1, threadCount: threads.length, + projects: backend?.projects ?? [], threads, messages, }; diff --git a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx index ed590f226e..3b9b60e5dd 100644 --- a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx +++ b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx @@ -7,7 +7,8 @@ import { Label } from "@/components/ui/label"; import { getAuthToken } from "@/features/auth"; import { useT } from "@/i18n"; import { toastError, toastSuccess } from "@/shared/toast"; -import { Camera } from "lucide-react"; +import { Camera01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { useMemo, useRef, useState } from "react"; import { decodeJwtSubject } from "../utils/jwt-subject"; import { resizeImageFileToDataUrl } from "../utils/resize-image-file"; @@ -117,10 +118,10 @@ export function ProfilePersonalizationPanel() {
diff --git a/studio/frontend/src/features/profile/utils/avatar-initials.ts b/studio/frontend/src/features/profile/utils/avatar-initials.ts index 926f57f3b5..254a50719b 100644 --- a/studio/frontend/src/features/profile/utils/avatar-initials.ts +++ b/studio/frontend/src/features/profile/utils/avatar-initials.ts @@ -7,7 +7,7 @@ export function initialsFromName(name: string): string { return trimmed[0]!.toUpperCase(); } -/** Default blue background for avatar fallback (readable white text). */ +/** Default Unsloth-green background for avatar fallback (readable white text). */ export function avatarBgStyle(): { backgroundColor: string } { - return { backgroundColor: "hsl(217 58% 48%)" }; + return { backgroundColor: "#14b789" }; } diff --git a/studio/frontend/src/features/recipe-studio/api/index.ts b/studio/frontend/src/features/recipe-studio/api/index.ts index 06b4bc1f8b..b227b960d1 100644 --- a/studio/frontend/src/features/recipe-studio/api/index.ts +++ b/studio/frontend/src/features/recipe-studio/api/index.ts @@ -2,7 +2,10 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { authFetch } from "@/features/auth"; -import { formatFastApiDetail, readFastApiError } from "@/lib/format-fastapi-error"; +import { + formatFastApiDetail, + readFastApiError, +} from "@/lib/format-fastapi-error"; const DEFAULT_BASE = "/api/data-recipe"; @@ -212,8 +215,10 @@ async function parseErrorResponse(response: Response): Promise { // formatFastApiDetail returns null when it cannot flatten the value. const formatted = formatFastApiDetail(parsed.detail); if (formatted) return formatted; - if (typeof parsed.message === "string" && parsed.message) return parsed.message; - if (typeof parsed.raw_detail === "string" && parsed.raw_detail) return parsed.raw_detail; + if (typeof parsed.message === "string" && parsed.message) + return parsed.message; + if (typeof parsed.raw_detail === "string" && parsed.raw_detail) + return parsed.raw_detail; return text; } catch { return text; @@ -437,14 +442,10 @@ export async function uploadUnstructuredFile( file: File, blockId: string, signal?: AbortSignal, - existingFileIds?: string[], ): Promise { const formData = new FormData(); formData.append("file", file); formData.append("block_id", blockId); - if (existingFileIds?.length) { - formData.append("existing_file_ids", existingFileIds.join(",")); - } const res = await authFetch( `${DATA_DESIGNER_API_BASE}/seed/upload-unstructured-file`, diff --git a/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx index 54eae08f7c..878f9eba7c 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx @@ -44,6 +44,10 @@ import { } from "react"; import { cn } from "@/lib/utils"; import { UnstructuredDropZone, type FileEntry } from "./unstructured-drop-zone"; +import { + LOCAL_SEED_UPLOAD_MAX_BYTES, + LOCAL_SEED_UPLOAD_MAX_LABEL, +} from "./upload-limits"; import { getGithubEnvTokenStatus, inspectSeedDataset, @@ -73,7 +77,6 @@ const SELECTION_OPTIONS: Array<{ value: SeedSelectionType; label: string }> = [ ]; const LOCAL_ACCEPT = ".csv,.json,.jsonl"; -const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; const DEFAULT_CHUNK_SIZE = 1200; const DEFAULT_CHUNK_OVERLAP = 200; const MAX_CHUNK_SIZE = 20000; @@ -740,8 +743,10 @@ export function SeedDialog({ if (!localFile) { throw new Error("Select a local CSV/JSON/JSONL file first."); } - if (localFile.size > MAX_UPLOAD_BYTES) { - throw new Error("File too large (max 50MB)."); + if (localFile.size > LOCAL_SEED_UPLOAD_MAX_BYTES) { + throw new Error( + `File too large (max ${LOCAL_SEED_UPLOAD_MAX_LABEL}).`, + ); } const payload = await fileToBase64Payload(localFile); const response = await inspectSeedUpload({ @@ -980,7 +985,7 @@ export function SeedDialog({

- Max 50MB per file. + Max {LOCAL_SEED_UPLOAD_MAX_LABEL} per file.

{(localFile?.name || config.local_file_name?.trim()) && (

diff --git a/studio/frontend/src/features/recipe-studio/dialogs/seed/unstructured-drop-zone.tsx b/studio/frontend/src/features/recipe-studio/dialogs/seed/unstructured-drop-zone.tsx index 6de7cafba3..c052edebc5 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/seed/unstructured-drop-zone.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/seed/unstructured-drop-zone.tsx @@ -1,11 +1,21 @@ -import { useCallback, useRef, useState } from "react"; -import { CloudUploadIcon, Cancel01Icon, Loading03Icon, CheckmarkCircle02Icon, Alert02Icon } from "@hugeicons/core-free-icons"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + CloudUploadIcon, + Cancel01Icon, + Loading03Icon, + CheckmarkCircle02Icon, + Alert02Icon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uploadUnstructuredFile, removeUnstructuredFile } from "../../api"; +import { + UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES, + UNSTRUCTURED_RECIPE_UPLOAD_MAX_LABEL, + UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES, + UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL, +} from "./upload-limits"; const ACCEPTED_EXTENSIONS = [".txt", ".pdf", ".docx", ".md"]; -const MAX_FILE_SIZE = 50 * 1024 * 1024; -const MAX_TOTAL_SIZE = 100 * 1024 * 1024; type FileEntry = { id: string; @@ -19,7 +29,9 @@ type FileEntry = { type UnstructuredDropZoneProps = { blockId: string; files: FileEntry[]; - onFilesChange: (files: FileEntry[] | ((prev: FileEntry[]) => FileEntry[])) => void; + onFilesChange: ( + files: FileEntry[] | ((prev: FileEntry[]) => FileEntry[]), + ) => void; disabled?: boolean; }; @@ -42,16 +54,19 @@ export function UnstructuredDropZone({ }: UnstructuredDropZoneProps) { const inputRef = useRef(null); const filesRef = useRef(files); - filesRef.current = files; const [isDragOver, setIsDragOver] = useState(false); + useEffect(() => { + filesRef.current = files; + }, [files]); + const totalSize = files.reduce((sum, f) => sum + f.size, 0); const handleFiles = useCallback( async (newFiles: File[]) => { const valid = newFiles.filter((f) => { if (!isValidExtension(f.name)) return false; - if (f.size > MAX_FILE_SIZE) return false; + if (f.size > UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES) return false; return true; }); @@ -59,7 +74,8 @@ export function UnstructuredDropZone({ const addedSize = valid.reduce((s, f) => s + f.size, 0); const currentTotal = filesRef.current.reduce((sum, f) => sum + f.size, 0); - if (currentTotal + addedSize > MAX_TOTAL_SIZE) return; + if (currentTotal + addedSize > UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES) + return; const entries: FileEntry[] = valid.map((f) => ({ id: "", @@ -78,12 +94,10 @@ export function UnstructuredDropZone({ let updatedStatus: FileEntry["status"] = "error"; let updatedError: string | undefined; try { - const existingIds = filesRef.current.filter((f) => f.id).map((f) => f.id); const result = await uploadUnstructuredFile( file, blockId, entry.abortController?.signal, - existingIds, ); updatedId = result.file_id; updatedStatus = result.status === "ok" ? "ok" : "error"; @@ -98,12 +112,16 @@ export function UnstructuredDropZone({ onFilesChange((prev) => prev.map((f) => f === entry - ? { ...f, id: updatedId, status: updatedStatus, error: updatedError } + ? { + ...f, + id: updatedId, + status: updatedStatus, + error: updatedError, + } : f, ), ); } - }, [blockId, onFilesChange], ); @@ -116,7 +134,11 @@ export function UnstructuredDropZone({ if (entry.status === "uploading" && entry.abortController) { entry.abortController.abort(); } - if (entry.id && entry.status === "ok" && !deletedIdsRef.current.has(entry.id)) { + if ( + entry.id && + entry.status === "ok" && + !deletedIdsRef.current.has(entry.id) + ) { deletedIdsRef.current.add(entry.id); void removeUnstructuredFile(blockId, entry.id).catch(() => {}); } @@ -174,12 +196,16 @@ export function UnstructuredDropZone({ onDragLeave={handleDragLeave} onClick={handleClick} > - +

Drop files here or click to browse

- PDF, DOCX, TXT, MD - up to 50MB each, 100MB total + PDF, DOCX, TXT, MD - up to {UNSTRUCTURED_RECIPE_UPLOAD_MAX_LABEL}{" "} + each, {UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL} total

@@ -200,13 +226,22 @@ export function UnstructuredDropZone({ className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm" > {entry.status === "uploading" && ( - + )} {entry.status === "ok" && ( - + )} {entry.status === "error" && ( - + )} {entry.name} @@ -228,8 +263,14 @@ export function UnstructuredDropZone({
))}
- {successFiles.length} file{successFiles.length !== 1 ? "s" : ""} uploaded - {formatSize(totalSize)} / 100MB + + {successFiles.length} file{successFiles.length !== 1 ? "s" : ""}{" "} + uploaded + + + {formatSize(totalSize)} /{" "} + {UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL} +
)} diff --git a/studio/frontend/src/features/recipe-studio/dialogs/seed/upload-limits.ts b/studio/frontend/src/features/recipe-studio/dialogs/seed/upload-limits.ts new file mode 100644 index 0000000000..606179851e --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/dialogs/seed/upload-limits.ts @@ -0,0 +1,9 @@ +// 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 const LOCAL_SEED_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; +export const LOCAL_SEED_UPLOAD_MAX_LABEL = "100MB"; +export const UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES = 500 * 1024 * 1024; +export const UNSTRUCTURED_RECIPE_UPLOAD_MAX_LABEL = "500MB"; +export const UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES = 1024 * 1024 * 1024; +export const UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL = "1GB"; diff --git a/studio/frontend/src/features/settings/api/upload-limit.ts b/studio/frontend/src/features/settings/api/upload-limit.ts new file mode 100644 index 0000000000..2ef0458286 --- /dev/null +++ b/studio/frontend/src/features/settings/api/upload-limit.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { authFetch } from "@/features/auth"; +import { readFastApiError } from "@/lib/format-fastapi-error"; + +export const DEFAULT_UPLOAD_LIMIT_MB = 500; +export const DEFAULT_UPLOAD_LIMIT_BYTES = DEFAULT_UPLOAD_LIMIT_MB * 1024 * 1024; + +const UPLOAD_LIMIT_EVENT = "unsloth-upload-limit-change"; + +export type UploadLimitSettings = { + maxUploadSizeMb: number; + maxUploadSizeBytes: number; + maxUploadSizeLabel: string; + defaultUploadSizeMb: number; + minUploadSizeMb: number; + maxAllowedUploadSizeMb: number; +}; + +type ApiUploadLimitSettings = { + // biome-ignore lint/style/useNamingConvention: API schema + max_upload_size_mb: number; + // biome-ignore lint/style/useNamingConvention: API schema + max_upload_size_bytes: number; + // biome-ignore lint/style/useNamingConvention: API schema + max_upload_size_label: string; + // biome-ignore lint/style/useNamingConvention: API schema + default_upload_size_mb: number; + // biome-ignore lint/style/useNamingConvention: API schema + min_upload_size_mb: number; + // biome-ignore lint/style/useNamingConvention: API schema + max_allowed_upload_size_mb: number; +}; + +let cachedUploadLimit: UploadLimitSettings | null = null; +let inFlightUploadLimit: Promise | null = null; + +export function getCachedUploadLimitBytes() { + return cachedUploadLimit?.maxUploadSizeBytes ?? DEFAULT_UPLOAD_LIMIT_BYTES; +} + +export function getCachedUploadLimitLabel() { + return ( + cachedUploadLimit?.maxUploadSizeLabel ?? `${DEFAULT_UPLOAD_LIMIT_MB}MB` + ); +} + +export function formatUploadSize(bytes: number) { + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +export function subscribeUploadLimitSettings( + listener: (settings: UploadLimitSettings) => void, +) { + const handleChange = (event: Event) => { + listener((event as CustomEvent).detail); + }; + window.addEventListener(UPLOAD_LIMIT_EVENT, handleChange); + return () => window.removeEventListener(UPLOAD_LIMIT_EVENT, handleChange); +} + +function fromApi(settings: ApiUploadLimitSettings): UploadLimitSettings { + return { + maxUploadSizeMb: settings.max_upload_size_mb, + maxUploadSizeBytes: settings.max_upload_size_bytes, + maxUploadSizeLabel: settings.max_upload_size_label, + defaultUploadSizeMb: settings.default_upload_size_mb, + minUploadSizeMb: settings.min_upload_size_mb, + maxAllowedUploadSizeMb: settings.max_allowed_upload_size_mb, + }; +} + +function cacheUploadLimit(settings: UploadLimitSettings) { + cachedUploadLimit = settings; + window.dispatchEvent( + new CustomEvent(UPLOAD_LIMIT_EVENT, { detail: settings }), + ); + return settings; +} + +async function fetchUploadLimitSettings(): Promise { + const res = await authFetch("/api/settings/upload-limit"); + if (!res.ok) { + throw new Error(await readFastApiError(res, "Failed to load upload limit")); + } + return fromApi(await res.json()); +} + +export async function loadUploadLimitSettings() { + if (cachedUploadLimit) { + return cachedUploadLimit; + } + inFlightUploadLimit ??= fetchUploadLimitSettings() + .then(cacheUploadLimit) + .finally(() => { + inFlightUploadLimit = null; + }); + return inFlightUploadLimit; +} + +export async function updateUploadLimitSettings( + maxUploadSizeMb: number, +): Promise { + const res = await authFetch("/api/settings/upload-limit", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + // biome-ignore lint/style/useNamingConvention: API schema + body: JSON.stringify({ max_upload_size_mb: maxUploadSizeMb }), + }); + if (!res.ok) { + throw new Error( + await readFastApiError(res, "Failed to update upload limit"), + ); + } + return cacheUploadLimit(fromApi(await res.json())); +} diff --git a/studio/frontend/src/features/settings/settings-dialog.tsx b/studio/frontend/src/features/settings/settings-dialog.tsx index b6f2463b68..a1048b202e 100644 --- a/studio/frontend/src/features/settings/settings-dialog.tsx +++ b/studio/frontend/src/features/settings/settings-dialog.tsx @@ -128,8 +128,10 @@ export function SettingsDialog() { // Cap at 820px but shrink to the viewport so we don't clip // on iPad-portrait widths (640-820px) where the fixed // `w-[820px]` overflows by 26px on each side. - "!max-w-[min(820px,calc(100vw-2rem))] h-[560px] w-[min(820px,calc(100vw-2rem))] p-0 overflow-hidden", - "shadow-border rounded-xl border-border", + "settings-surface !max-w-[min(820px,calc(100vw-2rem))] h-[560px] w-[min(820px,calc(100vw-2rem))] p-0 overflow-hidden", + // Soft shadow only, no outline ring. Pin --radius to the light value + // so the corner rounding is the same in dark mode. + "shadow-border rounded-xl ring-0 [--radius:1.1rem]", "max-sm:h-dvh max-sm:w-dvw max-sm:!max-w-none max-sm:rounded-none", )} > @@ -138,7 +140,10 @@ export function SettingsDialog() { {t("settings.dialog.description")}
-