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

# Conflicts:
#	studio/backend/tests/test_desktop_auth.py
#	studio/frontend/package-lock.json
This commit is contained in:
Roland Tannous 2026-06-03 10:43:15 +04:00
commit c267c3772c
33 changed files with 1271 additions and 253 deletions

View file

@ -244,6 +244,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 (
@ -524,11 +525,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",
@ -536,17 +541,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")
@ -566,10 +604,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":
@ -583,6 +643,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":
@ -591,8 +652,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 = []
@ -608,8 +681,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):
@ -633,8 +706,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,
)
@ -689,6 +764,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"])

View file

@ -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}"

View file

@ -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)

View 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)

View file

@ -280,6 +280,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 (
@ -1517,6 +1526,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:

View 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()) == []

View file

@ -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,22 +429,32 @@ 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(),
rag_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(),
"rag_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

View file

@ -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

View file

@ -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:

View file

@ -0,0 +1,97 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""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

View file

@ -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",
"@types/event-source-polyfill": "1.0.5",
"@xyflow/react": "^12.10.0",
@ -6742,6 +6743,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/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@ -14336,9 +14346,9 @@
}
},
"node_modules/react-is": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
"integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
"license": "MIT",
"peer": true
},

View file

@ -48,6 +48,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",
"@types/event-source-polyfill": "1.0.5",
"@xyflow/react": "^12.10.0",

View file

@ -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> {

View file

@ -382,11 +382,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]} />;
}

View file

@ -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 }}

View file

@ -66,13 +66,12 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--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.

View file

@ -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>
);
}

View file

@ -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,14 +245,12 @@ export function useChatModelRuntime() {
message: string,
progressPercent?: number | null,
progressLabel?: string | null,
onStop?: () => void,
) =>
createElement(ModelLoadDescription, {
title,
message,
progressPercent,
progressLabel,
onStop,
}),
[],
);
@ -471,10 +481,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;
@ -799,25 +810,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;
@ -924,19 +942,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 &&
@ -963,19 +976,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({
@ -1025,19 +1033,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.
@ -1059,12 +1062,20 @@ export function useChatModelRuntime() {
try {
await performLoad();
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({
@ -1083,7 +1094,11 @@ export function useChatModelRuntime() {
toast.error(message, {
id: toastId,
description: undefined,
cancel: undefined,
classNames: undefined,
closeButton: true,
duration: 8000,
onDismiss: undefined,
});
}
notifyNative({

View file

@ -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`,

View 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">

View file

@ -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>
)}

View file

@ -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";

View 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()));
}

View file

@ -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

View file

@ -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>
);

View file

@ -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 {

View file

@ -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",
@ -112,15 +113,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",
},
},
@ -185,8 +192,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.",
@ -210,8 +216,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:
@ -224,7 +229,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",
@ -262,10 +268,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: {
@ -362,7 +368,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...",
@ -411,8 +418,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",
@ -472,7 +478,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",
@ -488,11 +495,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",
@ -530,7 +539,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",
@ -587,7 +597,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",
@ -633,7 +644,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.",
@ -668,7 +680,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",

View file

@ -17,6 +17,7 @@ export const zhCN = {
new: "新增",
rename: "重命名",
save: "保存",
saving: "保存中...",
search: "搜索",
shutdown: "关闭服务",
},
@ -109,15 +110,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: "重置并重新加载",
},
},
@ -244,10 +251,10 @@ export const zhCN = {
actionsFor: "{name} 的操作",
copyPrefix: "复制前缀",
revokeToken: "撤销 token",
revokeTitle: "撤销访问 token \"{name}\"",
revokeTitle: '撤销访问 token "{name}"',
revokeDescription:
"使用此 token 的应用会立即失去访问权限。此操作无法撤销。",
revokeAction: "撤销 \"{name}\"",
revokeAction: '撤销 "{name}"',
revoking: "撤销中...",
},
about: {
@ -408,8 +415,7 @@ export const zhCN = {
"可选。如果未提供,将从训练数据中切分出一小部分。",
advanced: "高级",
targetFormat: "目标格式",
targetFormatTooltip:
"训练数据的格式。自动检测对大多数数据集都有效。",
targetFormatTooltip: "训练数据的格式。自动检测对大多数数据集都有效。",
auto: "自动",
rawText: "原始文本",
trainSplitStart: "训练切分起始",
@ -506,8 +512,7 @@ export const zhCN = {
weightDecayTooltip: "L2 正则化,用于防止过拟合。",
warmupSteps: "预热步数",
warmupStepsTooltip: "在训练开始时逐步提高学习率,提升稳定性。",
scheduleEpochsTooltip:
"完整遍历数据集的次数。设为 0 则按最大步数运行。",
scheduleEpochsTooltip: "完整遍历数据集的次数。设为 0 则按最大步数运行。",
saveSteps: "保存步数",
saveStepsTooltip: "每 N 步保存一次检查点。0 表示禁用。",
evalSteps: "评估步数",

View file

@ -1226,10 +1226,42 @@
/* Lighter shadow + tighter vertical padding than Sonner's defaults; !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;
@ -1441,13 +1473,15 @@
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;
}
[data-sonner-toast][data-styled="true"] [data-close-button] svg {
stroke-width: 2.25;
}

View file

@ -4751,6 +4751,21 @@ dependencies = [
"zip",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.11.0",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-runtime"
version = "2.10.1"
@ -5395,6 +5410,7 @@ dependencies = [
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tokio",
"windows 0.62.2",
"windows-sys 0.59.0",

View file

@ -29,6 +29,7 @@ tauri-plugin-clipboard-manager = "2"
tauri-plugin-dialog = "2"
rand = "0.10.0"
tauri-plugin-notification = "2.3.3"
tauri-plugin-window-state = "2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"

View file

@ -28,6 +28,7 @@
"allow": [{ "url": "https://*" }, { "url": "http://*" }, { "url": "mailto:*" }]
},
"updater:default",
"clipboard-manager:allow-write-text"
"clipboard-manager:allow-write-text",
"window-state:default"
]
}

View file

@ -23,6 +23,15 @@ use std::fs;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{Emitter, Manager};
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
#[tauri::command]
fn has_saved_window_state(app: tauri::AppHandle) -> bool {
let Ok(dir) = app.path().app_config_dir() else {
return false;
};
dir.join(app.filename()).is_file()
}
fn setup_logging() {
let mut loggers: Vec<Box<dyn SharedLogger>> = vec![];
@ -173,6 +182,12 @@ fn main() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(
tauri_plugin_window_state::Builder::new()
.with_state_flags(StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED)
.skip_initial_state("main")
.build(),
)
.manage(diagnostics::new_diagnostics_state())
.manage(install::new_install_state())
.manage(native_intents::new_native_intake_state())
@ -204,6 +219,7 @@ fn main() {
native_intents::register_artifact_path,
native_intents::reveal_path_token,
native_intents::open_path_token,
has_saved_window_state,
])
.setup(|app| {
#[cfg(any(target_os = "windows", target_os = "linux"))]