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:
commit
c267c3772c
33 changed files with 1271 additions and 253 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
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,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
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
|
||||
16
studio/frontend/package-lock.json
generated
16
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",
|
||||
"@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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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]} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "评估步数",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
16
studio/src-tauri/Cargo.lock
generated
16
studio/src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue