Merge branch 'main' into pip
This commit is contained in:
commit
6d3849b821
113 changed files with 9042 additions and 1617 deletions
|
|
@ -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:
|
||||
|
|
|
|||
28
README.md
28
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):
|
||||
|
|
|
|||
45
install.ps1
45
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)
|
||||
|
|
|
|||
43
install.sh
43
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..."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# ============================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# ============================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
59
studio/backend/routes/settings.py
Normal file
59
studio/backend/routes/settings.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
67
studio/backend/tests/test_dataset_upload_limits.py
Normal file
67
studio/backend/tests/test_dataset_upload_limits.py
Normal file
|
|
@ -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()) == []
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
888
studio/backend/utils/datasets/dataset_none_detect.py
Normal file
888
studio/backend/utils/datasets/dataset_none_detect.py
Normal file
|
|
@ -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_<name>() (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[<split>] 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)
|
||||
|
|
@ -25,6 +25,8 @@ from .storage_roots import (
|
|||
auth_root,
|
||||
auth_db_path,
|
||||
studio_db_path,
|
||||
documents_root,
|
||||
project_workspaces_root,
|
||||
tmp_root,
|
||||
seed_uploads_root,
|
||||
unstructured_seed_cache_root,
|
||||
|
|
@ -44,6 +46,10 @@ from .storage_roots import (
|
|||
resolve_dataset_path,
|
||||
)
|
||||
|
||||
# Re-export shim: name-load the project-path helpers so the import-hoist
|
||||
# safety net sees them used here, not just listed in __all__ as strings.
|
||||
_REEXPORTED = (documents_root, project_workspaces_root)
|
||||
|
||||
__all__ = [
|
||||
"normalize_path",
|
||||
"is_local_path",
|
||||
|
|
@ -62,6 +68,8 @@ __all__ = [
|
|||
"auth_root",
|
||||
"auth_db_path",
|
||||
"studio_db_path",
|
||||
"documents_root",
|
||||
"project_workspaces_root",
|
||||
"tmp_root",
|
||||
"seed_uploads_root",
|
||||
"unstructured_seed_cache_root",
|
||||
|
|
|
|||
|
|
@ -96,6 +96,38 @@ def studio_db_path() -> Path:
|
|||
return studio_root() / "studio.db"
|
||||
|
||||
|
||||
def _xdg_user_dir(key: str) -> Path | None:
|
||||
config = Path.home() / ".config" / "user-dirs.dirs"
|
||||
try:
|
||||
lines = config.read_text(encoding = "utf-8").splitlines()
|
||||
except OSError:
|
||||
return None
|
||||
prefix = f"{key}="
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line.startswith(prefix):
|
||||
continue
|
||||
value = line[len(prefix) :].strip().strip('"')
|
||||
if not value:
|
||||
return None
|
||||
return Path(value.replace("$HOME", str(Path.home()))).expanduser()
|
||||
return None
|
||||
|
||||
|
||||
def documents_root() -> Path:
|
||||
override = (os.environ.get("UNSLOTH_STUDIO_DOCUMENTS_HOME") or "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return _xdg_user_dir("XDG_DOCUMENTS_DIR") or (Path.home() / "Documents")
|
||||
|
||||
|
||||
def project_workspaces_root() -> Path:
|
||||
override = (os.environ.get("UNSLOTH_STUDIO_PROJECTS_HOME") or "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return documents_root() / "Unsloth Studio" / "Projects"
|
||||
|
||||
|
||||
def tmp_root() -> Path:
|
||||
return Path(tempfile.gettempdir()) / "unsloth-studio"
|
||||
|
||||
|
|
|
|||
97
studio/backend/utils/upload_limits.py
Normal file
97
studio/backend/utils/upload_limits.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""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
|
||||
198
studio/frontend/package-lock.json
generated
198
studio/frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
if (!isCurrent()) return;
|
||||
|
|
@ -39,35 +42,54 @@ async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise<void> {
|
|||
|
||||
async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void> {
|
||||
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<boolean>("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<void> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<AppProvider>
|
||||
<SettingsDialog />
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type ChatSearch = {
|
|||
thread?: string;
|
||||
compare?: string;
|
||||
new?: string;
|
||||
project?: string;
|
||||
};
|
||||
|
||||
export const Route = createRoute({
|
||||
|
|
@ -21,6 +22,7 @@ export const Route = createRoute({
|
|||
thread: typeof search.thread === "string" ? search.thread : undefined,
|
||||
compare: typeof search.compare === "string" ? search.compare : undefined,
|
||||
new: typeof search.new === "string" ? search.new : undefined,
|
||||
project: typeof search.project === "string" ? search.project : undefined,
|
||||
}),
|
||||
component: ChatPage,
|
||||
});
|
||||
|
|
|
|||
21
studio/frontend/src/app/routes/projects.tsx
Normal file
21
studio/frontend/src/app/routes/projects.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { lazy } from "react";
|
||||
import { requireAuth } from "../auth-guards";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
const ProjectsPage = lazy(() =>
|
||||
import("@/features/chat/projects-page").then((m) => ({
|
||||
default: m.ProjectsPage,
|
||||
})),
|
||||
);
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/projects",
|
||||
staticData: { title: "Projects" },
|
||||
beforeLoad: () => requireAuth(),
|
||||
component: ProjectsPage,
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -24,7 +24,9 @@ import {
|
|||
useAui,
|
||||
useAuiState,
|
||||
} from "@assistant-ui/react";
|
||||
import { FileText, PlusIcon, XIcon } from "lucide-react";
|
||||
import { File02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { PlusIcon, XIcon } from "lucide-react";
|
||||
import {
|
||||
type FC,
|
||||
type PropsWithChildren,
|
||||
|
|
@ -134,7 +136,11 @@ const AttachmentThumb: FC = () => {
|
|||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<FileText className="size-6 text-muted-foreground" />
|
||||
<HugeiconsIcon
|
||||
icon={File02Icon}
|
||||
strokeWidth={2}
|
||||
className="size-6 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DownloadIcon, PauseIcon, PlayIcon } from "lucide-react";
|
||||
import { PauseIcon, PlayIcon } from "lucide-react";
|
||||
import { Download01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type FC, useRef, useState } from "react";
|
||||
|
||||
interface AudioPlayerProps {
|
||||
|
|
@ -110,7 +112,7 @@ export const AudioPlayer: FC<AudioPlayerProps> = ({ src }) => {
|
|||
onClick={handleDownload}
|
||||
title="Download audio"
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,13 +15,14 @@ import type {
|
|||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import {
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
ImageIcon,
|
||||
ImageOffIcon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
ShieldAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { Download01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type PropsWithChildren,
|
||||
|
|
@ -427,7 +428,7 @@ function ImageActions({ part, onRegenerate, className }: ImageActionsProps) {
|
|||
aria-label="Download image"
|
||||
className="inline-flex size-7 items-center justify-center rounded hover:bg-muted"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
|||
import { preprocessLaTeX } from "@/lib/latex";
|
||||
import { openLink } from "@/lib/open-link";
|
||||
import { INTERNAL, useAuiState, useMessagePartText } from "@assistant-ui/react";
|
||||
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Copy01Icon, Download01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { createMathPlugin } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Block, type BlockProps, Streamdown } from "streamdown";
|
||||
import { createCodePlugin } from "./code-plugin";
|
||||
|
|
@ -279,7 +278,7 @@ function CodeBlockActions({
|
|||
downloadTextFile(getCodeFilename(language), source);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-icon" />
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -382,11 +381,60 @@ function StreamdownBlock(props: BlockProps) {
|
|||
}
|
||||
const AUDIO_PLAYER_RE = /<audio-player\s+src="([^"]+)"\s*\/>/;
|
||||
|
||||
// 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<number | null>(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 <AudioPlayer src={audioMatch[1]} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
|
@ -385,7 +385,7 @@ function GgufVariantExpander({
|
|||
handleVariantClick(v.quant, v.downloaded, v.size_bytes)
|
||||
}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-[6px] px-2.5 py-1 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035]",
|
||||
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-[8px] px-2.5 py-1 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#3a3d44]",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
|
|
@ -925,7 +925,7 @@ export function HubModelPicker({
|
|||
(!chatOnly && cachedModels.length > 0)) ? (
|
||||
<>
|
||||
<ListLabel
|
||||
icon={<DownloadIcon className="size-3" />}
|
||||
icon={<HugeiconsIcon icon={Download01Icon} className="size-3" />}
|
||||
collapsed={downloadedCollapsed}
|
||||
onToggle={() => setDownloadedCollapsed((v) => !v)}
|
||||
>Downloaded</ListLabel>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ function HighlightedCode({ code: source, language }: { code: string; language: s
|
|||
[source, language],
|
||||
);
|
||||
return (
|
||||
<div className="max-h-48 overflow-auto text-xs [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:!text-xs [&_[data-streamdown=code-block]]:!my-0 [&_[data-streamdown=code-block]]:!p-0 [&_[data-streamdown=code-block]]:!border-0">
|
||||
<div className="max-h-48 overflow-auto text-xs [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:!text-xs [&_[data-streamdown=code-block]]:!my-0 [&_[data-streamdown=code-block]]:!p-3 [&_[data-streamdown=code-block]]:!border-0">
|
||||
<Streamdown
|
||||
mode="static"
|
||||
plugins={{ code: codePlugin }}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
@ -143,10 +143,10 @@ function ComboboxInput({
|
|||
);
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
|
|
@ -162,23 +162,23 @@ function ComboboxContent({
|
|||
const dialogContainer = useDialogPortalContainer();
|
||||
return (
|
||||
<ComboboxPrimitive.Portal container={container ?? dialogContainer ?? undefined}>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-[120] pointer-events-auto"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 *:data-[slot=input-group]:bg-input/30 max-h-72 min-w-36 overflow-hidden rounded-xl corner-squircle duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:shadow-none group/combobox-content relative pointer-events-auto max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-[120] pointer-events-auto"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 *:data-[slot=input-group]:bg-input/30 max-h-72 min-w-36 overflow-hidden rounded-xl corner-squircle duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:shadow-none group/combobox-content relative pointer-events-auto max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-lg py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-lg py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
|
|
@ -135,7 +135,7 @@ function DropdownMenuRadioItem({
|
|||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-lg py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-lg py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -221,7 +221,7 @@ function DropdownMenuSubTrigger({
|
|||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-lg px-3 py-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-lg px-3 py-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex cursor-pointer items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -244,7 +244,7 @@ function DropdownMenuSubContent({
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
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-36 rounded-lg p-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) 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-36 rounded-lg p-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
|
|
@ -33,7 +33,7 @@ function PopoverContent({
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 flex flex-col gap-4 rounded-lg p-4 text-sm duration-100 z-50 w-72 origin-(--radix-popover-content-transform-origin) outline-hidden",
|
||||
"bg-popover text-popover-foreground 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 flex flex-col gap-4 rounded-lg p-4 text-sm duration-100 z-50 w-72 origin-(--radix-popover-content-transform-origin) outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ function SelectContent({
|
|||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 min-w-36 rounded-xl p-1 corner-squircle duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto ",
|
||||
"bg-popover text-popover-foreground 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 min-w-36 rounded-xl p-1 corner-squircle duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto ",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons"
|
|||
|
||||
const noop = () => {}
|
||||
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH = "17.5rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
|
|
@ -472,7 +472,7 @@ function SidebarGroupLabel({
|
|||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-[#94a3b8] dark:text-[#666] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0.08em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
|
||||
"text-[#94a3b8] dark:text-[#666] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<motion.span
|
||||
layoutId={`tab-bg-${ctx.id}`}
|
||||
className="absolute inset-0 rounded-xl bg-background dark:bg-input/30 dark:border dark:border-input"
|
||||
className="absolute inset-0 rounded-xl bg-background dark:bg-input/30 dark:border dark:border-input group-data-[variant=line]/tabs-list:bg-[#ececec] dark:group-data-[variant=line]/tabs-list:bg-[#2d2f33] dark:group-data-[variant=line]/tabs-list:border-0"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
|
|
|
|||
|
|
@ -17,12 +17,20 @@ type TerminalProps = {
|
|||
className?: string
|
||||
sequence?: boolean
|
||||
startOnView?: boolean
|
||||
/**
|
||||
* Render every line in its final state immediately, skipping the typing /
|
||||
* fade-in animations. Used when the terminal re-mounts for a run whose intro
|
||||
* already played (e.g. navigating away from the training page and back), so
|
||||
* the logs don't visually "restart" while the run itself keeps going.
|
||||
*/
|
||||
instant?: boolean
|
||||
}
|
||||
|
||||
type InternalLineProps = {
|
||||
__isActive?: boolean
|
||||
__onDone?: () => void
|
||||
__sequence?: boolean
|
||||
__instant?: boolean
|
||||
}
|
||||
|
||||
function useStartOnView(enabled: boolean): {
|
||||
|
|
@ -65,15 +73,18 @@ export function Terminal({
|
|||
className,
|
||||
sequence = true,
|
||||
startOnView = true,
|
||||
instant = false,
|
||||
}: TerminalProps): ReactElement {
|
||||
const { ref, started } = useStartOnView(startOnView)
|
||||
const childElements = Children.toArray(children).filter(isValidElement)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const visibleIndex = sequence
|
||||
? started
|
||||
? activeIndex
|
||||
: -1
|
||||
: Number.MAX_SAFE_INTEGER
|
||||
const visibleIndex = instant
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: sequence
|
||||
? started
|
||||
? activeIndex
|
||||
: -1
|
||||
: Number.MAX_SAFE_INTEGER
|
||||
|
||||
function handleLineDone(index: number): void {
|
||||
if (!sequence) {
|
||||
|
|
@ -99,7 +110,8 @@ export function Terminal({
|
|||
{childElements.map((child, index) =>
|
||||
cloneElement(child, {
|
||||
__sequence: sequence,
|
||||
__isActive: !sequence || visibleIndex >= index,
|
||||
__isActive: instant || !sequence || visibleIndex >= index,
|
||||
__instant: instant,
|
||||
__onDone: () => handleLineDone(index),
|
||||
key: child.key ?? index,
|
||||
} as InternalLineProps)
|
||||
|
|
@ -122,11 +134,12 @@ export function AnimatedSpan({
|
|||
startOnView = false,
|
||||
__isActive,
|
||||
__sequence,
|
||||
__instant,
|
||||
__onDone,
|
||||
}: AnimatedSpanProps): ReactElement {
|
||||
const { ref, started } = useStartOnView(startOnView)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const doneRef = useRef(false)
|
||||
const [visible, setVisible] = useState(Boolean(__instant))
|
||||
const doneRef = useRef(Boolean(__instant))
|
||||
const onDoneRef = useRef(__onDone)
|
||||
const shouldStart = __sequence ? __isActive : started
|
||||
|
||||
|
|
@ -180,11 +193,12 @@ export function TypingAnimation({
|
|||
startOnView = true,
|
||||
__isActive,
|
||||
__sequence,
|
||||
__instant,
|
||||
__onDone,
|
||||
}: TypingAnimationProps): ReactElement {
|
||||
const { ref, started } = useStartOnView(startOnView)
|
||||
const [typed, setTyped] = useState("")
|
||||
const doneRef = useRef(false)
|
||||
const [typed, setTyped] = useState(__instant ? children : "")
|
||||
const doneRef = useRef(Boolean(__instant))
|
||||
const onDoneRef = useRef(__onDone)
|
||||
const shouldStart = __sequence ? __isActive : started
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<void>((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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string> {
|
||||
const projectId = await resolveProjectId(threadId);
|
||||
if (!projectId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const project = await getStoredChatProject(projectId).catch(() => null);
|
||||
if (!project || project.archived) {
|
||||
return "";
|
||||
}
|
||||
return project.instructions?.trim() ?? "";
|
||||
}
|
||||
|
||||
async function resolveProjectId(
|
||||
threadId: string | undefined,
|
||||
): Promise<string | null> {
|
||||
let projectId: string | null | undefined;
|
||||
if (threadId) {
|
||||
const thread = await getStoredChatThread(threadId).catch(() => null);
|
||||
projectId = thread?.projectId ?? null;
|
||||
}
|
||||
if (!projectId) {
|
||||
projectId = useChatRuntimeStore.getState().activeProjectId;
|
||||
}
|
||||
if (!projectId) {
|
||||
return null;
|
||||
}
|
||||
return projectId;
|
||||
}
|
||||
|
||||
async function resolveSandboxSessionId(
|
||||
threadId: string | undefined,
|
||||
): Promise<string | undefined> {
|
||||
const projectId = await resolveProjectId(threadId);
|
||||
return projectId ? `project-${projectId}` : threadId;
|
||||
}
|
||||
|
||||
/** Wait for an in-progress model load to finish (polls store every 500ms). */
|
||||
function waitForModelReady(abortSignal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -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
|
||||
? `<project_instructions>\n${projectInstructions}\n</project_instructions>`
|
||||
: "",
|
||||
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<string, string> = { cancel_id: cancelId };
|
||||
if (resolvedThreadId) body.session_id = resolvedThreadId;
|
||||
if (sandboxSessionId) body.session_id = sandboxSessionId;
|
||||
// Plain fetch, not authFetch: authFetch redirects to login on
|
||||
// 401, which would kick the user out mid-stop.
|
||||
const token = getAuthToken();
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<ThreadRecord[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (args.modelType) params.set("model_type", args.modelType);
|
||||
if (args.pairId) params.set("pair_id", args.pairId);
|
||||
if (args.projectId) params.set("project_id", args.projectId);
|
||||
if (args.includeArchived !== undefined) {
|
||||
params.set("include_archived", String(args.includeArchived));
|
||||
}
|
||||
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<void> {
|
|||
notifyChatHistoryUpdated();
|
||||
}
|
||||
|
||||
export async function listChatProjects(
|
||||
args: { includeArchived?: boolean } = {},
|
||||
): Promise<ProjectRecord[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (args.includeArchived !== undefined) {
|
||||
params.set("include_archived", String(args.includeArchived));
|
||||
}
|
||||
const qs = params.toString();
|
||||
const response = await authFetch(`/api/chat/projects${qs ? `?${qs}` : ""}`);
|
||||
const data = await parseJsonOrThrow<{ projects: ProjectRecord[] }>(response);
|
||||
// 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<ProjectRecord | null> {
|
||||
const response = await authFetch(
|
||||
`/api/chat/projects/${encodeURIComponent(projectId)}`,
|
||||
);
|
||||
if (response.status === 404) return null;
|
||||
return parseJsonOrThrow<ProjectRecord>(response);
|
||||
}
|
||||
|
||||
export async function saveChatProject(
|
||||
project: ProjectRecord,
|
||||
): Promise<ProjectRecord> {
|
||||
const response = await authFetch("/api/chat/projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(project),
|
||||
});
|
||||
const saved = await parseJsonOrThrow<ProjectRecord>(response);
|
||||
notifyChatHistoryUpdated();
|
||||
return saved;
|
||||
}
|
||||
|
||||
export async function updateChatProject(
|
||||
projectId: string,
|
||||
patch: Partial<ProjectRecord>,
|
||||
): Promise<ProjectRecord> {
|
||||
const response = await authFetch(
|
||||
`/api/chat/projects/${encodeURIComponent(projectId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
},
|
||||
);
|
||||
const project = await parseJsonOrThrow<ProjectRecord>(response);
|
||||
notifyChatHistoryUpdated();
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function deleteChatProject(
|
||||
projectId: string,
|
||||
args: { deleteFiles?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
if (args.deleteFiles) params.set("delete_files", "true");
|
||||
const qs = params.toString();
|
||||
const response = await authFetch(
|
||||
`/api/chat/projects/${encodeURIComponent(projectId)}${qs ? `?${qs}` : ""}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
await parseJsonOrThrow<ProjectRecord>(response);
|
||||
notifyChatHistoryUpdated();
|
||||
}
|
||||
|
||||
export async function listChatMessages(
|
||||
threadId: string,
|
||||
): Promise<MessageRecord[]> {
|
||||
|
|
@ -464,6 +543,7 @@ export async function buildBackendChatExport(): Promise<{
|
|||
exportedAt: string;
|
||||
version: number;
|
||||
threadCount: number;
|
||||
projects?: ProjectRecord[];
|
||||
threads: ThreadRecord[];
|
||||
messages: MessageRecord[];
|
||||
}> {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -330,7 +331,7 @@ export function ArtifactSurface({
|
|||
className="h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-auto text-xs leading-relaxed [&_[data-streamdown=code-block]]:!rounded-none [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:text-xs [&_pre]:leading-relaxed [&_code]:text-xs">
|
||||
<div className="h-full overflow-auto text-xs leading-relaxed [&_[data-streamdown=code-block]]:!my-0 [&_[data-streamdown=code-block]]:!gap-0 [&_[data-streamdown=code-block]]:!rounded-none [&_[data-streamdown=code-block]]:!border-0 [&_[data-streamdown=code-block]]:!bg-transparent [&_[data-streamdown=code-block]]:!p-0 [&_[data-streamdown=code-block-body]]:!border-0 [&_[data-streamdown=code-block-body]]:!bg-transparent [&_[data-streamdown=code-block-body]]:!p-0 [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:text-xs [&_pre]:leading-relaxed [&_code]:text-xs">
|
||||
<Streamdown
|
||||
mode="streaming"
|
||||
plugins={{ code: artifactSourceCodePlugin }}
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import {
|
|||
type ModelOption,
|
||||
ModelSelector,
|
||||
} from "@/components/assistant-ui/model-selector";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { ProjectComposer, Thread } from "@/components/assistant-ui/thread";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
||||
import {
|
||||
NativeModelChip,
|
||||
|
|
@ -29,12 +30,17 @@ import {
|
|||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CustomizeIcon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Folder02Icon,
|
||||
FolderAddIcon,
|
||||
LayoutAlignRightIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { useNavigate, useRouterState, useSearch } from "@tanstack/react-router";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import type { PanelImperativeHandle } from "react-resizable-panels";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactElement,
|
||||
memo,
|
||||
useCallback,
|
||||
|
|
@ -49,12 +55,18 @@ import { ChatSettingsPanel } from "./chat-settings-sheet";
|
|||
import { CopyableErrorChip } from "@/components/ui/copyable-error-chip";
|
||||
import { ContextUsageBar } from "./components/context-usage-bar";
|
||||
import { ModelLoadInlineStatus } from "./components/model-load-status";
|
||||
import { ProjectSwitcher } from "./components/project-switcher";
|
||||
import {
|
||||
buildExternalModelId,
|
||||
isExternalModelId,
|
||||
parseExternalModelId,
|
||||
} from "./external-providers";
|
||||
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
|
||||
import { useChatProjects } from "./hooks/use-chat-projects";
|
||||
import {
|
||||
type SidebarItem,
|
||||
useChatSidebarItems,
|
||||
} from "./hooks/use-chat-sidebar-items";
|
||||
import {
|
||||
clearTrainingCompareHandoff,
|
||||
getTrainingCompareHandoff,
|
||||
|
|
@ -181,12 +193,14 @@ const ARTIFACT_SURFACE_POP_DELAY_MS = 150;
|
|||
const SingleContent = memo(function SingleContent({
|
||||
threadId,
|
||||
newThreadNonce,
|
||||
projectId,
|
||||
artifact,
|
||||
artifactSurface,
|
||||
onCloseArtifact,
|
||||
}: {
|
||||
threadId?: string;
|
||||
newThreadNonce?: string;
|
||||
projectId?: string | null;
|
||||
artifact?: ChatArtifact | null;
|
||||
artifactSurface: ChatArtifactSurface;
|
||||
onCloseArtifact: () => void;
|
||||
|
|
@ -272,6 +286,8 @@ const SingleContent = memo(function SingleContent({
|
|||
modelType="base"
|
||||
initialThreadId={threadId}
|
||||
newThreadNonce={newThreadNonce}
|
||||
projectId={projectId}
|
||||
listThreads={false}
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
|
|
@ -365,31 +381,44 @@ function useIsLoraCompare(): boolean {
|
|||
|
||||
const CompareContent = memo(function CompareContent({
|
||||
pairId,
|
||||
projectId,
|
||||
models,
|
||||
loraModels,
|
||||
externalModels,
|
||||
onFoldersChange,
|
||||
onModelsChange,
|
||||
deleteDisabled,
|
||||
onExitCompare,
|
||||
}: {
|
||||
pairId: string;
|
||||
projectId?: string | null;
|
||||
models: ModelOption[];
|
||||
loraModels: LoraModelOption[];
|
||||
externalModels: ExternalModelOption[];
|
||||
onFoldersChange?: () => void;
|
||||
onModelsChange?: (deletedModel?: DeletedModelRef) => void;
|
||||
deleteDisabled?: boolean;
|
||||
onExitCompare?: () => void;
|
||||
}): ReactElement {
|
||||
const isLoraCompare = useIsLoraCompare();
|
||||
|
||||
return isLoraCompare ? (
|
||||
<LoraCompareContent pairId={pairId} />
|
||||
<LoraCompareContent
|
||||
pairId={pairId}
|
||||
onExitCompare={onExitCompare}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : (
|
||||
<GeneralCompareContent
|
||||
pairId={pairId}
|
||||
projectId={projectId}
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
externalModels={externalModels}
|
||||
onFoldersChange={onFoldersChange}
|
||||
onModelsChange={onModelsChange}
|
||||
deleteDisabled={deleteDisabled}
|
||||
onExitCompare={onExitCompare}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -408,6 +437,7 @@ const CompareContent = memo(function CompareContent({
|
|||
function ComparePane({
|
||||
modelType,
|
||||
pairId,
|
||||
projectId,
|
||||
initialThreadId,
|
||||
handleName,
|
||||
header,
|
||||
|
|
@ -415,6 +445,7 @@ function ComparePane({
|
|||
}: {
|
||||
modelType: "base" | "lora" | "model1" | "model2";
|
||||
pairId: string;
|
||||
projectId?: string | null;
|
||||
initialThreadId: string | undefined;
|
||||
handleName: string;
|
||||
header: ReactElement;
|
||||
|
|
@ -432,6 +463,7 @@ function ComparePane({
|
|||
<ChatRuntimeProvider
|
||||
modelType={modelType}
|
||||
pairId={pairId}
|
||||
projectId={projectId}
|
||||
initialThreadId={initialThreadId}
|
||||
syncActiveThreadId={false}
|
||||
>
|
||||
|
|
@ -487,7 +519,13 @@ function CompareShell({
|
|||
/** Fast path: same model, adapter on/off, simultaneous generation. */
|
||||
const LoraCompareContent = memo(function LoraCompareContent({
|
||||
pairId,
|
||||
}: { pairId: string }): ReactElement {
|
||||
onExitCompare,
|
||||
projectId,
|
||||
}: {
|
||||
pairId: string;
|
||||
onExitCompare?: () => void;
|
||||
projectId?: string | null;
|
||||
}): ReactElement {
|
||||
const handlesRef = useRef<Record<string, CompareHandle>>({});
|
||||
const [baseThreadId, setBaseThreadId] = useState<string>();
|
||||
const [loraThreadId, setLoraThreadId] = useState<string>();
|
||||
|
|
@ -513,12 +551,18 @@ const LoraCompareContent = memo(function LoraCompareContent({
|
|||
return (
|
||||
<CompareShell
|
||||
handlesRef={handlesRef}
|
||||
composer={<SharedComposer handlesRef={handlesRef} />}
|
||||
composer={
|
||||
<SharedComposer
|
||||
handlesRef={handlesRef}
|
||||
onExitCompare={onExitCompare}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<ComparePane
|
||||
modelType="base"
|
||||
pairId={pairId}
|
||||
projectId={projectId}
|
||||
initialThreadId={baseThreadId}
|
||||
handleName="base"
|
||||
header={
|
||||
|
|
@ -532,6 +576,7 @@ const LoraCompareContent = memo(function LoraCompareContent({
|
|||
<ComparePane
|
||||
modelType="lora"
|
||||
pairId={pairId}
|
||||
projectId={projectId}
|
||||
initialThreadId={loraThreadId}
|
||||
handleName="lora"
|
||||
borderClassName="border-t border-border/60 md:border-t-0 md:border-l"
|
||||
|
|
@ -557,6 +602,7 @@ const LoraCompareContent = memo(function LoraCompareContent({
|
|||
function GeneralCompareHeader({
|
||||
models,
|
||||
loraModels,
|
||||
externalModels,
|
||||
value,
|
||||
onValueChange,
|
||||
onFoldersChange,
|
||||
|
|
@ -566,6 +612,7 @@ function GeneralCompareHeader({
|
|||
}: {
|
||||
models: ModelOption[];
|
||||
loraModels: LoraModelOption[];
|
||||
externalModels: ExternalModelOption[];
|
||||
value: string;
|
||||
onValueChange: (
|
||||
id: string,
|
||||
|
|
@ -586,6 +633,7 @@ function GeneralCompareHeader({
|
|||
<ModelSelector
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
externalModels={externalModels}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
onFoldersChange={onFoldersChange}
|
||||
|
|
@ -601,18 +649,24 @@ function GeneralCompareHeader({
|
|||
/** General path: any two models, sequential load → generate. */
|
||||
const GeneralCompareContent = memo(function GeneralCompareContent({
|
||||
pairId,
|
||||
projectId,
|
||||
models,
|
||||
loraModels,
|
||||
externalModels,
|
||||
onFoldersChange,
|
||||
onModelsChange,
|
||||
deleteDisabled,
|
||||
onExitCompare,
|
||||
}: {
|
||||
pairId: string;
|
||||
projectId?: string | null;
|
||||
models: ModelOption[];
|
||||
loraModels: LoraModelOption[];
|
||||
externalModels: ExternalModelOption[];
|
||||
onFoldersChange?: () => void;
|
||||
onModelsChange?: (deletedModel?: DeletedModelRef) => void;
|
||||
deleteDisabled?: boolean;
|
||||
onExitCompare?: () => void;
|
||||
}): ReactElement {
|
||||
const handlesRef = useRef<Record<string, CompareHandle>>({});
|
||||
const [model1ThreadId, setModel1ThreadId] = useState<string>();
|
||||
|
|
@ -677,6 +731,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
handlesRef={handlesRef}
|
||||
model1={model1}
|
||||
model2={model2}
|
||||
onExitCompare={onExitCompare}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
@ -684,6 +739,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
<ComparePane
|
||||
modelType="model1"
|
||||
pairId={pairId}
|
||||
projectId={projectId}
|
||||
initialThreadId={model1ThreadId}
|
||||
handleName="model1"
|
||||
header={
|
||||
|
|
@ -691,6 +747,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
side="left"
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
externalModels={externalModels}
|
||||
value={model1.id}
|
||||
onValueChange={(id, meta) =>
|
||||
setModel1({
|
||||
|
|
@ -708,6 +765,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
<ComparePane
|
||||
modelType="model2"
|
||||
pairId={pairId}
|
||||
projectId={projectId}
|
||||
initialThreadId={model2ThreadId}
|
||||
handleName="model2"
|
||||
borderClassName="border-t border-sidebar-border md:border-t-0 md:border-l"
|
||||
|
|
@ -716,6 +774,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
side="right"
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
externalModels={externalModels}
|
||||
value={model2.id}
|
||||
onValueChange={(id, meta) =>
|
||||
setModel2({
|
||||
|
|
@ -735,9 +794,248 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
);
|
||||
});
|
||||
|
||||
function formatProjectChatDate(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
function extractMessageText(content: MessageRecord["content"]): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
return content
|
||||
.map((part) => {
|
||||
if (part.type === "text") {
|
||||
return part.text;
|
||||
}
|
||||
if (part.type === "image") {
|
||||
return "Image";
|
||||
}
|
||||
if (part.type === "audio") {
|
||||
return "Audio";
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function ProjectLanding({
|
||||
projectId,
|
||||
projectName,
|
||||
items,
|
||||
}: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
items: SidebarItem[];
|
||||
}): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
|
||||
const initialActiveThreadRef = useRef<string | null>(null);
|
||||
const [projectTab, setProjectTab] = useState<"chats" | "sources">("chats");
|
||||
const [pendingNewThreadId, setPendingNewThreadId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [newThreadNonce, setNewThreadNonce] = useState(() =>
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
const [previews, setPreviews] = useState<
|
||||
Record<string, { snippet: string; date: string }>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
initialActiveThreadRef.current =
|
||||
useChatRuntimeStore.getState().activeThreadId;
|
||||
useChatRuntimeStore.getState().setActiveThreadId(null);
|
||||
useChatRuntimeStore.getState().setContextUsage(null);
|
||||
setPendingNewThreadId(null);
|
||||
setNewThreadNonce(crypto.randomUUID());
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeThreadId) {
|
||||
setPendingNewThreadId(null);
|
||||
return;
|
||||
}
|
||||
if (activeThreadId === initialActiveThreadRef.current) {
|
||||
return;
|
||||
}
|
||||
setPendingNewThreadId(activeThreadId);
|
||||
}, [activeThreadId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadPreviews(): Promise<void> {
|
||||
const entries = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
if (item.type !== "single") {
|
||||
return [
|
||||
item.id,
|
||||
{
|
||||
snippet: "Compare chat",
|
||||
date: formatProjectChatDate(item.createdAt),
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
const messages = await listStoredChatMessages(item.id).catch(() => []);
|
||||
const firstUserMessage =
|
||||
messages.find((message) => message.role === "user") ?? messages[0];
|
||||
return [
|
||||
item.id,
|
||||
{
|
||||
snippet: firstUserMessage
|
||||
? extractMessageText(firstUserMessage.content)
|
||||
: "",
|
||||
date: formatProjectChatDate(item.createdAt),
|
||||
},
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
setPreviews(Object.fromEntries(entries));
|
||||
}
|
||||
}
|
||||
|
||||
void loadPreviews();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<ChatRuntimeProvider
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
newThreadNonce={newThreadNonce}
|
||||
listThreads={false}
|
||||
>
|
||||
{pendingNewThreadId ? (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<Thread hideWelcome={true} targetThreadId={pendingNewThreadId} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex min-h-0 min-w-0 flex-1 basis-0 overflow-y-auto px-5"
|
||||
style={
|
||||
{
|
||||
["--thread-max-width" as string]: "48rem",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[48rem] flex-col pt-[120px] pb-14">
|
||||
<div className="mb-12 flex items-center gap-3">
|
||||
<HugeiconsIcon
|
||||
icon={Folder02Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-9 shrink-0 text-foreground"
|
||||
/>
|
||||
<h1 className="truncate font-sans text-[30px] font-medium leading-tight tracking-normal text-foreground">
|
||||
{projectName}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<ProjectComposer
|
||||
disabled={Boolean(pendingNewThreadId)}
|
||||
placeholder={`New chat in ${projectName}`}
|
||||
/>
|
||||
|
||||
<div className="mt-9 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjectTab("chats")}
|
||||
data-active={projectTab === "chats"}
|
||||
className="h-10 rounded-full border px-5 text-[14px] font-semibold transition-colors data-[active=true]:border-border data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:border-transparent data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover"
|
||||
>
|
||||
Chats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjectTab("sources")}
|
||||
data-active={projectTab === "sources"}
|
||||
className="h-10 rounded-full border px-5 text-[14px] font-semibold transition-colors data-[active=true]:border-border data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:border-transparent data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover"
|
||||
>
|
||||
Sources
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{projectTab === "sources" ? (
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-3 rounded-[16px] border border-dashed border-border/70 bg-muted/30 px-6 py-16 text-center">
|
||||
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<HugeiconsIcon
|
||||
icon={FolderAddIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-6"
|
||||
/>
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[15px] font-semibold text-foreground">
|
||||
Give this project context
|
||||
</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Upload PDFs, documents, or other text. The model can
|
||||
reference them in every chat in this project.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" className="mt-1" disabled>
|
||||
Add sources
|
||||
</Button>
|
||||
<p className="text-[11px] text-muted-foreground">Coming soon</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 flex flex-col gap-1">
|
||||
{items.map((item) => {
|
||||
const preview = previews[item.id];
|
||||
return (
|
||||
<button
|
||||
key={`${item.type}:${item.id}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search:
|
||||
item.type === "single"
|
||||
? { thread: item.id, project: projectId }
|
||||
: { compare: item.id, project: projectId },
|
||||
});
|
||||
}}
|
||||
className="group flex min-h-[58px] w-full items-center gap-4 rounded-[10px] px-4 py-2 text-left transition-colors hover:bg-nav-surface-hover"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[15px] font-semibold leading-5 text-foreground">
|
||||
{item.title}
|
||||
</div>
|
||||
{preview?.snippet ? (
|
||||
<div className="mt-0.5 truncate text-[14px] leading-5 text-muted-foreground">
|
||||
{preview.snippet}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 text-[14px] text-muted-foreground">
|
||||
{preview?.date ?? formatProjectChatDate(item.createdAt)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ChatRuntimeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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<ChatSearch | null>(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<ChatSearch | null>(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<string | null>(
|
||||
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<void> {
|
||||
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<ChatView>(() => {
|
||||
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 (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 bg-background" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 bg-background overflow-hidden">
|
||||
<GuidedTour {...tour.tourProps} />
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<NativeModelDropOverlay state={nativeModelDropState} />
|
||||
{/* 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" && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-0 right-[10px] top-[48px] z-20 h-6 bg-gradient-to-b from-background to-transparent"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-[10px] z-30 flex h-[48px] shrink-0 items-start pt-[11px] pr-2 bg-background",
|
||||
|
|
@ -1729,6 +2156,30 @@ export function ChatPage(): ReactElement {
|
|||
className="max-w-[62vw] !pr-3 sm:max-w-none !h-[34px]"
|
||||
/>
|
||||
)}
|
||||
{view.mode !== "compare" && currentProjectId && (
|
||||
<nav
|
||||
aria-label="Project location"
|
||||
className="flex h-[34px] min-w-0 items-center gap-1.5 self-center text-[13.5px] tracking-nav text-muted-foreground"
|
||||
>
|
||||
<ProjectSwitcher
|
||||
currentProject={currentProject}
|
||||
projects={projects}
|
||||
isLoading={projectsLoading}
|
||||
onSelectProject={openProjectLanding}
|
||||
onViewAllProjects={openProjectsList}
|
||||
/>
|
||||
{currentProject && activeThreadId ? (
|
||||
<>
|
||||
<span className="shrink-0" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{currentChatTitle ?? "New chat"}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
)}
|
||||
{pendingNativeModelIntent && view.mode !== "compare" ? (
|
||||
<NativeModelChip
|
||||
intent={pendingNativeModelIntent}
|
||||
|
|
@ -1786,12 +2237,12 @@ export function ChatPage(): ReactElement {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={CustomizeIcon}
|
||||
icon={LayoutAlignRightIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
|
|
@ -1802,18 +2253,26 @@ export function ChatPage(): ReactElement {
|
|||
sideOffset={6}
|
||||
className="tooltip-compact"
|
||||
>
|
||||
Open configuration
|
||||
Open run settings
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.mode === "single" ? (
|
||||
{view.mode === "project" ? (
|
||||
<ProjectLanding
|
||||
key={view.projectId}
|
||||
projectId={view.projectId}
|
||||
projectName={currentProject?.name ?? "Project"}
|
||||
items={currentProjectItems}
|
||||
/>
|
||||
) : view.mode === "single" ? (
|
||||
<SingleContent
|
||||
key={view.threadId ?? view.newThreadNonce ?? "single"}
|
||||
threadId={view.threadId}
|
||||
newThreadNonce={view.newThreadNonce}
|
||||
projectId={view.projectId}
|
||||
artifact={selectedArtifact}
|
||||
artifactSurface={artifactSurface}
|
||||
onCloseArtifact={closeArtifactSurface}
|
||||
|
|
@ -1822,11 +2281,14 @@ export function ChatPage(): ReactElement {
|
|||
<CompareContent
|
||||
key={view.pairId}
|
||||
pairId={view.pairId}
|
||||
projectId={view.projectId}
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
externalModels={externalModels}
|
||||
onFoldersChange={refreshLocalModels}
|
||||
onModelsChange={refreshModelLists}
|
||||
deleteDisabled={modelOperationInProgress}
|
||||
onExitCompare={exitCompare}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <a> inside the <button> (invalid HTML).
|
||||
*/
|
||||
labelHref?: string;
|
||||
children?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
first?: boolean;
|
||||
|
|
@ -338,6 +346,17 @@ function CollapsibleSection({
|
|||
return Object.hasOwn(saved, label) ? saved[label] : defaultOpen;
|
||||
});
|
||||
|
||||
const toggle = () => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
saveCollapsibleOpen(label, next);
|
||||
};
|
||||
|
||||
const headerClasses = cn(
|
||||
"flex w-full items-center justify-between text-[12px] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors focus-visible:outline-none focus-visible:ring-0",
|
||||
first ? "pt-4 pb-5" : "py-5",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -345,25 +364,42 @@ function CollapsibleSection({
|
|||
"border-t border-black/[0.13] dark:border-white/[0.09]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
saveCollapsibleOpen(label, next);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center justify-between text-[12px] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors hover:text-nav-fg focus-visible:outline-none focus-visible:ring-0",
|
||||
first ? "pt-4 pb-5" : "py-5",
|
||||
)}
|
||||
>
|
||||
<span className="leading-none">{label}</span>
|
||||
<span className="flex shrink-0 items-center leading-none">
|
||||
<ChevronDown
|
||||
className={cn("size-3.5", open ? "rotate-0" : "-rotate-90")}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{labelHref ? (
|
||||
<div className={headerClasses}>
|
||||
<a
|
||||
href={labelHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex cursor-pointer items-center gap-1 leading-none transition-colors hover:text-nav-fg"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
|
||||
className="flex shrink-0 cursor-pointer items-center leading-none transition-colors hover:text-nav-fg"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn("size-3.5", open ? "rotate-0" : "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={cn("cursor-pointer hover:text-nav-fg", headerClasses)}
|
||||
>
|
||||
<span className="leading-none">{label}</span>
|
||||
<span className="flex shrink-0 items-center leading-none">
|
||||
<ChevronDown
|
||||
className={cn("size-3.5", open ? "rotate-0" : "-rotate-90")}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{open && <div className="pb-7">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -683,26 +719,30 @@ export function ChatSettingsPanel({
|
|||
}
|
||||
}, [open]);
|
||||
|
||||
const settingsScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const settingsContent = (
|
||||
<>
|
||||
<div className="aui-thread-viewport relative h-full overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 flex h-[48px] items-start gap-2 bg-panel-surface pl-[18px] pr-[14px] pt-[11px]">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Header sits outside the scroll area so the scrollbar never shifts the
|
||||
close button. */}
|
||||
<div className="flex h-[48px] shrink-0 items-start gap-2 bg-panel-surface pl-[18px] pr-[16px] pt-[11px]">
|
||||
{isMobile ? (
|
||||
<span className="flex h-[34px] flex-1 items-center text-[15px] font-semibold tracking-[-0.01em] dark:tracking-[0.015em] text-nav-fg">
|
||||
Configuration
|
||||
<span className="flex h-[34px] flex-1 items-center text-[16px] font-semibold tracking-[0em] dark:tracking-[0.015em] text-nav-fg">
|
||||
Run settings
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex h-[34px] flex-1 items-center text-[15px] font-semibold tracking-[-0.01em] dark:tracking-[0.015em] text-nav-fg">
|
||||
Configuration
|
||||
<span className="flex h-[34px] flex-1 items-center text-[16px] font-semibold tracking-[0em] dark:tracking-[0.015em] text-nav-fg">
|
||||
Run settings
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipPrimitive.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
className="flex h-[34px] w-[34px] items-center justify-center rounded-[12px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close configuration"
|
||||
className="flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[12px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close run settings"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={LayoutAlignRightIcon}
|
||||
|
|
@ -716,13 +756,17 @@ export function ChatSettingsPanel({
|
|||
sideOffset={6}
|
||||
className="tooltip-compact"
|
||||
>
|
||||
Close configuration
|
||||
Close run settings
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={settingsScrollRef}
|
||||
className="run-settings-scroll relative min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="px-[18px] pt-3">
|
||||
{hasModelContent && (
|
||||
<CollapsibleSection label="Model" defaultOpen={true} first>
|
||||
|
|
@ -1332,6 +1376,7 @@ export function ChatSettingsPanel({
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
open={systemPromptEditorOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
|
|
@ -1395,7 +1440,7 @@ export function ChatSettingsPanel({
|
|||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-[18rem] p-0 font-heading">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Configuration</SheetTitle>
|
||||
<SheetTitle>Run settings</SheetTitle>
|
||||
<SheetDescription>Chat inference settings</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full flex-col">{settingsContent}</div>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<CommandDialog
|
||||
open={isOpen}
|
||||
onOpenChange={setOpen}
|
||||
className="shadow-border corner-squircle w-[635px] max-w-[calc(100%-2rem)] gap-0 p-0 sm:max-w-[635px]"
|
||||
className="chat-search-surface corner-squircle top-[25%] w-[635px] max-w-[calc(100%-2rem)] gap-0 p-0 sm:max-w-[635px]"
|
||||
overlayClassName="bg-transparent"
|
||||
>
|
||||
<Command className="rounded-none p-0">
|
||||
<Command className="rounded-4xl p-0" filter={chatSearchFilter}>
|
||||
<div className="flex items-center gap-3 border-b border-border/40 px-4 py-3">
|
||||
<HugeiconsIcon
|
||||
icon={SearchIcon}
|
||||
|
|
@ -74,7 +87,7 @@ export function ChatSearchDialog() {
|
|||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<CommandList className="max-h-[420px] p-1">
|
||||
<CommandList className="cmd-native-scrollbar hover-scrollbar max-h-[420px] p-1">
|
||||
<CommandEmpty className="py-6 text-center text-xs text-muted-foreground">
|
||||
{loading
|
||||
? "Loading…"
|
||||
|
|
@ -86,18 +99,25 @@ export function ChatSearchDialog() {
|
|||
{items.map((item) => (
|
||||
<CommandPrimitive.Item
|
||||
key={item.id}
|
||||
value={`${item.title} ${item.preview}`}
|
||||
value={item.id}
|
||||
keywords={[item.title, item.preview]}
|
||||
onSelect={() => {
|
||||
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"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Message01Icon}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ type ModelLoadDescriptionProps = {
|
|||
message?: string | null;
|
||||
progressPercent?: number | null;
|
||||
progressLabel?: string | null;
|
||||
onStop?: () => 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({
|
|||
<div className="flex h-full shrink-0 items-center self-center">
|
||||
<Spinner className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pr-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
{title ? <p className="text-foreground leading-5 font-semibold">{title}</p> : null}
|
||||
{hasProgress ? (
|
||||
<div className="w-full pt-1">
|
||||
|
|
@ -82,18 +80,6 @@ export function ModelLoadDescription({
|
|||
<p className="pt-1 text-xs leading-relaxed text-muted-foreground">{message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{onStop ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="Stop model loading"
|
||||
className="h-auto self-stretch shrink-0 !rounded-none !border-0 bg-transparent px-1 text-[10px] text-muted-foreground hover:bg-transparent hover:text-destructive focus-visible:text-destructive"
|
||||
onClick={onStop}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setName("");
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commitCreate()}
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
Folder01Icon,
|
||||
Tick02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { ReactElement } from "react";
|
||||
import type { ProjectRecord } from "../types";
|
||||
|
||||
export function ProjectSwitcher({
|
||||
currentProject,
|
||||
projects,
|
||||
isLoading,
|
||||
onSelectProject,
|
||||
onViewAllProjects,
|
||||
}: {
|
||||
currentProject: ProjectRecord | null;
|
||||
projects: ProjectRecord[];
|
||||
isLoading: boolean;
|
||||
onSelectProject: (projectId: string) => void;
|
||||
onViewAllProjects: () => void;
|
||||
}): ReactElement {
|
||||
const showLoadingRow = isLoading && projects.length === 0;
|
||||
const showEmptyRow = !isLoading && projects.length === 0;
|
||||
const label = currentProject?.name ?? (isLoading ? "Project" : "Projects");
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
currentProject
|
||||
? `Project: ${currentProject.name}. Switch project`
|
||||
: isLoading
|
||||
? "Loading project"
|
||||
: "Pick a project"
|
||||
}
|
||||
className="-mx-1 flex h-[34px] shrink-0 items-center gap-2 rounded-[10px] px-1.5 transition-colors hover:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-[#2d2e32]"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Folder01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon shrink-0 text-foreground/70"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 items-baseline">
|
||||
<span className="min-w-0 flex max-w-[150px] flex-1 items-baseline truncate font-heading text-[16px] font-medium leading-tight text-black dark:text-white">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center">
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDown01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="relative top-0.5 size-3.5 text-muted-foreground"
|
||||
aria-hidden={true}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="app-user-menu menu-soft-surface ring-0 min-w-56 max-w-72 max-h-72 py-2 font-heading rounded-[14px] border-0"
|
||||
>
|
||||
{showLoadingRow ? (
|
||||
<DropdownMenuItem disabled={true} className="text-muted-foreground">
|
||||
Loading…
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{showEmptyRow ? (
|
||||
<DropdownMenuItem disabled={true} className="text-muted-foreground">
|
||||
No projects yet
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{projects.map((project) => {
|
||||
const isActive = currentProject?.id === project.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onSelect={() => onSelectProject(project.id)}
|
||||
className="justify-between"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={Folder01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon shrink-0 text-foreground/70"
|
||||
/>
|
||||
<span className="truncate">{project.name}</span>
|
||||
</span>
|
||||
{isActive ? (
|
||||
<HugeiconsIcon
|
||||
icon={Tick02Icon}
|
||||
strokeWidth={2}
|
||||
className="size-icon shrink-0 text-foreground/80"
|
||||
/>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onViewAllProjects}>
|
||||
View all projects
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof renderLoadDescription>) => ({
|
||||
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({
|
||||
|
|
|
|||
99
studio/frontend/src/features/chat/hooks/use-chat-projects.ts
Normal file
99
studio/frontend/src/features/chat/hooks/use-chat-projects.ts
Normal file
|
|
@ -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<ProjectRecord[]>(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<ProjectRecord> {
|
||||
return createStoredChatProject(name);
|
||||
}
|
||||
|
||||
export async function renameChatProject(
|
||||
projectId: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error("Project name is required.");
|
||||
await updateStoredChatProject(projectId, { name: trimmed });
|
||||
}
|
||||
|
||||
export async function updateChatProjectInstructions(
|
||||
projectId: string,
|
||||
instructions: string,
|
||||
): Promise<void> {
|
||||
await updateStoredChatProject(projectId, { instructions: instructions.trim() });
|
||||
}
|
||||
|
||||
export async function deleteChatProject(
|
||||
projectId: string,
|
||||
args: { deleteFiles?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await deleteStoredChatProject(projectId, args);
|
||||
}
|
||||
|
||||
export async function moveChatItemToProject(
|
||||
item: SidebarItem,
|
||||
projectId: string | null,
|
||||
): Promise<void> {
|
||||
await moveStoredChatItemToProject(item, projectId);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
|
||||
import { batchListChatMessages, CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
|
||||
import type { MessageRecord } from "../types";
|
||||
import {
|
||||
listStoredChatMessages,
|
||||
|
|
@ -15,6 +15,7 @@ export interface ChatSearchItem {
|
|||
title: string;
|
||||
preview: string;
|
||||
createdAt: number;
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
const THREAD_LIMIT = 200;
|
||||
|
|
@ -68,6 +69,7 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
|
|||
id: t.pairId,
|
||||
title: t.title,
|
||||
createdAt: t.createdAt,
|
||||
projectId: t.projectId ?? null,
|
||||
},
|
||||
threadIds: [t.id],
|
||||
});
|
||||
|
|
@ -78,6 +80,7 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
|
|||
id: t.id,
|
||||
title: t.title,
|
||||
createdAt: t.createdAt,
|
||||
projectId: t.projectId ?? null,
|
||||
},
|
||||
threadIds: [t.id],
|
||||
});
|
||||
|
|
@ -87,26 +90,34 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
|
|||
const allThreadIds = Array.from(itemThreadIds.values()).flatMap(
|
||||
(e) => e.threadIds,
|
||||
);
|
||||
const storedMessagesByThread = await Promise.all(
|
||||
allThreadIds.map(async (threadId) => ({
|
||||
threadId,
|
||||
messages: await listStoredChatMessages(threadId),
|
||||
})),
|
||||
let messagesByThread = await batchListChatMessages(allThreadIds).catch(
|
||||
() => new Map<string, MessageRecord[]>(),
|
||||
);
|
||||
const messages = storedMessagesByThread.flatMap((entry) => entry.messages);
|
||||
|
||||
const byThreadId = new Map<string, MessageRecord[]>();
|
||||
for (const m of messages) {
|
||||
const arr = byThreadId.get(m.threadId);
|
||||
if (arr) arr.push(m);
|
||||
else byThreadId.set(m.threadId, [m]);
|
||||
// Legacy-only chats can exist before server-side history import finishes.
|
||||
// Fill just the missing ids from the legacy-aware path instead of issuing
|
||||
// one request per thread up front.
|
||||
const missingThreadIds = allThreadIds.filter(
|
||||
(threadId) => !messagesByThread.has(threadId),
|
||||
);
|
||||
if (missingThreadIds.length > 0) {
|
||||
const legacyEntries = await Promise.all(
|
||||
missingThreadIds.map(async (threadId) => [
|
||||
threadId,
|
||||
await listStoredChatMessages(threadId).catch(() => []),
|
||||
] as const),
|
||||
);
|
||||
messagesByThread = new Map(messagesByThread);
|
||||
for (const [threadId, messages] of legacyEntries) {
|
||||
messagesByThread.set(threadId, messages);
|
||||
}
|
||||
}
|
||||
|
||||
const results: ChatSearchItem[] = [];
|
||||
for (const { item, threadIds } of itemThreadIds.values()) {
|
||||
const merged: MessageRecord[] = [];
|
||||
for (const tid of threadIds) {
|
||||
const arr = byThreadId.get(tid);
|
||||
const arr = messagesByThread.get(tid);
|
||||
if (arr) merged.push(...arr);
|
||||
}
|
||||
if (merged.length === 0) {
|
||||
|
|
@ -141,6 +152,7 @@ export function useChatSearchIndex(enabled: boolean): {
|
|||
if (!enabled) {
|
||||
// Clear stale results so the next open doesn't flash old items.
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
|
|
|||
|
|
@ -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<ThreadRecord[]>([]);
|
||||
const enabled = options?.enabled ?? true;
|
||||
const requireMessages = options?.requireMessages ?? true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let requestSeq = 0;
|
||||
|
||||
async function doLoad(seq: number) {
|
||||
try {
|
||||
const threads = await listStoredChatThreadsWithMessages({
|
||||
const listThreads = requireMessages
|
||||
? listStoredChatThreadsWithMessages
|
||||
: listStoredChatThreads;
|
||||
const threads = await listThreads({
|
||||
includeArchived: false,
|
||||
projectId: options?.projectId,
|
||||
});
|
||||
// Discard the response if a newer request was scheduled while we
|
||||
// were in flight, or if the effect was torn down.
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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, boolean>): string[] {
|
||||
const [order, setOrder] = useState<string[]>(() =>
|
||||
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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<path d="M5.99977 9.00005L11.9998 15L17.9998 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
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<string | null>(null);
|
||||
const [hintKey, setHintKey] = useState<string | null>(null);
|
||||
|
||||
// mcp_enabled only applies on the local tool-capable send path; grey out otherwise.
|
||||
const usable = modelLoaded && supportsTools;
|
||||
|
||||
// Keep the per-chat flag in step with whether any server is enabled. Reads the
|
||||
// store directly so the callback stays stable (no refetch loop on mount).
|
||||
const reconcileFlag = useCallback(
|
||||
(rows: McpServerConfig[]) => {
|
||||
const anyEnabled = rows.some((s) => s.is_enabled);
|
||||
const current = useChatRuntimeStore.getState().mcpEnabledForChat;
|
||||
if (anyEnabled && !current) setMcpEnabledForChat(true);
|
||||
else if (!anyEnabled && current) setMcpEnabledForChat(false);
|
||||
},
|
||||
[setMcpEnabledForChat],
|
||||
);
|
||||
// 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"
|
||||
}
|
||||
>
|
||||
<span className="truncate">{opts.label}</span>
|
||||
{opts.enabled ? (
|
||||
<span className="flex size-4 shrink-0 items-center justify-center text-emerald-600 dark:text-emerald-400">
|
||||
<HugeiconsIcon
|
||||
icon={Tick02Icon}
|
||||
className="size-4 group-data-[highlighted]/mcp:hidden"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
className="hidden size-4 text-foreground group-data-[highlighted]/mcp:block"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
{opts.enabled ? <CheckIcon className="ml-auto" /> : null}
|
||||
{opts.hint ? (
|
||||
<Tooltip open={hintKey === opts.key}>
|
||||
<TooltipTrigger asChild={true}>
|
||||
|
|
@ -260,28 +250,21 @@ export function McpComposerButton() {
|
|||
>
|
||||
<HugeiconsIcon
|
||||
icon={McpServerIcon}
|
||||
className="size-3.5"
|
||||
className="size-[15px]"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<span>MCP</span>
|
||||
<ArrowDownStandardIcon className="composer-pill-caret size-[15px]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<div className="flex items-center justify-between pr-1">
|
||||
<DropdownMenuLabel>MCP Servers</DropdownMenuLabel>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
className="size-3.5"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<DropdownMenuContent
|
||||
side={side}
|
||||
align="start"
|
||||
sideOffset={2}
|
||||
avoidCollisions={true}
|
||||
className="unsloth-plus-menu mcp-menu w-[232px]"
|
||||
>
|
||||
<DropdownMenuLabel>MCP Servers</DropdownMenuLabel>
|
||||
{MCP_PRESETS.map((preset) => {
|
||||
const norm = normalizeMcpUrl(preset.url);
|
||||
return renderRow({
|
||||
|
|
@ -330,7 +313,7 @@ export function McpComposerButton() {
|
|||
>
|
||||
<HugeiconsIcon
|
||||
icon={McpServerIcon}
|
||||
className="size-3.5"
|
||||
className="size-[15px]"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<span>MCP</span>
|
||||
|
|
|
|||
401
studio/frontend/src/features/chat/projects-page.tsx
Normal file
401
studio/frontend/src/features/chat/projects-page.tsx
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
createChatProject,
|
||||
deleteChatProject,
|
||||
renameChatProject,
|
||||
useChatProjects,
|
||||
useChatRuntimeStore,
|
||||
type ProjectRecord,
|
||||
} from "@/features/chat";
|
||||
import {
|
||||
Delete02Icon,
|
||||
Edit03Icon,
|
||||
FolderAddIcon,
|
||||
Search01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { MoreHorizontalIcon } from "lucide-react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type SortMode = "activity" | "name";
|
||||
|
||||
function formatUpdatedAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
if (!Number.isFinite(diff) || diff < 0) return "just now";
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return "just now";
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`;
|
||||
const mo = Math.floor(d / 30);
|
||||
if (mo < 12) return `${mo} month${mo === 1 ? "" : "s"} ago`;
|
||||
const y = Math.floor(mo / 12);
|
||||
return `${y} year${y === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
export function ProjectsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projects, hasLoaded } = useChatProjects();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("activity");
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [renaming, setRenaming] = useState<ProjectRecord | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const [deleting, setDeleting] = useState<ProjectRecord | null>(null);
|
||||
|
||||
const visibleProjects = useMemo(() => {
|
||||
const trimmed = query.trim().toLowerCase();
|
||||
const filtered = trimmed
|
||||
? projects.filter((p) => p.name.toLowerCase().includes(trimmed))
|
||||
: projects.slice();
|
||||
filtered.sort((a, b) =>
|
||||
sortMode === "name"
|
||||
? a.name.localeCompare(b.name)
|
||||
: b.updatedAt - a.updatedAt,
|
||||
);
|
||||
return filtered;
|
||||
}, [projects, query, sortMode]);
|
||||
|
||||
function openProject(projectId: string) {
|
||||
const runtime = useChatRuntimeStore.getState();
|
||||
runtime.setActiveThreadId(null);
|
||||
runtime.setActiveProjectId(projectId);
|
||||
navigate({ to: "/chat", search: { project: projectId } });
|
||||
}
|
||||
|
||||
async function commitCreate() {
|
||||
const name = nameDraft.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const project = await createChatProject(name);
|
||||
setCreating(false);
|
||||
setNameDraft("");
|
||||
openProject(project.id);
|
||||
} catch (err) {
|
||||
toast.error("Failed to create project", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function commitRename() {
|
||||
const target = renaming;
|
||||
const name = renameDraft.trim();
|
||||
if (!target || !name || name === target.name) {
|
||||
setRenaming(null);
|
||||
return;
|
||||
}
|
||||
setRenaming(null);
|
||||
try {
|
||||
await renameChatProject(target.id, name);
|
||||
} catch (err) {
|
||||
toast.error("Failed to rename project", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function commitDelete() {
|
||||
const target = deleting;
|
||||
if (!target) return;
|
||||
setDeleting(null);
|
||||
try {
|
||||
await deleteChatProject(target.id);
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete project", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-8 font-heading sm:px-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
Projects
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Sort by</span>
|
||||
<Select
|
||||
value={sortMode}
|
||||
onValueChange={(v) => setSortMode(v as SortMode)}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="activity">Activity</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNameDraft("");
|
||||
setCreating(true);
|
||||
}}
|
||||
>
|
||||
New project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-6">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
<HugeiconsIcon icon={Search01Icon} strokeWidth={1.75} className="size-icon" />
|
||||
</span>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search projects..."
|
||||
className="h-11 pl-10"
|
||||
aria-label="Search projects"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hasLoaded ? (
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="min-h-[160px] rounded-[14px] border border-border/70 bg-card p-5"
|
||||
>
|
||||
<Skeleton className="h-5 w-2/3 rounded-[6px]" />
|
||||
<Skeleton className="mt-3 h-4 w-full rounded-[6px]" />
|
||||
<Skeleton className="mt-2 h-4 w-4/5 rounded-[6px]" />
|
||||
<Skeleton className="mt-12 h-3 w-24 rounded-[6px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : visibleProjects.length === 0 ? (
|
||||
<div className="mt-16 flex flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
||||
<p className="text-sm">
|
||||
{projects.length === 0
|
||||
? "No projects yet."
|
||||
: "No projects match your search."}
|
||||
</p>
|
||||
{projects.length === 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
setNameDraft("");
|
||||
setCreating(true);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={FolderAddIcon} strokeWidth={1.75} className="size-icon" />
|
||||
Create your first project
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => openProject(project.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
openProject(project.id);
|
||||
}
|
||||
}}
|
||||
className="group/project-card relative flex min-h-[160px] cursor-pointer flex-col rounded-[14px] border border-border/70 bg-card p-5 text-left transition-colors hover:border-border hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="truncate pr-2 text-[16px] font-semibold text-foreground">
|
||||
{project.name}
|
||||
</h2>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Project options"
|
||||
className="-mr-1 -mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-[8px] text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover/project-card:opacity-100"
|
||||
>
|
||||
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setRenameDraft(project.name);
|
||||
setRenaming(project);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setDeleting(project)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{project.instructions ? (
|
||||
<p className="mt-2 line-clamp-3 text-sm text-muted-foreground">
|
||||
{project.instructions}
|
||||
</p>
|
||||
) : null}
|
||||
<span className="mt-auto pt-4 text-xs text-muted-foreground">
|
||||
Updated {formatUpdatedAgo(project.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create project */}
|
||||
<Dialog
|
||||
open={creating}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCreating(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={nameDraft}
|
||||
onChange={(e) => setNameDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void commitCreate();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
placeholder="Project name"
|
||||
aria-label="Project name"
|
||||
className="focus-visible:border-input focus-visible:ring-0"
|
||||
/>
|
||||
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
||||
<Button type="button" variant="ghost" onClick={() => setCreating(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void commitCreate()} disabled={!nameDraft.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rename project */}
|
||||
<Dialog
|
||||
open={renaming !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRenaming(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void commitRename();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
placeholder="Project name"
|
||||
aria-label="Project name"
|
||||
className="focus-visible:border-input focus-visible:ring-0"
|
||||
/>
|
||||
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
||||
<Button type="button" variant="ghost" onClick={() => setRenaming(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commitRename()}
|
||||
disabled={!renameDraft.trim() || renameDraft.trim() === renaming?.name}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete project */}
|
||||
<Dialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleting(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="menu-flat-destructive corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to delete <em>{deleting?.name}</em>? Chats in this
|
||||
project will be moved back to Recents.
|
||||
</p>
|
||||
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
||||
<Button type="button" variant="ghost" onClick={() => setDeleting(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={() => void commitDelete()}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, Promise<void>>();
|
||||
const pendingRunStartReadyByMessageId = new Map<string, Promise<void>>();
|
||||
|
||||
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<PendingAttachment> {
|
||||
return {
|
||||
|
|
@ -534,10 +549,12 @@ export async function ensureThreadRecord({
|
|||
threadId,
|
||||
modelType,
|
||||
pairId,
|
||||
projectId,
|
||||
}: {
|
||||
threadId: string;
|
||||
modelType: ModelType;
|
||||
pairId?: string;
|
||||
projectId?: string | null;
|
||||
}): Promise<void> {
|
||||
if (isChatThreadDeleted(threadId)) {
|
||||
return;
|
||||
|
|
@ -556,6 +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<void>,
|
||||
): Promise<void> {
|
||||
pendingRunStartReadyByMessageId.set(messageId, ready);
|
||||
const cleanup = () => {
|
||||
setTimeout(() => {
|
||||
if (pendingRunStartReadyByMessageId.get(messageId) === ready) {
|
||||
pendingRunStartReadyByMessageId.delete(messageId);
|
||||
}
|
||||
}, 30_000);
|
||||
};
|
||||
ready.then(cleanup, cleanup);
|
||||
return ready;
|
||||
}
|
||||
|
||||
async function waitForRunStartHistoryAppend(
|
||||
messages: Parameters<ChatModelAdapter["run"]>[0]["messages"],
|
||||
): Promise<void> {
|
||||
|
|
@ -747,20 +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<ThreadHistoryAdapter>(
|
||||
|
|
@ -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<typeof useLocalRuntime> {
|
||||
const adapters = useStudioRuntimeAdapters();
|
||||
function useRuntimeHook(
|
||||
modelType: ModelType,
|
||||
pairId?: string,
|
||||
): ReturnType<typeof useLocalRuntime> {
|
||||
const adapters = useStudioRuntimeAdapters(modelType, pairId);
|
||||
const persistedChatAdapter = useMemo(
|
||||
() => createPersistedRunAdapter(chatAdapter),
|
||||
[],
|
||||
|
|
@ -956,6 +1015,12 @@ function useRuntimeHook(): ReturnType<typeof useLocalRuntime> {
|
|||
return useLocalRuntime(persistedChatAdapter, { adapters });
|
||||
}
|
||||
|
||||
function createRuntimeHook(modelType: ModelType, pairId?: string) {
|
||||
return function useConfiguredRuntimeHook(): ReturnType<typeof useLocalRuntime> {
|
||||
return useRuntimeHook(modelType, pairId);
|
||||
};
|
||||
}
|
||||
|
||||
function ThreadAutoSwitch({
|
||||
threadId,
|
||||
syncActiveThreadId = true,
|
||||
|
|
@ -1133,20 +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({});
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<path d="M5.99977 9.00005L11.9998 15L17.9998 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MicIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<path d="M128,176a48.05,48.05,0,0,0,48-48V64a48,48,0,0,0-96,0v64A48.05,48.05,0,0,0,128,176ZM96,64a32,32,0,0,1,64,0v64a32,32,0,0,1-64,0Zm40,143.6V232a8,8,0,0,1-16,0V207.6A80.11,80.11,0,0,1,48,128a8,8,0,0,1,16,0,64,64,0,0,0,128,0,8,8,0,0,1,16,0A80.11,80.11,0,0,1,136,207.6Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BulbIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="-10.24 -10.24 1044.48 1044.48"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth={16.384}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<path d="M511.984 0c-198.032 0-353.12 161.104-353.12 359.136 0 149.2 73.28 220.256 131.185 272.128 37.28 33.424 62.368 53.552 62.368 78.352v54.255c0 1.392.193 2.752.368 4.128h-.72v92.624c.016 97.712 63.2 163.376 161.072 163.376 94.464 0 158.944-65.664 158.944-163.376V768h-.928c.176-1.376.416-2.736.416-4.128v-54.255c0-37.76 28.032-60.592 70.528-97.696 57.504-50.208 123.023-112.688 123.023-252.784C865.136 161.104 710.016 0 511.983 0zm-1.215 960c-59.904 0-94.689-37.152-94.689-99.376l-.463-42.672C438.64 825.824 470 832 512 832c41.424 0 72.848-6.624 96.08-14.768v43.392c0 63.152-35.247 99.376-97.312 99.376zm189.248-396.288c-43.472 37.968-92.433 77.216-92.433 145.904v40.432c-15.183 8.48-43.183 18.56-96.127 18.56-55.569 0-81.92-9.856-95.024-17.473V709.6c0-54.608-42.688-89.297-83.68-126.017-54.32-48.672-109.873-103.84-109.873-224.464-.015-162.72 126.385-295.12 289.104-295.12 162.752 0 289.152 132.4 289.152 295.137 0 111.024-48.463 158.576-101.12 204.576z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
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 (
|
||||
<span className="composer-pill-glyph">
|
||||
{children}
|
||||
<XIcon className="composer-pill-x" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLTextAreaElement>(null);
|
||||
const composingRef = useRef(false);
|
||||
const stuckImeTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div
|
||||
className={`chat-composer-surface ${dragging ? "border-ring bg-accent/50" : ""}`}
|
||||
className="chat-composer-surface"
|
||||
onDragOver={(e) => {
|
||||
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. */}
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 overflow-hidden rounded-[32px] bg-background/90 backdrop-blur-sm transition-opacity duration-150 dark:bg-card/90 ${dragging ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={AttachmentIcon}
|
||||
strokeWidth={2}
|
||||
className="size-6 text-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium text-primary">Drop files here</span>
|
||||
</div>
|
||||
{(pendingImages.length > 0 || pendingAudio) && (
|
||||
<div className="mb-2 flex w-full flex-row flex-wrap items-center gap-2 px-1.5 pt-0.5 pb-1">
|
||||
{pendingImages.map(({ id, file }) => (
|
||||
|
|
@ -980,7 +1098,10 @@ export function SharedComposer({
|
|||
dir="auto"
|
||||
/>
|
||||
<div className="composer-action-wrapper">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div
|
||||
className="flex items-center gap-0.5"
|
||||
data-pill-compact={pillsCompact ? "true" : undefined}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
|
@ -992,131 +1113,440 @@ export function SharedComposer({
|
|||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<TooltipIconButton
|
||||
tooltip="Add Attachment"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8.5 rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||
onClick={() => {
|
||||
// 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();
|
||||
<input
|
||||
ref={audioInputRef}
|
||||
type="file"
|
||||
accept={AUDIO_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
aria-label="Add Attachment"
|
||||
>
|
||||
<PlusIcon className="size-5 stroke-[1.5px]" />
|
||||
</TooltipIconButton>
|
||||
{activeModel?.hasAudioInput && (
|
||||
<>
|
||||
<input
|
||||
ref={audioInputRef}
|
||||
type="file"
|
||||
accept={AUDIO_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<TooltipIconButton
|
||||
tooltip="Upload audio"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8.5 rounded-full p-1 text-muted-foreground hover:bg-muted-foreground/15"
|
||||
onClick={() => audioInputRef.current?.click()}
|
||||
aria-label="Upload audio"
|
||||
/>
|
||||
<NewProjectDialog
|
||||
open={newProjectOpen}
|
||||
onOpenChange={setNewProjectOpen}
|
||||
/>
|
||||
{/* Same + side menu as the single-chat composer (ComposerToolsMenu),
|
||||
wired to the compare composer's own file/audio inputs and tools. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Tools and attachments"
|
||||
className="unsloth-composer-plus"
|
||||
>
|
||||
<HeadphonesIcon className="size-4.5 stroke-[1.5px]" />
|
||||
</TooltipIconButton>
|
||||
</>
|
||||
<PlusIcon className="size-[22px] stroke-[1.75px]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={2}
|
||||
avoidCollisions={true}
|
||||
className="unsloth-plus-menu w-[212px]"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}>
|
||||
<HugeiconsIcon icon={AttachmentIcon} strokeWidth={2} />
|
||||
Add photos & files
|
||||
</DropdownMenuItem>
|
||||
{activeModel?.hasAudioInput && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => audioInputRef.current?.click()}
|
||||
>
|
||||
<HeadphonesIcon />
|
||||
Upload audio
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
disabled={searchDisabled}
|
||||
className={
|
||||
toolsEnabled && !searchDisabled
|
||||
? "text-primary font-medium"
|
||||
: undefined
|
||||
}
|
||||
onSelect={() => {
|
||||
const next = !toolsEnabled;
|
||||
setToolsEnabled(next);
|
||||
// Mirror the Search pill: Kimi forbids search + thinking together.
|
||||
if (isKimiExternal) {
|
||||
setReasoningEnabled(!next, { persist: false });
|
||||
applyQwenThinkingParams(!next);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GlobeIcon />
|
||||
Web search
|
||||
{toolsEnabled && !searchDisabled ? (
|
||||
<CheckIcon className="ml-auto" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={codeDisabled}
|
||||
className={
|
||||
codeToolsEnabled && !codeDisabled
|
||||
? "text-primary font-medium"
|
||||
: undefined
|
||||
}
|
||||
onSelect={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={CodeIcon}
|
||||
strokeWidth={2}
|
||||
className="size-[1.175rem]!"
|
||||
/>
|
||||
Code
|
||||
{codeToolsEnabled && !codeDisabled ? (
|
||||
<CheckIcon className="ml-auto" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
{showImagePill && (
|
||||
<DropdownMenuItem
|
||||
disabled={imageDisabled}
|
||||
className={
|
||||
imageToolsEnabled && !imageDisabled
|
||||
? "text-primary font-medium"
|
||||
: undefined
|
||||
}
|
||||
onSelect={() => setImageToolsEnabled(!imageToolsEnabled)}
|
||||
>
|
||||
<HugeiconsIcon icon={Image03Icon} strokeWidth={2} />
|
||||
Images
|
||||
{imageToolsEnabled && !imageDisabled ? (
|
||||
<CheckIcon className="ml-auto" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className={
|
||||
artifactsEnabled ? "text-primary font-medium" : undefined
|
||||
}
|
||||
onSelect={() => setArtifactsEnabled(!artifactsEnabled)}
|
||||
>
|
||||
<HugeiconsIcon icon={PencilRulerIcon} strokeWidth={2} />
|
||||
Canvas
|
||||
{artifactsEnabled ? <CheckIcon className="ml-auto" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!supportsTools}
|
||||
className={
|
||||
mcpEnabledForChat ? "text-primary font-medium" : undefined
|
||||
}
|
||||
onSelect={() => setMcpEnabledForChat(!mcpEnabledForChat)}
|
||||
>
|
||||
<HugeiconsIcon icon={McpServerIcon} strokeWidth={2} />
|
||||
MCP
|
||||
{mcpEnabledForChat ? <CheckIcon className="ml-auto" /> : null}
|
||||
</DropdownMenuItem>
|
||||
{/* RAG hidden temporarily */}
|
||||
{/* Always active: this menu only renders in compare mode.
|
||||
Ticked like Web search/Code; click toggles it off. */}
|
||||
<DropdownMenuItem
|
||||
className="text-primary font-medium"
|
||||
onSelect={handleExitCompare}
|
||||
>
|
||||
<Columns2Icon />
|
||||
Compare chat
|
||||
<CheckIcon className="ml-auto" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<HugeiconsIcon icon={Folder01Icon} strokeWidth={2} />
|
||||
Projects
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="unsloth-plus-menu w-[200px]">
|
||||
<DropdownMenuItem onSelect={() => setNewProjectOpen(true)}>
|
||||
<HugeiconsIcon icon={FolderAddIcon} strokeWidth={2} />
|
||||
New project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuLabel>Recents</DropdownMenuLabel>
|
||||
{recentProjects.length > 0 ? (
|
||||
recentProjects.map((project) => (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onSelect={() => openProject(project.id)}
|
||||
>
|
||||
<HugeiconsIcon icon={Folder01Icon} strokeWidth={2} />
|
||||
<span className="truncate">{project.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled={true}>
|
||||
No recent projects
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Active in compare mode; sits first. Click to exit back to single chat. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExitCompare}
|
||||
className="composer-pill-btn"
|
||||
data-active="true"
|
||||
data-keep-label="true"
|
||||
aria-label="Exit compare chat"
|
||||
>
|
||||
<PillGlyph>
|
||||
<Columns2Icon className="size-[14px]" />
|
||||
</PillGlyph>
|
||||
<span>Compare</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={searchDisabled}
|
||||
onClick={() => {
|
||||
const next = !toolsEnabled;
|
||||
setToolsEnabled(next);
|
||||
// Kimi's $web_search builtin requires thinking=disabled
|
||||
// (https://platform.kimi.ai/docs/guide/use-web-search). Toggle
|
||||
// the Think pill off when Search is on, mirroring the backend.
|
||||
if (isKimiExternal) {
|
||||
setReasoningEnabled(!next, { persist: false });
|
||||
applyQwenThinkingParams(!next);
|
||||
}
|
||||
}}
|
||||
className="composer-pill-btn"
|
||||
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
|
||||
aria-label={
|
||||
toolsEnabled ? "Disable web search" : "Enable web search"
|
||||
}
|
||||
>
|
||||
<PillGlyph>
|
||||
<GlobeIcon className="size-[15px]" />
|
||||
</PillGlyph>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={codeDisabled}
|
||||
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
|
||||
aria-label={
|
||||
codeToolsEnabled
|
||||
? "Disable code execution"
|
||||
: "Enable code execution"
|
||||
}
|
||||
>
|
||||
<PillGlyph>
|
||||
<HugeiconsIcon
|
||||
icon={CodeIcon}
|
||||
className="size-[18.5px]"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</PillGlyph>
|
||||
<span>Code</span>
|
||||
</button>
|
||||
{showImagePill && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={imageDisabled}
|
||||
onClick={() => setImageToolsEnabled(!imageToolsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={
|
||||
imageToolsEnabled && !imageDisabled ? "true" : "false"
|
||||
}
|
||||
aria-label={
|
||||
imageToolsEnabled
|
||||
? "Disable image generation"
|
||||
: "Enable image generation"
|
||||
}
|
||||
>
|
||||
<PillGlyph>
|
||||
<HugeiconsIcon
|
||||
icon={Image03Icon}
|
||||
className="size-3.5"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</PillGlyph>
|
||||
<span>Images</span>
|
||||
</button>
|
||||
)}
|
||||
{showWebFetchPill && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={webFetchDisabled}
|
||||
onClick={() => setWebFetchToolsEnabled(!webFetchToolsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={
|
||||
webFetchToolsEnabled && !webFetchDisabled ? "true" : "false"
|
||||
}
|
||||
aria-label={
|
||||
webFetchToolsEnabled ? "Disable URL fetch" : "Enable URL fetch"
|
||||
}
|
||||
>
|
||||
<PillGlyph>
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-3.5" />
|
||||
</PillGlyph>
|
||||
<span>Fetch</span>
|
||||
</button>
|
||||
)}
|
||||
{artifactsEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setArtifactsEnabled(false)}
|
||||
className="composer-pill-btn"
|
||||
data-active="true"
|
||||
aria-label="Disable canvas"
|
||||
>
|
||||
<PillGlyph>
|
||||
<HugeiconsIcon
|
||||
icon={PencilRulerIcon}
|
||||
className="size-[15.5px]"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</PillGlyph>
|
||||
<span>Canvas</span>
|
||||
</button>
|
||||
) : null}
|
||||
{mcpEnabledForChat ? <McpComposerButton side="top" /> : null}
|
||||
</div>
|
||||
{/* mr-0.5 matches the send button inset from the edge in normal chat;
|
||||
gap-1.5 matches its control spacing. */}
|
||||
<div className="ml-auto mr-0.5 flex items-center gap-1.5">
|
||||
{showReasoningControl ? (
|
||||
effectiveReasoningStyle === "reasoning_effort" ? (
|
||||
isEffort || supportsPreserveThinking ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={reasoningDisabled}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
|
||||
reasoningDisabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: effectiveReasoningVisualEnabled
|
||||
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
|
||||
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
||||
)}
|
||||
className="unsloth-thinking-pill"
|
||||
data-active={thinkingActiveLook ? "true" : "false"}
|
||||
aria-label={thinkEffortAriaLabel({
|
||||
modelLoaded,
|
||||
reasoningDisabled,
|
||||
reasoningEffort,
|
||||
})}
|
||||
>
|
||||
{effectiveReasoningVisualEnabled ? (
|
||||
<LightbulbIcon className="size-3.5" />
|
||||
) : (
|
||||
<LightbulbOffIcon className="size-3.5" />
|
||||
)}
|
||||
<span>
|
||||
Think:{" "}
|
||||
{effectiveReasoningVisualEnabled
|
||||
? formatReasoningEffortLabel(
|
||||
reasoningEffort,
|
||||
externalSelection?.modelId,
|
||||
)
|
||||
: formatReasoningDisabledLabel(
|
||||
<BulbIcon className="size-[15.5px]" />
|
||||
{thinkingActiveLook ? (
|
||||
<span>
|
||||
{isEffort
|
||||
? `Thinking · ${formatReasoningEffortLabel(
|
||||
reasoningEffort,
|
||||
externalSelection?.modelId,
|
||||
)}`
|
||||
: "Thinking"}
|
||||
</span>
|
||||
) : null}
|
||||
<ArrowDownStandardIcon className="size-[15px]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="unsloth-plus-menu min-w-44"
|
||||
>
|
||||
{isEffort ? (
|
||||
<>
|
||||
{effectiveSupportsReasoningOff && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setReasoningEnabled(false);
|
||||
applyQwenThinkingParams(false);
|
||||
// Preserve thinking needs thinking on, so turn it off too.
|
||||
setPreserveThinking(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"unsloth-tick size-4",
|
||||
effectiveReasoningVisualEnabled && "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{formatReasoningDisabledLabel(
|
||||
effectiveSupportsReasoningOff,
|
||||
isExternalOpenAIReasoning,
|
||||
checkpoint,
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{effectiveSupportsReasoningOff && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setReasoningEnabled(false);
|
||||
applyQwenThinkingParams(false);
|
||||
}}
|
||||
>
|
||||
{formatReasoningDisabledLabel(
|
||||
effectiveSupportsReasoningOff,
|
||||
isExternalOpenAIReasoning,
|
||||
checkpoint,
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{effectiveReasoningEffortLevels
|
||||
.filter((level) => level !== "none")
|
||||
.map((level) => (
|
||||
{effectiveReasoningEffortLevels
|
||||
.filter((level) => level !== "none")
|
||||
.map((level) => (
|
||||
<DropdownMenuItem
|
||||
key={level}
|
||||
onSelect={() => {
|
||||
setReasoningEffort(level);
|
||||
setReasoningEnabled(true);
|
||||
applyQwenThinkingParams(true);
|
||||
// Mutual exclusion: turning thinking on for a
|
||||
// Kimi model forces the web_search builtin off.
|
||||
if (isKimiExternal && toolsEnabled) {
|
||||
setToolsEnabled(false, { persist: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"unsloth-tick size-4",
|
||||
!(
|
||||
effectiveReasoningVisualEnabled &&
|
||||
reasoningEffort === level
|
||||
) && "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{formatReasoningEffortLabel(
|
||||
level,
|
||||
externalSelection?.modelId,
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
effectiveSupportsReasoningOff &&
|
||||
!reasoningLockedOn && (
|
||||
<DropdownMenuItem
|
||||
key={level}
|
||||
onSelect={() => {
|
||||
setReasoningEffort(level);
|
||||
setReasoningEnabled(true);
|
||||
applyQwenThinkingParams(true);
|
||||
// Mutual exclusion: turning thinking on for a
|
||||
// Kimi model forces the web_search builtin off.
|
||||
if (isKimiExternal && toolsEnabled) {
|
||||
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"
|
||||
: ""}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"unsloth-tick size-4",
|
||||
!effectiveReasoningEnabled && "opacity-0",
|
||||
)}
|
||||
/>
|
||||
Thinking
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
{supportsPreserveThinking && (
|
||||
<DropdownMenuItem
|
||||
disabled={!modelLoaded}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
const next = !preserveThinking;
|
||||
setPreserveThinking(next);
|
||||
// Preserve thinking requires thinking on.
|
||||
if (next) {
|
||||
setReasoningEnabled(true);
|
||||
applyQwenThinkingParams(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"unsloth-tick size-4",
|
||||
!preserveThinking && "opacity-0",
|
||||
)}
|
||||
/>
|
||||
Preserve thinking
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
|
|
@ -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) ? (
|
||||
<LightbulbIcon className="size-3.5" />
|
||||
) : (
|
||||
<LightbulbOffIcon className="size-3.5" />
|
||||
)}
|
||||
<span>Think</span>
|
||||
<PillGlyph>
|
||||
<BulbIcon className="size-[15.5px]" />
|
||||
</PillGlyph>
|
||||
{thinkingActiveLook ? <span>Thinking</span> : null}
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
{supportsPreserveThinking && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!modelLoaded}
|
||||
onClick={() => setPreserveThinking(!preserveThinking)}
|
||||
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",
|
||||
!modelLoaded
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: preserveThinking
|
||||
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
|
||||
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
||||
)}
|
||||
aria-label={
|
||||
preserveThinking
|
||||
? "Disable preserve think"
|
||||
: "Enable preserve think"
|
||||
}
|
||||
>
|
||||
{preserveThinking && modelLoaded ? (
|
||||
<LightbulbIcon className="size-3.5" />
|
||||
) : (
|
||||
<LightbulbOffIcon className="size-3.5" />
|
||||
)}
|
||||
<span>Preserve Think</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={searchDisabled}
|
||||
onClick={() => {
|
||||
const next = !toolsEnabled;
|
||||
setToolsEnabled(next);
|
||||
// Kimi's $web_search builtin requires thinking=disabled
|
||||
// (https://platform.kimi.ai/docs/guide/use-web-search).
|
||||
// Toggle the Think pill off when Search comes on, and
|
||||
// back on when Search goes off — mutual exclusion that
|
||||
// mirrors what the backend enforces.
|
||||
if (isKimiExternal) {
|
||||
setReasoningEnabled(!next, { persist: false });
|
||||
applyQwenThinkingParams(!next);
|
||||
}
|
||||
}}
|
||||
className="composer-pill-btn"
|
||||
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
|
||||
aria-label={
|
||||
toolsEnabled ? "Disable web search" : "Enable web search"
|
||||
}
|
||||
>
|
||||
<GlobeIcon className="size-3.5" />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={codeDisabled}
|
||||
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
|
||||
aria-label={
|
||||
codeToolsEnabled
|
||||
? "Disable code execution"
|
||||
: "Enable code execution"
|
||||
}
|
||||
>
|
||||
<CodeToggleIcon className="size-3.5" />
|
||||
<span>Code</span>
|
||||
</button>
|
||||
{showImagePill && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={imageDisabled}
|
||||
onClick={() => setImageToolsEnabled(!imageToolsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={
|
||||
imageToolsEnabled && !imageDisabled ? "true" : "false"
|
||||
}
|
||||
aria-label={
|
||||
imageToolsEnabled
|
||||
? "Disable image generation"
|
||||
: "Enable image generation"
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Image03Icon}
|
||||
className="size-3.5"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<span>Images</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={artifactDisabled}
|
||||
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={
|
||||
artifactsEnabled && !artifactDisabled ? "true" : "false"
|
||||
}
|
||||
aria-label={
|
||||
artifactsEnabled ? "Disable artifacts" : "Enable artifacts"
|
||||
}
|
||||
>
|
||||
<FileTextIcon className="size-3.5" />
|
||||
<span>Artifacts</span>
|
||||
</button>
|
||||
<McpComposerButton />
|
||||
{showWebFetchPill && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={webFetchDisabled}
|
||||
onClick={() => setWebFetchToolsEnabled(!webFetchToolsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={
|
||||
webFetchToolsEnabled && !webFetchDisabled ? "true" : "false"
|
||||
}
|
||||
aria-label={
|
||||
webFetchToolsEnabled ? "Disable URL fetch" : "Enable URL fetch"
|
||||
}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
<span>Fetch</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{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}
|
||||
>
|
||||
<SquareIcon className="size-3 fill-current" />
|
||||
|
|
@ -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"
|
||||
>
|
||||
<ArrowUpIcon className="size-4" />
|
||||
<ArrowUpIcon className="size-[22px] stroke-2" />
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<ChatRuntimeStore>((set, get) => ({
|
|||
chatTemplateOverride: null,
|
||||
loadedChatTemplateOverride: null,
|
||||
activeThreadId: null,
|
||||
activeProjectId: null,
|
||||
settingsPanelOpen: false,
|
||||
pendingAudioBase64: null,
|
||||
pendingAudioName: null,
|
||||
|
|
@ -825,6 +828,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
}),
|
||||
setActiveThreadId: (activeThreadId) =>
|
||||
set({ activeThreadId, contextUsage: null }),
|
||||
setActiveProjectId: (activeProjectId) => set({ activeProjectId }),
|
||||
setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }),
|
||||
clearCheckpoint: () => {
|
||||
// Mirror setCheckpoint's persistence behavior: dropping the
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,28 @@
|
|||
export type ModelType = "base" | "lora" | "model1" | "model2";
|
||||
|
||||
export type ChatView =
|
||||
| { mode: "single"; threadId?: string; newThreadNonce?: string }
|
||||
| { mode: "compare"; pairId: string };
|
||||
| {
|
||||
mode: "project";
|
||||
projectId: string;
|
||||
}
|
||||
| {
|
||||
mode: "single";
|
||||
threadId?: string;
|
||||
newThreadNonce?: string;
|
||||
projectId?: string | null;
|
||||
}
|
||||
| { mode: "compare"; pairId: string; projectId?: string | null };
|
||||
|
||||
export interface ProjectRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
instructions?: string;
|
||||
rootPath?: string | null;
|
||||
sandboxPath?: string | null;
|
||||
archived: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ThreadRecord {
|
||||
id: string;
|
||||
|
|
@ -13,6 +33,7 @@ export interface ThreadRecord {
|
|||
modelType: ModelType;
|
||||
modelId?: string;
|
||||
pairId?: string;
|
||||
projectId?: string | null;
|
||||
archived: boolean;
|
||||
createdAt: number;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,22 +4,32 @@
|
|||
import {
|
||||
buildBackendChatExport,
|
||||
clearBackendChats,
|
||||
deleteChatProject,
|
||||
deleteChatThreads,
|
||||
getChatProject,
|
||||
getChatMessage,
|
||||
getChatThread,
|
||||
batchListChatMessages,
|
||||
listChatProjects,
|
||||
listChatImportLedger,
|
||||
listChatMessages,
|
||||
listChatThreads,
|
||||
notifyChatHistoryUpdated,
|
||||
recordChatImportLedger,
|
||||
saveChatProject,
|
||||
saveChatMessage,
|
||||
saveChatThread,
|
||||
syncChatMessages,
|
||||
updateChatProject,
|
||||
updateChatThread,
|
||||
} from "../api/chat-api";
|
||||
import { db, DEXIE_DB_NAME } from "../db";
|
||||
import type { MessageRecord, ModelType, ThreadRecord } from "../types";
|
||||
import type {
|
||||
MessageRecord,
|
||||
ModelType,
|
||||
ProjectRecord,
|
||||
ThreadRecord,
|
||||
} from "../types";
|
||||
import {
|
||||
isChatThreadDeleted,
|
||||
markChatThreadsDeleted,
|
||||
|
|
@ -28,6 +38,7 @@ import {
|
|||
type ThreadListArgs = {
|
||||
modelType?: ModelType;
|
||||
pairId?: string;
|
||||
projectId?: string | null;
|
||||
includeArchived?: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -45,6 +56,7 @@ interface ExportedChat {
|
|||
exportedAt: string;
|
||||
version: 1;
|
||||
threadCount: number;
|
||||
projects?: unknown[];
|
||||
threads: unknown[];
|
||||
messages: unknown[];
|
||||
}
|
||||
|
|
@ -82,6 +94,8 @@ function matchesThreadListArgs(
|
|||
return (
|
||||
!isChatThreadDeleted(thread.id) &&
|
||||
(!args.pairId || thread.pairId === args.pairId) &&
|
||||
(args.projectId === undefined ||
|
||||
(thread.projectId ?? null) === args.projectId) &&
|
||||
(!args.modelType || thread.modelType === args.modelType) &&
|
||||
(args.includeArchived !== false || !thread.archived)
|
||||
);
|
||||
|
|
@ -584,6 +598,72 @@ export async function listStoredChatThreadsWithMessages(
|
|||
return entries.filter((e) => e.hasContent).map((e) => e.thread);
|
||||
}
|
||||
|
||||
export async function listStoredChatProjects(
|
||||
args: { includeArchived?: boolean } = {},
|
||||
): Promise<ProjectRecord[]> {
|
||||
return listChatProjects(args);
|
||||
}
|
||||
|
||||
export async function getStoredChatProject(
|
||||
projectId: string,
|
||||
): Promise<ProjectRecord | null> {
|
||||
return getChatProject(projectId);
|
||||
}
|
||||
|
||||
export async function createStoredChatProject(
|
||||
name: string,
|
||||
): Promise<ProjectRecord> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Project name is required.");
|
||||
}
|
||||
const now = Date.now();
|
||||
return saveChatProject({
|
||||
id: crypto.randomUUID(),
|
||||
name: trimmed,
|
||||
instructions: "",
|
||||
archived: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateStoredChatProject(
|
||||
projectId: string,
|
||||
patch: Partial<ProjectRecord>,
|
||||
): Promise<ProjectRecord> {
|
||||
return updateChatProject(projectId, {
|
||||
...patch,
|
||||
updatedAt: patch.updatedAt ?? Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteStoredChatProject(
|
||||
projectId: string,
|
||||
args: { deleteFiles?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await deleteChatProject(projectId, args);
|
||||
}
|
||||
|
||||
export async function moveStoredChatItemToProject(
|
||||
item: { type: "single" | "compare"; id: string },
|
||||
projectId: string | null,
|
||||
): Promise<void> {
|
||||
const threadIds =
|
||||
item.type === "single"
|
||||
? [item.id]
|
||||
: (await listStoredChatThreads({
|
||||
pairId: item.id,
|
||||
includeArchived: true,
|
||||
})).map((thread) => thread.id);
|
||||
|
||||
await Promise.all(
|
||||
Array.from(new Set(threadIds)).map((threadId) =>
|
||||
updateStoredChatThread(threadId, { projectId }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveStoredChatMessage(
|
||||
message: MessageRecord,
|
||||
): Promise<MessageRecord> {
|
||||
|
|
@ -767,6 +847,7 @@ export async function buildStoredChatExport(): Promise<ExportedChat> {
|
|||
exportedAt: new Date().toISOString(),
|
||||
version: 1,
|
||||
threadCount: threads.length,
|
||||
projects: backend?.projects ?? [],
|
||||
threads,
|
||||
messages,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-[0_2px_8px_-2px_rgba(27,27,31,0.16)] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
aria-label={t("settings.profile.changePicture")}
|
||||
>
|
||||
<Camera className="size-3.5" strokeWidth={2} />
|
||||
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export function initialsFromName(name: string): string {
|
|||
return trimmed[0]!.toUpperCase();
|
||||
}
|
||||
|
||||
/** Default blue background for avatar fallback (readable white text). */
|
||||
/** Default Unsloth-green background for avatar fallback (readable white text). */
|
||||
export function avatarBgStyle(): { backgroundColor: string } {
|
||||
return { backgroundColor: "hsl(217 58% 48%)" };
|
||||
return { backgroundColor: "#14b789" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,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<string> {
|
|||
// 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<UnstructuredFileUploadResponse> {
|
||||
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`,
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Max 50MB per file.
|
||||
Max {LOCAL_SEED_UPLOAD_MAX_LABEL} per file.
|
||||
</p>
|
||||
{(localFile?.name || config.local_file_name?.trim()) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(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}
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} className="text-muted-foreground mb-2 size-8" />
|
||||
<HugeiconsIcon
|
||||
icon={CloudUploadIcon}
|
||||
className="text-muted-foreground mb-2 size-8"
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Drop files here or click to browse
|
||||
</p>
|
||||
<p className="text-muted-foreground/60 mt-1 text-xs">
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -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" && (
|
||||
<HugeiconsIcon icon={Loading03Icon} className="text-muted-foreground size-4 animate-spin" />
|
||||
<HugeiconsIcon
|
||||
icon={Loading03Icon}
|
||||
className="text-muted-foreground size-4 animate-spin"
|
||||
/>
|
||||
)}
|
||||
{entry.status === "ok" && (
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="size-4 text-green-500" />
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle02Icon}
|
||||
className="size-4 text-green-500"
|
||||
/>
|
||||
)}
|
||||
{entry.status === "error" && (
|
||||
<HugeiconsIcon icon={Alert02Icon} className="size-4 text-red-500" />
|
||||
<HugeiconsIcon
|
||||
icon={Alert02Icon}
|
||||
className="size-4 text-red-500"
|
||||
/>
|
||||
)}
|
||||
<span className="flex-1 truncate">{entry.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
|
|
@ -228,8 +263,14 @@ export function UnstructuredDropZone({
|
|||
</div>
|
||||
))}
|
||||
<div className="text-muted-foreground flex justify-between px-1 text-xs">
|
||||
<span>{successFiles.length} file{successFiles.length !== 1 ? "s" : ""} uploaded</span>
|
||||
<span>{formatSize(totalSize)} / 100MB</span>
|
||||
<span>
|
||||
{successFiles.length} file{successFiles.length !== 1 ? "s" : ""}{" "}
|
||||
uploaded
|
||||
</span>
|
||||
<span>
|
||||
{formatSize(totalSize)} /{" "}
|
||||
{UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
117
studio/frontend/src/features/settings/api/upload-limit.ts
Normal file
117
studio/frontend/src/features/settings/api/upload-limit.ts
Normal file
|
|
@ -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<UploadLimitSettings> | 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<UploadLimitSettings>).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<UploadLimitSettings> {
|
||||
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<UploadLimitSettings> {
|
||||
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()));
|
||||
}
|
||||
|
|
@ -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")}
|
||||
</DialogDescription>
|
||||
<div className="flex h-full min-h-0 max-sm:flex-col">
|
||||
<aside className="font-heading flex w-[216px] shrink-0 flex-col border-r border-border bg-muted/20 p-2 max-sm:w-full max-sm:border-r-0 max-sm:border-b">
|
||||
<aside className="font-heading flex w-[216px] shrink-0 flex-col border-r border-border bg-muted/20 p-2 dark:border-r-0 max-sm:w-full max-sm:border-r-0 max-sm:border-b max-sm:border-border">
|
||||
<h2 className="pl-3 pr-2.5 pt-3.5 pb-3.5 text-[19px] font-semibold text-foreground max-sm:hidden">
|
||||
{t("settings.dialog.title")}
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 max-sm:flex-row max-sm:overflow-x-auto">
|
||||
{TABS.map((tab) => {
|
||||
const active = activeTab === tab.id;
|
||||
|
|
@ -151,7 +156,7 @@ export function SettingsDialog() {
|
|||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"relative flex h-[32px] items-center gap-2.5 rounded-[8px] px-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
|
||||
"relative flex h-[32px] items-center gap-2.5 rounded-[11px] pl-3 pr-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
|
||||
"max-sm:shrink-0",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
active
|
||||
|
|
@ -162,7 +167,7 @@ export function SettingsDialog() {
|
|||
{active && (
|
||||
<motion.span
|
||||
layoutId="settings-active-pill"
|
||||
className="absolute inset-0 rounded-[8px] bg-[#ececec] dark:bg-[#2d2f33]"
|
||||
className="absolute inset-0 rounded-[11px] bg-[#ececec] dark:bg-[#2d2f33]"
|
||||
transition={
|
||||
reduced
|
||||
? { duration: 0 }
|
||||
|
|
@ -198,12 +203,12 @@ export function SettingsDialog() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={closeDialog}
|
||||
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2d2f33] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-full text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2d2f33] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t("settings.dialog.closeAriaLabel")}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
|
||||
</button>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6 [scrollbar-gutter:stable]">
|
||||
<div className="hover-scrollbar flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6 [scrollbar-gutter:stable]">
|
||||
{renderTab(activeTab)}
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
useChatRuntimeStore,
|
||||
} from "@/features/chat";
|
||||
import { useT } from "@/i18n";
|
||||
import { Delete02Icon, Download02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Delete02Icon, Download01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
|
|
@ -165,7 +165,7 @@ export function ChatTab() {
|
|||
onClick={handleExport}
|
||||
disabled={exporting || count === 0}
|
||||
>
|
||||
<HugeiconsIcon icon={Download02Icon} className="size-3.5 mr-1.5" />
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-3.5 mr-1.5" />
|
||||
{exporting
|
||||
? t("settings.chat.exportingAction")
|
||||
: t("settings.chat.exportAction")}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,13 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { usePlatformStore } from "@/config/env";
|
||||
import { resetOnboardingDone } from "@/features/auth";
|
||||
import { useChatRuntimeStore } from "@/features/chat";
|
||||
import { useSettingsDialogStore } from "@/features/settings";
|
||||
import {
|
||||
DEFAULT_UPLOAD_LIMIT_MB,
|
||||
loadUploadLimitSettings,
|
||||
updateUploadLimitSettings,
|
||||
type UploadLimitSettings,
|
||||
} from "../api/upload-limit";
|
||||
import { useSettingsDialogStore } from "../stores/settings-dialog-store";
|
||||
import { LOCALE_STORAGE_KEY, useT } from "@/i18n";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
|
@ -107,6 +113,14 @@ export function GeneralTab() {
|
|||
const [draftToken, setDraftToken] = useState(hfToken ?? "");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [uploadLimit, setUploadLimit] = useState<UploadLimitSettings | null>(
|
||||
null,
|
||||
);
|
||||
const [draftUploadLimit, setDraftUploadLimit] = useState(
|
||||
String(DEFAULT_UPLOAD_LIMIT_MB),
|
||||
);
|
||||
const [uploadLimitError, setUploadLimitError] = useState<string | null>(null);
|
||||
const [isSavingUploadLimit, setIsSavingUploadLimit] = useState(false);
|
||||
|
||||
const draftRef = useRef(draftToken);
|
||||
useEffect(() => {
|
||||
|
|
@ -132,6 +146,52 @@ export function GeneralTab() {
|
|||
if (trimmed !== hfToken) setHfToken(trimmed);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadUploadLimitSettings()
|
||||
.then((settings) => {
|
||||
if (cancelled) return;
|
||||
setUploadLimit(settings);
|
||||
setDraftUploadLimit(String(settings.maxUploadSizeMb));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setUploadLimitError(
|
||||
error instanceof Error ? error.message : "Failed to load upload limit.",
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveUploadLimit = async () => {
|
||||
const parsed = Number(draftUploadLimit);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
setUploadLimitError("Enter a whole number of MB.");
|
||||
return;
|
||||
}
|
||||
const min = uploadLimit?.minUploadSizeMb ?? 1;
|
||||
const max = uploadLimit?.maxAllowedUploadSizeMb ?? 8192;
|
||||
if (parsed < min || parsed > max) {
|
||||
setUploadLimitError(`Enter a value from ${min} to ${max} MB.`);
|
||||
return;
|
||||
}
|
||||
setIsSavingUploadLimit(true);
|
||||
setUploadLimitError(null);
|
||||
try {
|
||||
const settings = await updateUploadLimitSettings(parsed);
|
||||
setUploadLimit(settings);
|
||||
setDraftUploadLimit(String(settings.maxUploadSizeMb));
|
||||
} catch (error) {
|
||||
setUploadLimitError(
|
||||
error instanceof Error ? error.message : "Failed to save upload limit.",
|
||||
);
|
||||
} finally {
|
||||
setIsSavingUploadLimit(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
|
|
@ -183,6 +243,52 @@ export function GeneralTab() {
|
|||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.general.uploads.sectionTitle")}>
|
||||
<SettingsRow
|
||||
label={t("settings.general.uploads.maxUploadSize")}
|
||||
description={t("settings.general.uploads.maxUploadSizeDescription", {
|
||||
defaultSize: String(
|
||||
uploadLimit?.defaultUploadSizeMb ?? DEFAULT_UPLOAD_LIMIT_MB,
|
||||
),
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-28">
|
||||
<Input
|
||||
type="number"
|
||||
min={uploadLimit?.minUploadSizeMb ?? 1}
|
||||
max={uploadLimit?.maxAllowedUploadSizeMb ?? 8192}
|
||||
step={1}
|
||||
value={draftUploadLimit}
|
||||
aria-label="Training dataset upload cap in MB"
|
||||
onChange={(event) => setDraftUploadLimit(event.target.value)}
|
||||
className="h-8 w-full pr-10"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-xs font-medium text-muted-foreground">
|
||||
MB
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isSavingUploadLimit}
|
||||
onClick={() => void saveUploadLimit()}
|
||||
>
|
||||
{isSavingUploadLimit
|
||||
? t("common.saving")
|
||||
: t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{uploadLimitError ? (
|
||||
<span className="max-w-[260px] text-right text-xs text-destructive">
|
||||
{uploadLimitError}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
{!chatOnly && (
|
||||
<SettingsSection title={t("settings.general.gettingStarted")}>
|
||||
<SettingsRow
|
||||
|
|
|
|||
|
|
@ -40,12 +40,12 @@ import {
|
|||
} from "@/hooks";
|
||||
import {
|
||||
HfDatasetSubsetSplitSelectors,
|
||||
listLocalDatasets,
|
||||
uploadTrainingDataset,
|
||||
useDatasetPreviewDialogStore,
|
||||
useTrainingConfigStore,
|
||||
type LocalDatasetInfo,
|
||||
} from "@/features/training";
|
||||
import { listLocalDatasets } from "@/features/training/api/datasets-api";
|
||||
import type { LocalDatasetInfo } from "@/features/training/types/datasets";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
|
|
@ -68,6 +68,13 @@ import {
|
|||
useState,
|
||||
} from "react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
formatUploadSize,
|
||||
getCachedUploadLimitBytes,
|
||||
getCachedUploadLimitLabel,
|
||||
loadUploadLimitSettings,
|
||||
subscribeUploadLimitSettings,
|
||||
} from "@/features/settings/api/upload-limit";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { DocumentUploadRedirectDialog } from "./document-upload-redirect-dialog";
|
||||
import { translate, useT } from "@/i18n";
|
||||
|
|
@ -81,18 +88,28 @@ const TRAINING_UPLOAD_EXTENSIONS = [
|
|||
".docx",
|
||||
".txt",
|
||||
] as const;
|
||||
const TRAINING_UPLOAD_EXTENSION_SET = new Set<string>(TRAINING_UPLOAD_EXTENSIONS);
|
||||
const TRAINING_UPLOAD_EXTENSION_SET = new Set<string>(
|
||||
TRAINING_UPLOAD_EXTENSIONS,
|
||||
);
|
||||
const TRAINING_UPLOAD_ACCEPT = TRAINING_UPLOAD_EXTENSIONS.join(",");
|
||||
const TRAINING_UPLOAD_LABEL = "CSV, JSONL, JSON, Parquet, PDF, DOCX, TXT";
|
||||
const TRAINING_DATASET_UPLOAD_LABEL = "CSV, JSONL, JSON, Parquet";
|
||||
const DOCUMENT_REDIRECT_LABEL = "PDF/DOCX/TXT open Learning Recipes";
|
||||
const DOCUMENT_REDIRECT_EXTENSIONS = new Set([".pdf", ".docx", ".txt"]);
|
||||
|
||||
const SEARCH_INPUT_REASONS = new Set(["input-change", "input-paste", "input-clear"]);
|
||||
const SEARCH_INPUT_REASONS = new Set([
|
||||
"input-change",
|
||||
"input-paste",
|
||||
"input-clear",
|
||||
]);
|
||||
const OPEN_LEARNING_RECIPES_ON_ARRIVAL_KEY =
|
||||
"data-recipes:open-learning-recipes";
|
||||
|
||||
function getFileExtension(fileName: string) {
|
||||
const extensionStart = fileName.lastIndexOf(".");
|
||||
return extensionStart >= 0 ? fileName.slice(extensionStart).toLowerCase() : "";
|
||||
return extensionStart >= 0
|
||||
? fileName.slice(extensionStart).toLowerCase()
|
||||
: "";
|
||||
}
|
||||
|
||||
function isLikelyLocalDatasetRef(value: string) {
|
||||
|
|
@ -326,7 +343,11 @@ export function DatasetSection() {
|
|||
|
||||
const localResultIds = useMemo(() => {
|
||||
const ids = localFilteredDatasets.map((item) => item.id);
|
||||
if (selectedLocalDataset && selectedLocalId && !ids.includes(selectedLocalId)) {
|
||||
if (
|
||||
selectedLocalDataset &&
|
||||
selectedLocalId &&
|
||||
!ids.includes(selectedLocalId)
|
||||
) {
|
||||
ids.push(selectedLocalId);
|
||||
}
|
||||
return ids;
|
||||
|
|
@ -353,7 +374,8 @@ export function DatasetSection() {
|
|||
]);
|
||||
|
||||
const activeSourceTab = datasetSource === "upload" ? "local" : "huggingface";
|
||||
const comboboxItems = pickerTab === "huggingface" ? hfResultIds : localResultIds;
|
||||
const comboboxItems =
|
||||
pickerTab === "huggingface" ? hfResultIds : localResultIds;
|
||||
const comboboxValue =
|
||||
pickerTab === "huggingface"
|
||||
? datasetSource === "huggingface"
|
||||
|
|
@ -367,11 +389,14 @@ export function DatasetSection() {
|
|||
!!dataset &&
|
||||
!isLikelyLocalDatasetRef(dataset);
|
||||
|
||||
const selectedDatasetName = datasetSource === "upload" ? uploadedFile : dataset;
|
||||
const selectedDatasetName =
|
||||
datasetSource === "upload" ? uploadedFile : dataset;
|
||||
const selectedLocalMetadata = selectedLocalDataset?.metadata ?? null;
|
||||
const selectedLocalColumns = selectedLocalMetadata?.columns ?? [];
|
||||
const selectedLocalRows =
|
||||
selectedLocalDataset?.rows ?? selectedLocalMetadata?.actual_num_records ?? null;
|
||||
selectedLocalDataset?.rows ??
|
||||
selectedLocalMetadata?.actual_num_records ??
|
||||
null;
|
||||
const selectedLocalUpdatedAt = selectedLocalDataset?.updated_at ?? null;
|
||||
|
||||
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -384,18 +409,67 @@ export function DatasetSection() {
|
|||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDatasetDragOver, setIsDatasetDragOver] = useState(false);
|
||||
const [uploadLimitBytes, setUploadLimitBytes] = useState(
|
||||
getCachedUploadLimitBytes,
|
||||
);
|
||||
const [uploadLimitLabel, setUploadLimitLabel] = useState(
|
||||
getCachedUploadLimitLabel,
|
||||
);
|
||||
const [documentRedirectOpen, setDocumentRedirectOpen] = useState(false);
|
||||
const [redirectFileName, setRedirectFileName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const applyLimit = (settings: {
|
||||
maxUploadSizeBytes: number;
|
||||
maxUploadSizeLabel: string;
|
||||
}) => {
|
||||
setUploadLimitBytes(settings.maxUploadSizeBytes);
|
||||
setUploadLimitLabel(settings.maxUploadSizeLabel);
|
||||
};
|
||||
const unsubscribe = subscribeUploadLimitSettings(applyLimit);
|
||||
void loadUploadLimitSettings().then((settings) => {
|
||||
if (!cancelled) applyLimit(settings);
|
||||
}).catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUploadButtonClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const getLatestUploadLimit = async () => {
|
||||
try {
|
||||
const settings = await loadUploadLimitSettings();
|
||||
setUploadLimitBytes(settings.maxUploadSizeBytes);
|
||||
setUploadLimitLabel(settings.maxUploadSizeLabel);
|
||||
return settings;
|
||||
} catch {
|
||||
return {
|
||||
maxUploadSizeBytes: uploadLimitBytes,
|
||||
maxUploadSizeLabel: uploadLimitLabel,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (
|
||||
file: File,
|
||||
onSuccess: (storedPath: string) => void,
|
||||
successMessage: string,
|
||||
) => {
|
||||
const latestLimit = await getLatestUploadLimit();
|
||||
if (file.size > latestLimit.maxUploadSizeBytes) {
|
||||
toast.error("File too large", {
|
||||
description: `${file.name} is ${formatUploadSize(
|
||||
file.size,
|
||||
)}. Training uploads support up to ${latestLimit.maxUploadSizeLabel}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const uploaded = await uploadTrainingDataset(file);
|
||||
|
|
@ -430,7 +504,9 @@ export function DatasetSection() {
|
|||
await handleFileUpload(file, selectLocalDataset, t("studio.dataset.datasetUploaded"));
|
||||
};
|
||||
|
||||
const handleDatasetFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const handleDatasetFileChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
|
|
@ -560,11 +636,16 @@ export function DatasetSection() {
|
|||
value={comboboxValue}
|
||||
onOpenChange={(open) => {
|
||||
setSearchQuery("");
|
||||
if (open && (pickerTab === "local" || activeSourceTab === "local")) {
|
||||
if (
|
||||
open &&
|
||||
(pickerTab === "local" || activeSourceTab === "local")
|
||||
) {
|
||||
void refreshLocalDatasets();
|
||||
}
|
||||
if (!open) {
|
||||
setPickerTab(pendingSourceTabRef.current ?? activeSourceTab);
|
||||
setPickerTab(
|
||||
pendingSourceTabRef.current ?? activeSourceTab,
|
||||
);
|
||||
pendingSourceTabRef.current = null;
|
||||
}
|
||||
}}
|
||||
|
|
@ -586,9 +667,7 @@ export function DatasetSection() {
|
|||
handleInputChange(value, eventDetails)
|
||||
}
|
||||
itemToStringValue={(id) =>
|
||||
pickerTab === "local"
|
||||
? localLabelById.get(id) ?? id
|
||||
: id
|
||||
pickerTab === "local" ? (localLabelById.get(id) ?? id) : id
|
||||
}
|
||||
autoHighlight={true}
|
||||
>
|
||||
|
|
@ -635,7 +714,11 @@ export function DatasetSection() {
|
|||
<ComboboxList className="p-1 !max-h-none !overflow-visible">
|
||||
{(id: string) => {
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} className="gap-2">
|
||||
<ComboboxItem
|
||||
key={id}
|
||||
value={id}
|
||||
className="gap-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<span className="block min-w-0 flex-1 truncate">
|
||||
|
|
@ -670,7 +753,9 @@ export function DatasetSection() {
|
|||
) : (
|
||||
<>
|
||||
{localError ? (
|
||||
<p className="px-2 py-2 text-xs text-destructive">{localError}</p>
|
||||
<p className="px-2 py-2 text-xs text-destructive">
|
||||
{localError}
|
||||
</p>
|
||||
) : (
|
||||
<ComboboxEmpty className="px-2 py-3">
|
||||
<div className="flex w-full flex-col items-center gap-2 text-center">
|
||||
|
|
@ -692,7 +777,11 @@ export function DatasetSection() {
|
|||
{(id: string) => {
|
||||
const label = localLabelById.get(id) ?? id;
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} className="gap-2">
|
||||
<ComboboxItem
|
||||
key={id}
|
||||
value={id}
|
||||
className="gap-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<span className="block min-w-0 flex-1 truncate">
|
||||
|
|
@ -814,8 +903,10 @@ export function DatasetSection() {
|
|||
<MetadataRow
|
||||
label={t("studio.dataset.batches")}
|
||||
value={
|
||||
typeof selectedLocalMetadata?.num_completed_batches === "number" &&
|
||||
typeof selectedLocalMetadata?.total_num_batches === "number"
|
||||
typeof selectedLocalMetadata?.num_completed_batches ===
|
||||
"number" &&
|
||||
typeof selectedLocalMetadata?.total_num_batches ===
|
||||
"number"
|
||||
? `${selectedLocalMetadata.num_completed_batches}/${selectedLocalMetadata.total_num_batches}`
|
||||
: "--"
|
||||
}
|
||||
|
|
@ -837,7 +928,10 @@ export function DatasetSection() {
|
|||
{uploadedEvalFile ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 overflow-hidden">
|
||||
<HugeiconsIcon icon={FileAttachmentIcon} className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<HugeiconsIcon
|
||||
icon={FileAttachmentIcon}
|
||||
className="size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="truncate text-xs">
|
||||
{deriveLocalDatasetName(uploadedEvalFile)}
|
||||
</span>
|
||||
|
|
@ -863,7 +957,10 @@ export function DatasetSection() {
|
|||
{isUploading ? (
|
||||
<Spinner className="size-3.5" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={CloudUploadIcon} className="size-3.5" />
|
||||
<HugeiconsIcon
|
||||
icon={CloudUploadIcon}
|
||||
className="size-3.5"
|
||||
/>
|
||||
)}
|
||||
{isUploading
|
||||
? t("studio.dataset.uploading")
|
||||
|
|
@ -962,7 +1059,9 @@ export function DatasetSection() {
|
|||
placeholder="0"
|
||||
value={datasetSliceStart ?? ""}
|
||||
onChange={(e) =>
|
||||
setDatasetSliceStart(normalizeSliceInput(e.target.value))
|
||||
setDatasetSliceStart(
|
||||
normalizeSliceInput(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1015,8 +1114,8 @@ export function DatasetSection() {
|
|||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono text-sm font-medium truncate">
|
||||
{datasetSource === "upload"
|
||||
? selectedLocalDataset?.label ??
|
||||
deriveLocalDatasetName(selectedDatasetName)
|
||||
? (selectedLocalDataset?.label ??
|
||||
deriveLocalDatasetName(selectedDatasetName))
|
||||
: selectedDatasetName}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
|
|
@ -1074,7 +1173,8 @@ export function DatasetSection() {
|
|||
{t("studio.dataset.dropFileOrClick")}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[10px] text-muted-foreground">
|
||||
{TRAINING_UPLOAD_LABEL}
|
||||
{TRAINING_DATASET_UPLOAD_LABEL} · up to{" "}
|
||||
{uploadLimitLabel}; {DOCUMENT_REDIRECT_LABEL}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -1131,7 +1231,7 @@ export function DatasetSection() {
|
|||
fileName={redirectFileName}
|
||||
onOpenLearningRecipes={handleOpenLearningRecipes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ export function StudioPage(): ReactElement {
|
|||
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
|
||||
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId);
|
||||
|
||||
const setCurrentRunViewActive = useTrainingRuntimeStore(
|
||||
(s) => s.setCurrentRunViewActive,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setSelectedHistoryRunId(null);
|
||||
}, [setSelectedHistoryRunId]);
|
||||
|
|
@ -67,6 +71,13 @@ export function StudioPage(): ReactElement {
|
|||
? "configure"
|
||||
: requestedTab;
|
||||
|
||||
// Mirror "Current Run" tab state into the store so the sidebar can highlight
|
||||
// the run this view refers to. Cleared on unmount (leaving the studio page).
|
||||
useEffect(() => {
|
||||
setCurrentRunViewActive(activeTab === "current-run");
|
||||
return () => setCurrentRunViewActive(false);
|
||||
}, [activeTab, setCurrentRunViewActive]);
|
||||
|
||||
const { setPinned } = useSidebar();
|
||||
const pinSidebar = useCallback(() => setPinned(true), [setPinned]);
|
||||
|
||||
|
|
@ -166,7 +177,7 @@ export function StudioPage(): ReactElement {
|
|||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 pb-3">
|
||||
{selectedHistoryRunId && activeTab === "history" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ import { useT } from "@/i18n";
|
|||
|
||||
const HF_REPO_REGEX = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
||||
|
||||
// Tracks which jobs have already played the terminal intro animation. The
|
||||
// overlay unmounts when you navigate away from the training page, so without
|
||||
// this its typing/fade-in would replay on every return even though the run
|
||||
// itself is still going. Module-level so it survives remounts.
|
||||
const animatedJobs = new Set<string>();
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n <= 0) return "0 B";
|
||||
if (n < 1024) return `${n} B`;
|
||||
|
|
@ -254,6 +260,7 @@ export function TrainingStartOverlay({
|
|||
const { stopTrainingRun, dismissTrainingRun } = useTrainingActions();
|
||||
const isStarting = useTrainingRuntimeStore((s) => s.isStarting);
|
||||
const phase = useTrainingRuntimeStore((s) => s.phase);
|
||||
const jobId = useTrainingRuntimeStore((s) => s.jobId);
|
||||
const startModelName = useTrainingRuntimeStore((s) => s.startModelName);
|
||||
const startDatasetName = useTrainingRuntimeStore((s) => s.startDatasetName);
|
||||
const startFromResume = useTrainingRuntimeStore((s) => s.startFromResume);
|
||||
|
|
@ -298,6 +305,16 @@ export function TrainingStartOverlay({
|
|||
}
|
||||
}, [isStarting]);
|
||||
|
||||
// Play the intro animation only the first time we mount for a given job.
|
||||
// On later remounts (e.g. leaving the training page and coming back) the
|
||||
// terminal renders its final state instantly so the logs don't restart.
|
||||
const alreadyAnimated = jobId != null && animatedJobs.has(jobId);
|
||||
useEffect(() => {
|
||||
if (jobId != null) {
|
||||
animatedJobs.add(jobId);
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-2xl bg-background/45 backdrop-blur-[1px]">
|
||||
<div className="pointer-events-auto relative flex w-[860px] max-w-[calc(100%-2rem)] flex-col items-center gap-4">
|
||||
|
|
@ -311,7 +328,7 @@ export function TrainingStartOverlay({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-3 top-3 z-10 size-7 cursor-pointer rounded-full text-muted-foreground/60 hover:bg-destructive/10 hover:text-destructive"
|
||||
className="absolute right-3 top-3 z-10 size-7 cursor-pointer rounded-full text-muted-foreground/90 hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
disabled={cancelRequested}
|
||||
>
|
||||
|
|
@ -349,6 +366,7 @@ export function TrainingStartOverlay({
|
|||
<Terminal
|
||||
className="w-full min-h-[390px] rounded-2xl px-7 py-6 text-left"
|
||||
startOnView={false}
|
||||
instant={alreadyAnimated}
|
||||
>
|
||||
<TypingAnimation
|
||||
duration={36}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ export {
|
|||
export { useMaxStepsEpochsToggle } from "./hooks/use-max-steps-epochs-toggle";
|
||||
export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-split-selectors";
|
||||
export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-store";
|
||||
export { uploadTrainingDataset } from "./api/datasets-api";
|
||||
export { listLocalDatasets, uploadTrainingDataset } from "./api/datasets-api";
|
||||
export type { LocalDatasetInfo } from "./types/datasets";
|
||||
export { listLocalModels } from "./api/models-api";
|
||||
export type { LocalModelInfo } from "./api/models-api";
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ const initialState: TrainingRuntimeState = {
|
|||
resetGeneration: 0,
|
||||
stopRequested: false,
|
||||
selectedHistoryRunId: null,
|
||||
currentRunViewActive: false,
|
||||
};
|
||||
|
||||
function sortSeries(points: TrainingSeriesPoint[]): TrainingSeriesPoint[] {
|
||||
|
|
@ -182,6 +183,9 @@ export const useTrainingRuntimeStore = create<TrainingRuntimeStore>()((set) => (
|
|||
setSelectedHistoryRunId: (selectedHistoryRunId) =>
|
||||
set({ selectedHistoryRunId }),
|
||||
|
||||
setCurrentRunViewActive: (currentRunViewActive) =>
|
||||
set({ currentRunViewActive }),
|
||||
|
||||
applyStatus: (payload) =>
|
||||
set((state) => {
|
||||
const metricHistory = applyMetricHistoryFromStatus(payload);
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ export interface TrainingRuntimeState {
|
|||
resetGeneration: number;
|
||||
stopRequested: boolean;
|
||||
selectedHistoryRunId: string | null;
|
||||
// True while the studio "Current Run" tab is the active view, so the sidebar
|
||||
// can highlight which run row the current run refers to (the active job).
|
||||
currentRunViewActive: boolean;
|
||||
}
|
||||
|
||||
export interface TrainingRuntimeActions {
|
||||
|
|
@ -127,6 +130,7 @@ export interface TrainingRuntimeActions {
|
|||
setStartQueued: (jobId: string, message: string) => void;
|
||||
setRuntimeError: (message: string) => void;
|
||||
setSelectedHistoryRunId: (id: string | null) => void;
|
||||
setCurrentRunViewActive: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export type TrainingRuntimeStore = TrainingRuntimeState & TrainingRuntimeActions;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const en = {
|
|||
new: "New",
|
||||
rename: "Rename",
|
||||
save: "Save",
|
||||
saving: "Saving...",
|
||||
search: "Search",
|
||||
shutdown: "Shutdown",
|
||||
},
|
||||
|
|
@ -54,11 +55,11 @@ export const en = {
|
|||
dialog: {
|
||||
deleteChat: {
|
||||
title: "Delete chat",
|
||||
description: "Are you sure you want to delete this chat \"{name}\"?",
|
||||
description: 'Are you sure you want to delete this chat "{name}"?',
|
||||
},
|
||||
deleteRun: {
|
||||
title: "Delete training run",
|
||||
description: "Are you sure you want to delete this run \"{name}\"?",
|
||||
description: 'Are you sure you want to delete this run "{name}"?',
|
||||
},
|
||||
renameChat: {
|
||||
title: "Rename chat",
|
||||
|
|
@ -111,15 +112,21 @@ export const en = {
|
|||
startOnboardingDescription:
|
||||
"Open the setup wizard again without changing your account.",
|
||||
startOnboardingAction: "Start onboarding",
|
||||
uploads: {
|
||||
sectionTitle: "Uploads",
|
||||
maxUploadSize: "Training dataset upload cap",
|
||||
maxUploadSizeDescription:
|
||||
"Applies to training dataset uploads. Default is {defaultSize} MB.",
|
||||
},
|
||||
resetPreferences: {
|
||||
sectionTitle: "Danger zone",
|
||||
label: "Reset all local preferences",
|
||||
description:
|
||||
"Clears local-only preferences. Chats, API access, and DB-backed chat settings are not affected.",
|
||||
"Clears local-only preferences. Chats, API access, and DB-backed settings are not affected.",
|
||||
action: "Reset preferences",
|
||||
confirmTitle: "Reset all local preferences?",
|
||||
confirmDescription:
|
||||
"This clears local-only preferences, then reloads Studio. Chats, API access, and DB-backed chat settings are not affected.",
|
||||
"This clears local-only preferences, then reloads Studio. Chats, API access, and DB-backed settings are not affected.",
|
||||
confirmAction: "Reset and reload",
|
||||
},
|
||||
},
|
||||
|
|
@ -184,8 +191,7 @@ export const en = {
|
|||
clearHistoryDescription: "Delete local chat history from this device.",
|
||||
clearAction: "Clear",
|
||||
clearAllChats: "Clear all chats",
|
||||
clearAllChatsDescription:
|
||||
"Permanently delete every chat on this device.",
|
||||
clearAllChatsDescription: "Permanently delete every chat on this device.",
|
||||
noChatsToClear: "No chats to clear.",
|
||||
clearOneChatDescription:
|
||||
"Permanently delete the only chat on this device.",
|
||||
|
|
@ -209,8 +215,7 @@ export const en = {
|
|||
"{clearedCount} chats cleared; {remainingCount} chats remain. Please retry.",
|
||||
oneChatClearedRemain:
|
||||
"1 chat cleared; {remainingCount} chats remain. Please retry.",
|
||||
oneChatClearedRemainOne:
|
||||
"1 chat cleared; 1 chat remains. Please retry.",
|
||||
oneChatClearedRemainOne: "1 chat cleared; 1 chat remains. Please retry.",
|
||||
storageClearFailedOne:
|
||||
"A storage clear failed; 1 chat may remain. Please retry.",
|
||||
storageClearFailed:
|
||||
|
|
@ -223,7 +228,8 @@ export const en = {
|
|||
},
|
||||
apiKeys: {
|
||||
title: "API",
|
||||
description: "Access Unsloth programmatically via the OpenAI-compatible API.",
|
||||
description:
|
||||
"Access Unsloth programmatically via the OpenAI-compatible API.",
|
||||
readDocs: "Read the API docs",
|
||||
noAccess: "No API access yet.",
|
||||
newBadge: "New",
|
||||
|
|
@ -261,10 +267,10 @@ export const en = {
|
|||
actionsFor: "Actions for {name}",
|
||||
copyPrefix: "Copy prefix",
|
||||
revokeToken: "Revoke token",
|
||||
revokeTitle: "Revoke access token \"{name}\"?",
|
||||
revokeTitle: 'Revoke access token "{name}"?',
|
||||
revokeDescription:
|
||||
"Applications using this token will immediately lose access. This cannot be undone.",
|
||||
revokeAction: "Revoke \"{name}\"",
|
||||
revokeAction: 'Revoke "{name}"',
|
||||
revoking: "Revoking...",
|
||||
},
|
||||
about: {
|
||||
|
|
@ -361,7 +367,8 @@ export const en = {
|
|||
fasterTrainingBadge: "2x Faster Training",
|
||||
baseModel: "Base model",
|
||||
localModel: "Local Model",
|
||||
localModelTooltip: "Path to a locally downloaded model or a custom HF repo.",
|
||||
localModelTooltip:
|
||||
"Path to a locally downloaded model or a custom HF repo.",
|
||||
scanningLocalAndCachedModels: "Scanning local and cached models...",
|
||||
scanning: "Scanning...",
|
||||
scanningLocalModels: "Scanning local models...",
|
||||
|
|
@ -410,8 +417,7 @@ export const en = {
|
|||
noLocalDatasetsYet: "No local datasets yet.",
|
||||
noLocalDatasetsMatchSearch: "No local datasets match search.",
|
||||
openDataRecipes: "Open Data Recipes",
|
||||
browsingSource:
|
||||
"Browsing {browsing}. Current selection stays {current}.",
|
||||
browsingSource: "Browsing {browsing}. Current selection stays {current}.",
|
||||
localDatasets: "Local datasets",
|
||||
localDataset: "Local dataset",
|
||||
localDatasetRows: " / {count} rows",
|
||||
|
|
@ -471,7 +477,8 @@ export const en = {
|
|||
maxStepsTooltip: "Override total optimizer steps.",
|
||||
epochsTooltip: "Number of full passes over the dataset.",
|
||||
epochsDescription: "Each epoch is one full pass over your dataset.",
|
||||
maxStepsDescription: "Limits training to a fixed number of optimizer steps.",
|
||||
maxStepsDescription:
|
||||
"Limits training to a fixed number of optimizer steps.",
|
||||
contextLength: "Context Length",
|
||||
contextLengthTooltip: "Maximum number of tokens per training sample.",
|
||||
customContextLength: "Enter a custom value",
|
||||
|
|
@ -487,11 +494,13 @@ export const en = {
|
|||
embeddingLearningRateDescription:
|
||||
"Leave blank to use lr/10 (recommended). Typical range is 2x-10x smaller than the main learning rate.",
|
||||
rank: "Rank",
|
||||
rankTooltip: "Dimension of the low-rank matrices. Higher = more capacity.",
|
||||
rankTooltip:
|
||||
"Dimension of the low-rank matrices. Higher = more capacity.",
|
||||
alpha: "Alpha",
|
||||
alphaTooltip: "Scaling factor for LoRA updates. Usually 2x rank.",
|
||||
dropout: "Dropout",
|
||||
dropoutTooltip: "Dropout probability for LoRA layers to reduce overfitting.",
|
||||
dropoutTooltip:
|
||||
"Dropout probability for LoRA layers to reduce overfitting.",
|
||||
visionLayers: "Vision layers",
|
||||
languageLayers: "Language layers",
|
||||
attentionModules: "Attention modules",
|
||||
|
|
@ -529,7 +538,8 @@ export const en = {
|
|||
weightDecay: "Weight Decay",
|
||||
weightDecayTooltip: "L2 regularization to prevent overfitting.",
|
||||
warmupSteps: "Warmup Steps",
|
||||
warmupStepsTooltip: "Gradually increase LR at training start for stability.",
|
||||
warmupStepsTooltip:
|
||||
"Gradually increase LR at training start for stability.",
|
||||
scheduleEpochsTooltip:
|
||||
"Number of full passes over the dataset. Set 0 to run by max steps.",
|
||||
saveSteps: "Save Steps",
|
||||
|
|
@ -586,7 +596,8 @@ export const en = {
|
|||
exportModel: "Export Model",
|
||||
milestone: "Milestone",
|
||||
halfwayDone: "Halfway done. Training is past 50%.",
|
||||
doneNextStep: "Training done. Next step: compare base vs fine-tuned outputs.",
|
||||
doneNextStep:
|
||||
"Training done. Next step: compare base vs fine-tuned outputs.",
|
||||
},
|
||||
history: {
|
||||
title: "History",
|
||||
|
|
@ -632,7 +643,8 @@ export const en = {
|
|||
},
|
||||
charts: {
|
||||
settings: "Chart Settings",
|
||||
settingsDescription: "Tune chart presentation while training keeps running.",
|
||||
settingsDescription:
|
||||
"Tune chart presentation while training keeps running.",
|
||||
openSettings: "Open chart settings",
|
||||
viewWindow: "View window",
|
||||
viewWindowDescription: "Show latest steps only or the full history.",
|
||||
|
|
@ -667,7 +679,8 @@ export const en = {
|
|||
waitingForFirstEvaluationStep: "Waiting for first evaluation step...",
|
||||
evaluationNotConfigured: "Evaluation not configured",
|
||||
evalChartWillAppear: "Chart will appear once eval_steps is reached",
|
||||
setEvalDatasetAndSteps: "Set eval dataset & eval_steps to track eval loss",
|
||||
setEvalDatasetAndSteps:
|
||||
"Set eval dataset & eval_steps to track eval loss",
|
||||
},
|
||||
progress: {
|
||||
title: "Training Progress",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const zhCN = {
|
|||
new: "新增",
|
||||
rename: "重命名",
|
||||
save: "保存",
|
||||
saving: "保存中...",
|
||||
search: "搜索",
|
||||
shutdown: "关闭服务",
|
||||
},
|
||||
|
|
@ -108,15 +109,21 @@ export const zhCN = {
|
|||
startOnboarding: "开始引导",
|
||||
startOnboardingDescription: "重新打开设置向导,不会更改你的账号。",
|
||||
startOnboardingAction: "开始引导",
|
||||
uploads: {
|
||||
sectionTitle: "上传",
|
||||
maxUploadSize: "训练数据集上传上限",
|
||||
maxUploadSizeDescription:
|
||||
"适用于训练数据集上传。默认值为 {defaultSize} MB。",
|
||||
},
|
||||
resetPreferences: {
|
||||
sectionTitle: "危险区域",
|
||||
label: "重置所有本地偏好设置",
|
||||
description:
|
||||
"清除仅保存在本地的偏好设置。聊天、API 访问权限和数据库中的聊天设置不会受到影响。",
|
||||
"清除仅保存在本地的偏好设置。聊天、API 访问权限和数据库中的设置不会受到影响。",
|
||||
action: "重置偏好设置",
|
||||
confirmTitle: "重置所有本地偏好设置?",
|
||||
confirmDescription:
|
||||
"这会清除仅保存在本地的偏好设置,然后重新加载 Studio。聊天、API 访问权限和数据库中的聊天设置不会受到影响。",
|
||||
"这会清除仅保存在本地的偏好设置,然后重新加载 Studio。聊天、API 访问权限和数据库中的设置不会受到影响。",
|
||||
confirmAction: "重置并重新加载",
|
||||
},
|
||||
},
|
||||
|
|
@ -243,10 +250,10 @@ export const zhCN = {
|
|||
actionsFor: "{name} 的操作",
|
||||
copyPrefix: "复制前缀",
|
||||
revokeToken: "撤销 token",
|
||||
revokeTitle: "撤销访问 token \"{name}\"?",
|
||||
revokeTitle: '撤销访问 token "{name}"?',
|
||||
revokeDescription:
|
||||
"使用此 token 的应用会立即失去访问权限。此操作无法撤销。",
|
||||
revokeAction: "撤销 \"{name}\"",
|
||||
revokeAction: '撤销 "{name}"',
|
||||
revoking: "撤销中...",
|
||||
},
|
||||
about: {
|
||||
|
|
@ -407,8 +414,7 @@ export const zhCN = {
|
|||
"可选。如果未提供,将从训练数据中切分出一小部分。",
|
||||
advanced: "高级",
|
||||
targetFormat: "目标格式",
|
||||
targetFormatTooltip:
|
||||
"训练数据的格式。自动检测对大多数数据集都有效。",
|
||||
targetFormatTooltip: "训练数据的格式。自动检测对大多数数据集都有效。",
|
||||
auto: "自动",
|
||||
rawText: "原始文本",
|
||||
trainSplitStart: "训练切分起始",
|
||||
|
|
@ -505,8 +511,7 @@ export const zhCN = {
|
|||
weightDecayTooltip: "L2 正则化,用于防止过拟合。",
|
||||
warmupSteps: "预热步数",
|
||||
warmupStepsTooltip: "在训练开始时逐步提高学习率,提升稳定性。",
|
||||
scheduleEpochsTooltip:
|
||||
"完整遍历数据集的次数。设为 0 则按最大步数运行。",
|
||||
scheduleEpochsTooltip: "完整遍历数据集的次数。设为 0 则按最大步数运行。",
|
||||
saveSteps: "保存步数",
|
||||
saveStepsTooltip: "每 N 步保存一次检查点。0 表示禁用。",
|
||||
evalSteps: "评估步数",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,51 @@
|
|||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@layer components {
|
||||
/* Sidebar scroll area (Gemini-style). The BOTTOM edge always fades so the
|
||||
last rows dissolve into the profile footer (no divider line). Once
|
||||
scrolled, the TOP edge also fades so rows dissolve as they pass under the
|
||||
pinned New Chat / Search header. */
|
||||
/* Top edge: when scrolled, rows dissolve as they pass under the pinned
|
||||
New Chat / Search header (mask — fades content to transparent). */
|
||||
.sidebar-scroll-fade.is-scrolled {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 14px);
|
||||
mask-image: linear-gradient(to bottom, transparent 0, #000 14px);
|
||||
}
|
||||
/* Bottom edge: Gemini-style sticky gradient OVERLAY painted in the sidebar
|
||||
background colour, so the last rows wash into the profile footer. Uses a
|
||||
zero-height sticky anchor with an absolutely-positioned gradient so it
|
||||
never takes layout space. */
|
||||
.sidebar-bottom-fade {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
height: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sidebar-bottom-fade::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(to top, var(--sidebar), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
[data-slot="dialog-title"],
|
||||
[data-slot="alert-dialog-title"] {
|
||||
font-family: "Hellix", "Space Grotesk Variable", var(--font-sans) !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: "Hellix";
|
||||
src: url("/fonts/Hellix-Regular.woff") format("woff");
|
||||
|
|
@ -81,13 +126,15 @@
|
|||
--chart-4: oklch(0.6926 0.1112 346.5775);
|
||||
--chart-5: oklch(0.7497 0.1003 85.0057);
|
||||
--radius: 1.1rem;
|
||||
--sidebar: #f9faf9;
|
||||
/* Match the page background so the sidebar reads as one surface with the
|
||||
content; the faint --sidebar-border on its right edge is the separator. */
|
||||
--sidebar: oklch(1 0 0);
|
||||
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--sidebar-primary: #17b88b;
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.0279 166.55);
|
||||
--sidebar-accent-foreground: oklch(0.2868 0.0649 159.9823);
|
||||
--sidebar-border: oklch(0.945 0.0101 164.8536);
|
||||
--sidebar-border: oklch(0.92 0 0);
|
||||
--sidebar-ring: #17b88b;
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
|
|
@ -120,7 +167,7 @@
|
|||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 8px 10px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
|
||||
--tracking-normal: -0.01em;
|
||||
--tracking-normal: 0em;
|
||||
|
||||
/* Hex (not OKLCH) so the rendered surface matches the design mockup pixel-for-pixel. */
|
||||
--nav-fg: #383835;
|
||||
|
|
@ -201,7 +248,8 @@
|
|||
--chart-3: oklch(0.7554 0.1285 197.339);
|
||||
--chart-4: oklch(0.7503 0.1199 346.7805);
|
||||
--chart-5: oklch(0.799 0.1196 84.6633);
|
||||
--sidebar: #18181a;
|
||||
/* Match the page background (dark); --sidebar-border is the right-edge separator. */
|
||||
--sidebar: #1f2023;
|
||||
--sidebar-foreground: #ececee;
|
||||
--sidebar-primary: #17b88b;
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
|
|
@ -210,7 +258,8 @@
|
|||
--sidebar-border: #2d2d2f;
|
||||
--sidebar-ring: #17b88b;
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--radius: 0.625rem;
|
||||
/* Match light's radius so every rounded-* element is the same in both themes. */
|
||||
--radius: 1.1rem;
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
|
|
@ -305,8 +354,8 @@
|
|||
--font-mono: JetBrains Mono, monospace;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--radius: 1.1rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-tighter: 0em;
|
||||
--tracking-tight: 0em;
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
|
|
@ -438,7 +487,7 @@
|
|||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -450,15 +499,14 @@
|
|||
on global antialiased font-smoothing to neutralize the bloom. */
|
||||
.font-heading {
|
||||
font-family: var(--font-heading);
|
||||
letter-spacing: -0.01em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Dark mode loosens tracking to offset optical bloom on dark surfaces. */
|
||||
.tracking-nav {
|
||||
letter-spacing: 0.015em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.dark .tracking-nav {
|
||||
letter-spacing: 0.03em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.nav-icon-btn {
|
||||
|
|
@ -485,6 +533,10 @@
|
|||
.sidebar-nav-btn:hover,
|
||||
.sidebar-nav-btn[data-active="true"],
|
||||
.sidebar-nav-btn[data-state="open"],
|
||||
.group\/project-item:hover .sidebar-nav-btn,
|
||||
.group\/project-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
|
||||
.group\/project-chat-item:hover .sidebar-nav-btn,
|
||||
.group\/project-chat-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
|
||||
.group\/recent-item:hover .sidebar-nav-btn,
|
||||
.group\/recent-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
|
||||
.group\/run-item:hover .sidebar-nav-btn,
|
||||
|
|
@ -495,6 +547,10 @@
|
|||
.dark .sidebar-nav-btn:hover,
|
||||
.dark .sidebar-nav-btn[data-active="true"],
|
||||
.dark .sidebar-nav-btn[data-state="open"],
|
||||
.dark .group\/project-item:hover .sidebar-nav-btn,
|
||||
.dark .group\/project-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
|
||||
.dark .group\/project-chat-item:hover .sidebar-nav-btn,
|
||||
.dark .group\/project-chat-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
|
||||
.dark .group\/recent-item:hover .sidebar-nav-btn,
|
||||
.dark .group\/recent-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
|
||||
.dark .group\/run-item:hover .sidebar-nav-btn,
|
||||
|
|
@ -503,7 +559,7 @@
|
|||
}
|
||||
|
||||
.sidebar-row-action {
|
||||
@apply absolute top-0 bottom-0 right-0 inline-flex items-center justify-end pl-2 pr-1.5 opacity-0 pointer-events-none outline-none;
|
||||
@apply absolute top-0 bottom-0 right-0 inline-flex cursor-pointer items-center justify-end pl-2 pr-1.5 opacity-0 pointer-events-none outline-none;
|
||||
}
|
||||
.sidebar-row-action[data-state="open"] {
|
||||
@apply opacity-100 pointer-events-auto;
|
||||
|
|
@ -529,12 +585,22 @@
|
|||
}
|
||||
|
||||
.sidebar-sticky-label {
|
||||
@apply sticky top-0 z-20 rounded-none bg-sidebar pt-0 pb-1.5 pl-[18px] pr-4 text-[13px]! font-medium normal-case tracking-[0.04em] text-nav-fg-muted focus-visible:ring-0! focus-visible:outline-none shadow-[0_-8px_0_0_var(--sidebar)] transition-shadow duration-150;
|
||||
@apply rounded-none bg-sidebar pt-0 pb-[8px] pl-[18px] pr-4 text-[14px]! leading-[17px] font-medium normal-case focus-visible:ring-0! focus-visible:outline-none transition-shadow duration-150;
|
||||
/* Muted section-header gray, matching Gemini's "Notebooks"/"Recents".
|
||||
Lightened from #5f6368 so the label reads as a header, clearly
|
||||
lighter than the near-black nav items. */
|
||||
color: #80868b;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.sidebar-sticky-label.is-scrolled {
|
||||
@apply shadow-[0_-8px_0_0_var(--sidebar),0_0.5px_0_0_var(--sidebar-border)];
|
||||
.sidebar-sticky-label-following {
|
||||
@apply pt-[21px];
|
||||
}
|
||||
.dark .sidebar-sticky-label {
|
||||
/* Muted gray, clearly dimmer than the near-white nav items (#ececee) so
|
||||
"Train"/"Recents" read as section headers — like Gemini's dark mode. */
|
||||
color: #9aa0a6;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Neutral panel input surface — sidesteps the green cast on
|
||||
`--input` / `--border` (both have a small chroma at hue ~165 in
|
||||
light mode). Same value drives the preset input pill, the system
|
||||
|
|
@ -697,36 +763,47 @@
|
|||
border-color: rgb(255 255 255 / 0.12) !important;
|
||||
}
|
||||
|
||||
.app-user-menu [data-slot="dropdown-menu-item"] {
|
||||
height: 32px;
|
||||
padding: 0 0.625rem !important;
|
||||
gap: 8.5px !important;
|
||||
border-radius: 10px;
|
||||
.app-user-menu [data-slot="dropdown-menu-item"],
|
||||
.app-user-menu [data-slot="dropdown-menu-sub-trigger"] {
|
||||
height: 36px;
|
||||
padding: 0 0.75rem !important;
|
||||
gap: 9.5px !important;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
font-size: 14.5px;
|
||||
line-height: 19px;
|
||||
letter-spacing: 0.015em;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0;
|
||||
color: var(--nav-fg);
|
||||
}
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-item"] {
|
||||
letter-spacing: 0.03em;
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-item"],
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-sub-trigger"] {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"] svg {
|
||||
.app-user-menu [data-slot="dropdown-menu-item"] svg,
|
||||
.app-user-menu [data-slot="dropdown-menu-sub-trigger"] svg {
|
||||
width: 19px !important;
|
||||
height: 19px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"]:focus {
|
||||
.app-user-menu [data-slot="dropdown-menu-item"]:focus,
|
||||
.app-user-menu [data-slot="dropdown-menu-sub-trigger"]:focus,
|
||||
.app-user-menu [data-slot="dropdown-menu-sub-trigger"][data-state="open"] {
|
||||
background-color: var(--nav-surface-hover);
|
||||
color: #000;
|
||||
}
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus {
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus,
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-sub-trigger"]:focus,
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-sub-trigger"][data-state="open"] {
|
||||
color: #fff;
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"]:focus * {
|
||||
.app-user-menu [data-slot="dropdown-menu-item"]:focus *,
|
||||
.app-user-menu [data-slot="dropdown-menu-sub-trigger"]:focus *,
|
||||
.app-user-menu [data-slot="dropdown-menu-sub-trigger"][data-state="open"] * {
|
||||
color: #000 !important;
|
||||
}
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus * {
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus *,
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-sub-trigger"]:focus *,
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-sub-trigger"][data-state="open"] * {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
|
|
@ -759,6 +836,59 @@
|
|||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Settings dialog: no shadow in dark mode. */
|
||||
.dark .settings-surface.shadow-border {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* App-wide: every clickable control shows the hand cursor. Disabled ones
|
||||
(native :disabled, aria-disabled, Radix data-disabled) keep the default. */
|
||||
button:not(:disabled):not([aria-disabled="true"]),
|
||||
summary,
|
||||
label[for],
|
||||
select:not(:disabled),
|
||||
a[href],
|
||||
:is(
|
||||
[role="button"],
|
||||
[role="tab"],
|
||||
[role="switch"],
|
||||
[role="radio"],
|
||||
[role="checkbox"],
|
||||
[role="menuitem"],
|
||||
[role="menuitemcheckbox"],
|
||||
[role="menuitemradio"],
|
||||
[role="option"],
|
||||
[role="combobox"],
|
||||
[role="link"]
|
||||
):not([aria-disabled="true"]):not([data-disabled]):not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Model selector: pointer cursor on every clickable element. */
|
||||
.unsloth-model-selector-trigger,
|
||||
.unsloth-model-selector-menu button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unsloth-model-selector-trigger:disabled,
|
||||
.unsloth-model-selector-menu button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Chat search box: borderless, soft Gemini-style elevation. */
|
||||
.chat-search-surface {
|
||||
border: none;
|
||||
/* Pin to the dark --radius so the corners are the same (less round) in
|
||||
both themes; rounded-4xl here and on the inner Command follow this. */
|
||||
--radius: 0.625rem;
|
||||
/* Match the chat box: soft elevation in light, none in dark. */
|
||||
box-shadow: 0 2px 8px -2px rgba(27, 27, 31, 0.16);
|
||||
}
|
||||
|
||||
.dark .chat-search-surface {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.menu-soft-surface,
|
||||
.menu-soft-surface-up {
|
||||
--menu-soft-edge: rgba(0, 0, 0, 0.14);
|
||||
|
|
@ -782,31 +912,125 @@
|
|||
--menu-soft-shadow: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
/* Model selector: drop the inset edge ring, keep the soft drop shadow.
|
||||
Pin --radius to the light value so the corners match in both themes. */
|
||||
.unsloth-model-selector-menu.menu-soft-surface {
|
||||
--radius: 1.1rem;
|
||||
box-shadow: 0 var(--menu-soft-offset-y) var(--menu-soft-blur)
|
||||
var(--menu-soft-spread) var(--menu-soft-shadow);
|
||||
}
|
||||
|
||||
.dark .unsloth-model-selector-menu.menu-soft-surface {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Every dropdown/menu/select/popover: borderless, chatbox shadow in light,
|
||||
none in dark. !important so it also overrides the bespoke menu shadows. */
|
||||
[data-slot="dropdown-menu-content"],
|
||||
[data-slot="dropdown-menu-sub-content"],
|
||||
[data-slot="select-content"],
|
||||
[data-slot="combobox-content"],
|
||||
[data-slot="popover-content"] {
|
||||
box-shadow: 0 2px 8px -2px rgba(27, 27, 31, 0.16) !important;
|
||||
}
|
||||
/* Dark: keep the chat-box dropdown shadow so menus don't merge into a
|
||||
same-color surface (e.g. a menu overlapping the composer). */
|
||||
.dark [data-slot="dropdown-menu-content"],
|
||||
.dark [data-slot="dropdown-menu-sub-content"],
|
||||
.dark [data-slot="select-content"],
|
||||
.dark [data-slot="combobox-content"],
|
||||
.dark [data-slot="popover-content"] {
|
||||
box-shadow: 0 4px 14px var(--background) !important;
|
||||
}
|
||||
|
||||
/* Context menus (chat, run, project): drop the inset edge ring, keep the soft shadow. */
|
||||
.app-user-menu.menu-soft-surface {
|
||||
box-shadow: 0 var(--menu-soft-offset-y) var(--menu-soft-blur)
|
||||
var(--menu-soft-spread) var(--menu-soft-shadow);
|
||||
}
|
||||
|
||||
/* Account menu: drop the inset edge ring and use the composer's soft shadow. */
|
||||
.app-user-menu.menu-soft-surface-up {
|
||||
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
.dark .app-user-menu.menu-soft-surface-up {
|
||||
box-shadow: 0 8px 28px -6px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.chat-composer-surface {
|
||||
@apply relative flex w-full flex-col rounded-[24px] bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow;
|
||||
@apply relative flex w-full flex-col rounded-[32px] bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow;
|
||||
font-family: var(--font-sans);
|
||||
border: 1px solid oklch(0.93 0 0 / 1);
|
||||
background-clip: padding-box;
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0 0 0 / 0.04),
|
||||
0 6px 14px oklch(0 0 0 / 0.05),
|
||||
0 18px 40px oklch(0 0 0 / 0.05);
|
||||
box-shadow: 0 2px 8px -2px rgba(27, 27, 31, 0.16);
|
||||
}
|
||||
|
||||
.dark .chat-composer-surface {
|
||||
border-color: #34363a;
|
||||
box-shadow: 0 -6px 36px -14px rgba(0, 0, 0, 0.15);
|
||||
background-color: #2a2a2c;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.composer-pill-btn {
|
||||
@apply flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40;
|
||||
@apply flex cursor-pointer items-center gap-1.5 rounded-full py-1.5 pl-2 pr-2.5 text-[14px] font-medium text-muted-foreground/70 transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
|
||||
/* Icon-only (compact) pills drop their label, so make the button a square
|
||||
and center the glyph; the rounded-full hover highlight then reads as a
|
||||
circle around the icon rather than a wide rounded rectangle. */
|
||||
[data-pill-compact="true"] .composer-pill-btn:not([data-keep-label]) {
|
||||
@apply size-8 justify-center px-0;
|
||||
}
|
||||
|
||||
.composer-pill-btn[data-active="true"] {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* With more than 4 tools on, drop pill labels to icons only to cut clutter.
|
||||
Compare keeps its label via data-keep-label. */
|
||||
[data-pill-compact="true"]
|
||||
.composer-pill-btn:not([data-keep-label])
|
||||
> span:not(.composer-pill-glyph) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Drop the MCP dropdown caret when collapsed so the icon is not squished. */
|
||||
[data-pill-compact="true"]
|
||||
.composer-pill-btn:not([data-keep-label])
|
||||
.composer-pill-caret {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Fixed-width icon slot so every pill's icon occupies the same space and
|
||||
the labels line up on an even rhythm, regardless of icon size. */
|
||||
.composer-pill-glyph {
|
||||
@apply relative inline-flex w-[19px] shrink-0 items-center justify-center transition-opacity;
|
||||
}
|
||||
|
||||
/* On hover the icon swaps for an X inside a soft circle (ChatGPT-style),
|
||||
filling the icon slot so every pill's X is identical and centered. */
|
||||
.composer-pill-x {
|
||||
@apply pointer-events-none absolute inset-0 m-auto size-[19px] rounded-full bg-primary/15 p-[3px] opacity-0 transition-opacity dark:bg-white/[0.14];
|
||||
}
|
||||
|
||||
/* Icon-only (compact) pills are too small for the circle, so show a bare x. */
|
||||
[data-pill-compact="true"]
|
||||
.composer-pill-btn:not([data-keep-label])
|
||||
.composer-pill-x {
|
||||
@apply size-[15px] bg-transparent p-0 dark:bg-transparent;
|
||||
}
|
||||
|
||||
/* Hovering an active pill swaps the icon for an X (click to turn off). */
|
||||
.composer-pill-btn[data-active="true"]:hover .composer-pill-glyph > :not(.composer-pill-x),
|
||||
.unsloth-thinking-pill[data-active="true"]:hover .composer-pill-glyph > :not(.composer-pill-x) {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.composer-pill-btn[data-active="true"]:hover .composer-pill-x,
|
||||
.unsloth-thinking-pill[data-active="true"]:hover .composer-pill-x {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.composer-input {
|
||||
@apply mt-2 mb-1 mx-3 min-h-12 w-[calc(100%-1.5rem)] resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0;
|
||||
@apply mt-2 mb-1 mx-3 min-h-12 w-[calc(100%-1.5rem)] resize-none overflow-y-auto bg-transparent pl-2 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0;
|
||||
}
|
||||
|
||||
.composer-action-wrapper {
|
||||
|
|
@ -814,10 +1038,266 @@
|
|||
}
|
||||
|
||||
.composer-footer-note {
|
||||
@apply mt-1.5 text-center text-[11px] tracking-[0.04em] text-muted-foreground;
|
||||
@apply mt-1.5 text-center text-[11px] tracking-[0em] text-muted-foreground;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Pill composer; own classes so compare-mode keeps its stacked layout. */
|
||||
.unsloth-composer-surface {
|
||||
@apply relative flex w-full flex-col rounded-[32px] bg-background dark:bg-card px-3 py-3 outline-none transition-shadow;
|
||||
font-family: var(--font-sans);
|
||||
background-clip: padding-box;
|
||||
/* Gemini search pill: soft, centered shadow, no downward offset. */
|
||||
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
|
||||
.unsloth-composer-surface:focus-within {
|
||||
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
|
||||
.dark .unsloth-composer-surface {
|
||||
background-color: #2a2a2c;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Keep the expand/collapse width swap instant. A transition on width (e.g.
|
||||
the reduced-motion blanket rule) makes getComputedStyle().width lag a
|
||||
frame, so autosize measures the stale width and leaves a stray blank row. */
|
||||
.unsloth-composer-line,
|
||||
.unsloth-composer-line .unsloth-composer-input,
|
||||
.unsloth-composer-left {
|
||||
transition-property: none !important;
|
||||
}
|
||||
|
||||
/* Composer row: one centered line when empty; two rows (input over controls)
|
||||
when filled, so the textarea never remounts. */
|
||||
.unsloth-composer-line {
|
||||
@apply flex w-full flex-wrap items-center gap-0.5 px-1;
|
||||
}
|
||||
|
||||
.unsloth-composer-left {
|
||||
@apply flex shrink-0 items-center gap-0.5;
|
||||
order: 1;
|
||||
/* Pull the plus button closer to the composer edge. */
|
||||
margin-left: -0.25rem;
|
||||
}
|
||||
|
||||
.unsloth-composer-line .unsloth-composer-input {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.unsloth-composer-line .aui-composer-action-wrapper {
|
||||
order: 3;
|
||||
margin-left: auto;
|
||||
/* Inset the send circle from the edge, Gemini-style. */
|
||||
margin-right: -0.125rem;
|
||||
}
|
||||
|
||||
.unsloth-composer-line[data-expanded="true"] .unsloth-composer-input {
|
||||
order: 1;
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
/* Sits close to the left edge, near the plus. */
|
||||
padding-left: 0.375rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Two-row gap between text and controls. On the line, not the input, so
|
||||
the placeholder max-height clamp never crops it like padding would. */
|
||||
.unsloth-composer-line[data-expanded="true"] {
|
||||
row-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.unsloth-composer-line[data-expanded="true"] .unsloth-composer-left {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
/* Empty (placeholder shown): clamp back to one row. max-height beats the
|
||||
autosize textarea's inline !important height, so a cleared message leaves
|
||||
no stale tall box or stray scrollbar. */
|
||||
.unsloth-composer-input:placeholder-shown {
|
||||
max-height: 40px !important;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
.unsloth-composer-input {
|
||||
@apply min-h-[40px] min-w-0 flex-1 resize-none overflow-y-auto bg-transparent pl-0.5 pr-2 py-2 text-[15px] font-[450] leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0;
|
||||
}
|
||||
|
||||
.unsloth-composer-plus {
|
||||
@apply flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-foreground transition-colors hover:bg-muted-foreground/15 disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
|
||||
.unsloth-composer-plus[data-state="open"] {
|
||||
@apply bg-muted-foreground/15;
|
||||
/* Radix's modal menu sets body pointer-events:none; re-enable on the open
|
||||
trigger so the cursor and click-to-close work. */
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Open menu rotates the plus 45deg into an x. Ease-in-out for a smooth
|
||||
spin; keep it at 45deg so it never passes back through a "+". */
|
||||
.unsloth-composer-plus svg {
|
||||
transition: transform 300ms cubic-bezier(0.65, 0, 0.35, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.unsloth-composer-plus[data-state="open"] svg {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Keyboard focus shows a subtle brand ring, not the browser's blue outline;
|
||||
mouse clicks (no :focus-visible) stay ring-free. */
|
||||
.composer-pill-btn:focus-visible,
|
||||
.unsloth-thinking-pill:focus-visible,
|
||||
.unsloth-composer-plus:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 45%, transparent);
|
||||
}
|
||||
|
||||
/* Set Hellix explicitly; .aui-thread-root resets --font-heading to sans. */
|
||||
.unsloth-welcome-title {
|
||||
font-family: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Right-side Thinking pill (toggle or dropdown). pl-2 matches the left
|
||||
pills so the hover X is not pushed in too far. */
|
||||
.unsloth-thinking-pill {
|
||||
@apply inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-full py-1.5 pl-2 pr-2.5 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40;
|
||||
/* Reserve a text line so the icon-only (inactive) pill matches the
|
||||
text pills' height instead of collapsing to the icon. */
|
||||
min-height: calc(1lh + 0.75rem);
|
||||
}
|
||||
|
||||
.unsloth-thinking-pill[data-active="true"] {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.unsloth-thinking-pill[data-state="open"] {
|
||||
@apply bg-muted-foreground/10;
|
||||
/* See .unsloth-composer-plus: re-enable pointer-events on the open trigger. */
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Smaller tick for selected Thinking options. */
|
||||
.unsloth-tick {
|
||||
width: 0.8rem !important;
|
||||
height: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* Soft elevation; [data-slot] outranks the component ring-1, dropping the border. */
|
||||
.unsloth-plus-menu[data-slot] {
|
||||
/* Pin radius so dark matches light (rounded-lg resolves smaller in dark). */
|
||||
border-radius: 18px;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0 0 0 / 0.04),
|
||||
0 6px 18px oklch(0 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* MCP menu is wider (232px), so nudge the radius up so it reads as round as
|
||||
the narrower + menu (18 * 232/212 ~= 20px). */
|
||||
.unsloth-plus-menu.mcp-menu[data-slot] {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.dark .unsloth-plus-menu[data-slot] {
|
||||
background-color: #2a2a2c;
|
||||
/* Shadow tinted to the page bg so it blends, not a dark halo. */
|
||||
box-shadow: 0 4px 14px var(--background);
|
||||
}
|
||||
|
||||
/* Compact items; also applied to portaled sub-content. */
|
||||
.unsloth-plus-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
) {
|
||||
@apply gap-3 pl-4 pr-3 py-2 text-[14px];
|
||||
cursor: pointer;
|
||||
/* Pin hover-box radius so dark matches light (same as the container). */
|
||||
border-radius: 1.1rem;
|
||||
}
|
||||
|
||||
.unsloth-plus-menu [data-slot="dropdown-menu-label"] {
|
||||
@apply pl-4 pr-3 py-1.5 text-[12px];
|
||||
}
|
||||
|
||||
/* Active (green) items keep their primary text and icon color on hover. */
|
||||
.unsloth-plus-menu [data-slot="dropdown-menu-item"].text-primary:is(:hover, :focus, :focus-visible, [data-highlighted]),
|
||||
.unsloth-plus-menu [data-slot="dropdown-menu-item"].text-primary:is(:hover, :focus, :focus-visible, [data-highlighted]) svg {
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* Light hover: neutral grey, not the green accent. */
|
||||
.unsloth-plus-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
):is(:hover, :focus, :focus-visible, [data-highlighted], [data-state="open"]) {
|
||||
background-color: rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
|
||||
/* Dark hover: the accent nearly matches the surface, so use a clear overlay. */
|
||||
.dark .unsloth-plus-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
):is(:hover, :focus, :focus-visible, [data-highlighted], [data-state="open"]) {
|
||||
background-color: rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
/* Hover tints only the background; the item and every descendant (text, icons,
|
||||
labels) keep their resting color, overriding Radix's focus:**:text-accent-
|
||||
foreground. The active green rule above still wins via higher specificity. */
|
||||
.unsloth-plus-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
):is(:hover, :focus, :focus-visible, [data-highlighted], [data-state="open"]),
|
||||
.unsloth-plus-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
):is(:hover, :focus, :focus-visible, [data-highlighted], [data-state="open"]) * {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.unsloth-plus-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
)
|
||||
svg {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
}
|
||||
|
||||
/* Destructive items keep red text and a red-tinted hover, not the grey one. */
|
||||
.unsloth-plus-menu [data-slot="dropdown-menu-item"][data-variant="destructive"],
|
||||
.unsloth-plus-menu [data-slot="dropdown-menu-item"][data-variant="destructive"] * {
|
||||
color: var(--destructive) !important;
|
||||
}
|
||||
.unsloth-plus-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:is(:hover, :focus, :focus-visible, [data-highlighted]) {
|
||||
background-color: color-mix(in oklab, var(--destructive) 10%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Thinking menu: tighter gap; nowrap keeps "Preserve thinking" on one line
|
||||
so the menu sizes to its content. */
|
||||
.unsloth-thinking-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
) {
|
||||
@apply gap-1.5 pl-2.5 pr-2.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.artifact-panel-shell {
|
||||
box-shadow: 0 2px 8px -2px rgba(27, 27, 31, 0.16);
|
||||
}
|
||||
|
||||
.dark .artifact-panel-shell {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.chat-artifact-split[data-artifact-layout-animating="true"] > [data-panel] {
|
||||
transition:
|
||||
flex-basis 260ms var(--ease-out-cubic),
|
||||
|
|
@ -1139,15 +1619,49 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Lighter shadow + tighter vertical padding than Sonner's defaults; !important because Sonner injects its base rules at runtime. */
|
||||
/* No border line; same drop shadow as the composer (.unsloth-composer-surface).
|
||||
!important because Sonner injects its base rules at runtime. */
|
||||
[data-sonner-toast][data-styled='true'] {
|
||||
padding: 10px 16px !important;
|
||||
padding: 12px 18px !important;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-styled='true']:has([data-close-button]):not(:has([data-cancel])) {
|
||||
padding-right: 48px !important;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-styled='true']:has([data-cancel]):has([data-close-button]) {
|
||||
padding-right: 88px !important;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-styled='true'].chat-model-load-toast,
|
||||
[data-sonner-toast][data-styled='true'].chat-model-loaded-toast {
|
||||
padding-top: 14px !important;
|
||||
padding-bottom: 14px !important;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-styled='true'].chat-model-loaded-toast [data-close-button] {
|
||||
top: calc(50% - 0.25px) !important;
|
||||
transform: translateY(-50%) !important;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-styled='true'].chat-model-load-toast:not(:has([data-cancel])) [data-close-button] {
|
||||
top: calc(50% - 0.25px) !important;
|
||||
transform: translateY(-50%) !important;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-styled="true"]:has([data-cancel]) [data-cancel] {
|
||||
position: absolute !important;
|
||||
right: 36px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Boost shadow on dark surfaces; mirrors .shadow-border / .menu-soft-surface pattern. */
|
||||
.dark [data-sonner-toast][data-styled='true'] {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3) !important;
|
||||
background-color: #2a2a2c !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Selectable toast text; non-selectable toast buttons. */
|
||||
|
|
@ -1233,6 +1747,86 @@
|
|||
background: oklch(0.67 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/* Composer textarea: keep the scroll thumb faint until the user hovers or
|
||||
drags the thumb itself, so a tall draft never shows a heavy dark rail. */
|
||||
.composer-input,
|
||||
.unsloth-composer-input {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.16) transparent;
|
||||
}
|
||||
|
||||
.composer-input::-webkit-scrollbar-thumb,
|
||||
.unsloth-composer-input::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 0.16);
|
||||
}
|
||||
|
||||
.composer-input::-webkit-scrollbar-thumb:hover,
|
||||
.unsloth-composer-input::-webkit-scrollbar-thumb:hover,
|
||||
.composer-input::-webkit-scrollbar-thumb:active,
|
||||
.unsloth-composer-input::-webkit-scrollbar-thumb:active {
|
||||
background: oklch(0.5 0 0 / 0.45);
|
||||
}
|
||||
|
||||
.dark .composer-input,
|
||||
.dark .unsloth-composer-input {
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.18) transparent;
|
||||
}
|
||||
|
||||
.dark .composer-input::-webkit-scrollbar-thumb,
|
||||
.dark .unsloth-composer-input::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.72 0 0 / 0.18);
|
||||
}
|
||||
|
||||
.dark .composer-input::-webkit-scrollbar-thumb:hover,
|
||||
.dark .unsloth-composer-input::-webkit-scrollbar-thumb:hover,
|
||||
.dark .composer-input::-webkit-scrollbar-thumb:active,
|
||||
.dark .unsloth-composer-input::-webkit-scrollbar-thumb:active {
|
||||
background: oklch(0.72 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/* Sidebars, settings and search: hide the scroll thumb at rest and reveal it
|
||||
only while the area is hovered, so the rail never sits visible over content. */
|
||||
.sidebar-scroll-fade,
|
||||
.run-settings-scroll,
|
||||
.hover-scrollbar {
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.sidebar-scroll-fade::-webkit-scrollbar-thumb,
|
||||
.run-settings-scroll::-webkit-scrollbar-thumb,
|
||||
.hover-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-scroll-fade:hover,
|
||||
.run-settings-scroll:hover,
|
||||
.hover-scrollbar:hover {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.4) transparent;
|
||||
}
|
||||
|
||||
.sidebar-scroll-fade:hover::-webkit-scrollbar-thumb,
|
||||
.run-settings-scroll:hover::-webkit-scrollbar-thumb,
|
||||
.hover-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 0.4);
|
||||
}
|
||||
|
||||
.dark .sidebar-scroll-fade:hover,
|
||||
.dark .run-settings-scroll:hover,
|
||||
.dark .hover-scrollbar:hover {
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.4) transparent;
|
||||
}
|
||||
|
||||
.dark .sidebar-scroll-fade:hover::-webkit-scrollbar-thumb,
|
||||
.dark .run-settings-scroll:hover::-webkit-scrollbar-thumb,
|
||||
.dark .hover-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.72 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* Run settings: always reserve the scrollbar gutter so the header close
|
||||
button keeps its position whether or not the scrollbar is showing. */
|
||||
.run-settings-scroll {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Chat viewport: solid track matching sidebar so the scrollbar reads as a
|
||||
full-height rail flush to the right edge, without a separate decorative strip. */
|
||||
.aui-thread-viewport {
|
||||
|
|
@ -1277,24 +1871,16 @@
|
|||
background: #23252a;
|
||||
}
|
||||
|
||||
[data-sidebar="content"] {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.22) var(--sidebar);
|
||||
}
|
||||
|
||||
.dark [data-sidebar="content"] {
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.25) var(--sidebar);
|
||||
/* Search list: re-enable the native scrollbar (CommandList defaults to no-scrollbar). */
|
||||
.cmd-native-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
-ms-overflow-style: auto;
|
||||
}
|
||||
|
||||
[data-sidebar="content"]::-webkit-scrollbar-track {
|
||||
background: var(--sidebar);
|
||||
}
|
||||
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 0.22);
|
||||
}
|
||||
|
||||
.dark [data-sidebar="content"]::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.72 0 0 / 0.25);
|
||||
.cmd-native-scrollbar::-webkit-scrollbar {
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/*---break---*/
|
||||
|
|
@ -1315,20 +1901,27 @@
|
|||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
/* Override sonner top: 0 and pin to theme tokens (--gray2 hover ignores data-sonner-theme). */
|
||||
/* Keep Sonner close button inside the toast and pin to theme tokens (--gray2 hover ignores data-sonner-theme). */
|
||||
[data-sonner-toast][data-styled="true"] [data-close-button] {
|
||||
top: 8px !important;
|
||||
transform: none !important;
|
||||
background: var(--popover) !important;
|
||||
color: var(--popover-foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Keep the (borderless) close button blended with the dark toast surface. */
|
||||
.dark [data-sonner-toast][data-styled="true"] [data-close-button] {
|
||||
background: #2a2a2c !important;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-styled="true"] [data-close-button] svg {
|
||||
stroke-width: 2.25;
|
||||
}
|
||||
[data-sonner-toast][data-styled="true"]:hover [data-close-button]:hover {
|
||||
background: var(--muted) !important;
|
||||
color: var(--popover-foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
.generated-image-loading-card {
|
||||
|
|
@ -1412,4 +2005,10 @@
|
|||
animation-duration: 1850ms !important;
|
||||
animation-iteration-count: infinite !important;
|
||||
}
|
||||
|
||||
/* Keep the plus/x morph animating under reduced motion (small rotation,
|
||||
like the spinners above). */
|
||||
.unsloth-composer-plus svg {
|
||||
transition-duration: 300ms !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
studio/frontend/src/speech-recognition.d.ts
vendored
9
studio/frontend/src/speech-recognition.d.ts
vendored
|
|
@ -28,6 +28,11 @@ interface SpeechRecognitionEvent extends Event {
|
|||
readonly results: SpeechRecognitionResultList;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
readonly error: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
|
|
@ -35,7 +40,7 @@ interface SpeechRecognition extends EventTarget {
|
|||
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
||||
onerror: ((event: Event) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
start(): void;
|
||||
start(audioTrack?: MediaStreamTrack): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
|
@ -49,4 +54,4 @@ interface Window {
|
|||
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
||||
}
|
||||
|
||||
declare var SpeechRecognition: SpeechRecognitionConstructor | undefined;
|
||||
declare const SpeechRecognition: SpeechRecognitionConstructor | undefined;
|
||||
|
|
|
|||
|
|
@ -1406,7 +1406,7 @@ def install_python_stack() -> int:
|
|||
# When called from "unsloth studio update", it is NOT set so base packages
|
||||
# (unsloth + unsloth-zoo) are always reinstalled to pick up new versions.
|
||||
skip_base = os.environ.get("SKIP_STUDIO_BASE", "0") == "1"
|
||||
# When --package is used, install a different package name (e.g. roland-sloth for testing)
|
||||
# When --package is used, install a different package name (for testing)
|
||||
package_name = os.environ.get("STUDIO_PACKAGE_NAME", "unsloth")
|
||||
# When --local is used, overlay a local repo checkout after updating deps
|
||||
local_repo = os.environ.get("STUDIO_LOCAL_REPO", "")
|
||||
|
|
@ -1565,7 +1565,7 @@ def install_python_stack() -> int:
|
|||
constrain = False,
|
||||
)
|
||||
elif package_name != "unsloth":
|
||||
# Custom package name (e.g. roland-sloth for testing) — install directly
|
||||
# Custom package name (for testing), install directly
|
||||
_progress("base packages")
|
||||
pip_install(
|
||||
f"Installing {package_name}",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue