From 07f0eddb0b5e1833e0374f5301d132eb3688fde5 Mon Sep 17 00:00:00 2001 From: oobabooga Date: Tue, 2 Jun 2026 01:22:00 -0300 Subject: [PATCH 1/5] studio/frontend: pad Python tool code block to fix corner clipping (#5938) --- studio/frontend/src/components/assistant-ui/tool-ui-python.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx index bab735104c..ce15d3e440 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx @@ -78,7 +78,7 @@ function HighlightedCode({ code: source, language }: { code: string; language: s [source, language], ); return ( -
+
Date: Tue, 2 Jun 2026 07:40:56 +0100 Subject: [PATCH 2/5] Studio: polish model load toast styling (#5648) * fix: toast cancel and style * fix: align model load toast Cancel and dismiss on the right * fix: show short cased model name in loaded toast and removed prefix org * revert: chat load toast refactor to visual-only changes * fix: align model load toast close button * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: guard empty toast label and dedupe toast padding CSS --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Roland Tannous Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> --- studio/frontend/src/components/ui/sonner.tsx | 13 +- .../chat/components/model-load-status.tsx | 16 +-- .../chat/hooks/use-chat-model-runtime.ts | 123 ++++++++++-------- studio/frontend/src/index.css | 38 +++++- 4 files changed, 112 insertions(+), 78 deletions(-) diff --git a/studio/frontend/src/components/ui/sonner.tsx b/studio/frontend/src/components/ui/sonner.tsx index 5bd3078761..bf6d4970b0 100644 --- a/studio/frontend/src/components/ui/sonner.tsx +++ b/studio/frontend/src/components/ui/sonner.tsx @@ -65,13 +65,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. diff --git a/studio/frontend/src/features/chat/components/model-load-status.tsx b/studio/frontend/src/features/chat/components/model-load-status.tsx index 381f58fbbf..75d92f4581 100644 --- a/studio/frontend/src/features/chat/components/model-load-status.tsx +++ b/studio/frontend/src/features/chat/components/model-load-status.tsx @@ -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({
-
+
{title ?

{title}

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

{message}

) : null}
- {onStop ? ( - - ) : null}
); } diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 1cac12ab13..8771bc888a 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -56,10 +56,16 @@ type SelectedModelInput = { }; const MODEL_LOAD_TOAST_CLASSNAMES = { - toast: "items-start gap-2.5", + toast: "chat-model-load-toast items-center gap-2.5", content: "gap-0.5 flex-1 min-w-0", title: "leading-5", description: "mt-0 w-full", + cancelButton: + "!h-auto !rounded-none !border-0 !bg-transparent !px-1 !text-[11px] !font-normal !text-muted-foreground hover:!bg-transparent hover:!text-destructive focus-visible:!text-destructive", +} as const; + +const MODEL_LOADED_TOAST_CLASSNAMES = { + toast: "chat-model-loaded-toast items-center gap-2.5", } as const; const LORA_SUFFIX_RE = /_(\d{9,})$/; @@ -78,6 +84,12 @@ function stripTrailingEpoch(input: string): string { return cleaned || input; } +function shortModelLabel(idOrName: string): string { + const slash = idOrName.lastIndexOf("/"); + const label = slash >= 0 ? idOrName.slice(slash + 1) : idOrName; + return label || idOrName; +} + function describeModel(model: { is_lora?: boolean; is_vision?: boolean; @@ -233,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; @@ -794,25 +805,32 @@ export function useChatModelRuntime() { const isCachedLoad = isDownloaded || isCachedLora; const toastTitle = isCachedLoad ? "Starting model…" : "Downloading model…"; + const modelLoadToastOptions = (description: ReturnType) => ({ + description, + duration: Infinity, + closeButton: true, + cancel: { + label: "Cancel", + onClick: cancelLoading, + }, + classNames: MODEL_LOAD_TOAST_CLASSNAMES, + onDismiss: (dismissedToast: { id: string | number }) => { + if (loadToastIdRef.current !== dismissedToast.id) { + return; + } + setLoadToastDismissedState(true); + }, + }); const toastId = toast( null, - { - description: renderLoadDescription( + modelLoadToastOptions( + renderLoadDescription( toastTitle, loadingDescription, isCachedLoad ? null : 0, isCachedLoad ? null : "Preparing download", - cancelLoading, ), - duration: Infinity, - classNames: MODEL_LOAD_TOAST_CLASSNAMES, - onDismiss: (dismissedToast) => { - if (loadToastIdRef.current !== dismissedToast.id) { - return; - } - setLoadToastDismissedState(true); - }, - }, + ), ); loadToastIdRef.current = toastId; @@ -919,19 +937,14 @@ export function useChatModelRuntime() { if (loadToastDismissedRef.current) return; toast(null, { id: toastId, - description: renderLoadDescription( - "Downloading model…", - loadingDescription, - pct, - progressLabel, - cancelLoading, + ...modelLoadToastOptions( + renderLoadDescription( + "Downloading model…", + loadingDescription, + pct, + progressLabel, + ), ), - duration: Infinity, - classNames: MODEL_LOAD_TOAST_CLASSNAMES, - onDismiss: (dismissedToast) => { - if (loadToastIdRef.current !== dismissedToast.id) return; - setLoadToastDismissedState(true); - }, }); } else if ( prog.downloaded_bytes > 0 && @@ -958,19 +971,14 @@ export function useChatModelRuntime() { if (!loadToastDismissedRef.current) { toast(null, { id: toastId, - description: renderLoadDescription( - "Starting model…", - "Download complete. Loading the model into memory.", - 100, - "Download complete", - cancelLoading, + ...modelLoadToastOptions( + renderLoadDescription( + "Starting model…", + "Download complete. Loading the model into memory.", + 100, + "Download complete", + ), ), - duration: Infinity, - classNames: MODEL_LOAD_TOAST_CLASSNAMES, - onDismiss: (dismissedToast) => { - if (loadToastIdRef.current !== dismissedToast.id) return; - setLoadToastDismissedState(true); - }, }); } notifyNative({ @@ -1020,19 +1028,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. @@ -1054,12 +1057,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({ @@ -1078,7 +1089,11 @@ export function useChatModelRuntime() { toast.error(message, { id: toastId, description: undefined, + cancel: undefined, + classNames: undefined, + closeButton: true, duration: 8000, + onDismiss: undefined, }); } notifyNative({ diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index 28c4cd0948..12d63aac88 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -1225,10 +1225,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; @@ -1391,13 +1423,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; } From de21723a0daaed3268bbbe78d781802afb577831 Mon Sep 17 00:00:00 2001 From: oobabooga Date: Tue, 2 Jun 2026 08:27:04 -0300 Subject: [PATCH 3/5] Studio: optimize chat streaming by batching renders to one per animation frame (#5788) * Studio: optimize chat streaming by batching renders to one per animation frame * Fix dev-mode streaming edge case * Studio: harden streaming markdown coalescing --------- Co-authored-by: Wasim Yousef Said --- .../components/assistant-ui/markdown-text.tsx | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index 8e1d0f03b7..8cb33d0ed2 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -382,11 +382,60 @@ function StreamdownBlock(props: BlockProps) { } const AUDIO_PLAYER_RE = //; +// Coalesce markdown re-parses to one per animation frame while streaming: the +// runtime notifies on every token (hundreds/sec) and the monitor can't paint +// that fast. When not streaming we return live text rather than the throttled +// state, so the final text never lags and a reused instance (parts are keyed by +// index) shows a completed message's text immediately instead of a stale frame. +function useRafCoalescedText(text: string, isStreaming: boolean): string { + const [displayed, setDisplayed] = useState(text); + const pendingRef = useRef(text); + const rafRef = useRef(null); + + useEffect(() => { + pendingRef.current = text; + if (!isStreaming) { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + return; + } + if (rafRef.current === null) { + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + setDisplayed(pendingRef.current); + }); + } + }, [text, isStreaming]); + + // Unmount cleanup. Cancel the in-flight rAF and null the handle so a + // StrictMode remount isn't gated out by a stale id. Kept separate from the + // scheduling effect so it doesn't cancel mid-stream and defeat the throttle. + useEffect(() => { + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, []); + + if (isStreaming && text.startsWith(displayed)) { + return displayed; + } + return text; +} + const MarkdownTextImpl = () => { const { text, status } = useMessagePartText(); - const processedText = useMemo(() => preprocessLaTeX(text), [text]); + const displayText = useRafCoalescedText(text, status.type === "running"); + const processedText = useMemo( + () => preprocessLaTeX(displayText), + [displayText], + ); - const audioMatch = text.match(AUDIO_PLAYER_RE); + const audioMatch = displayText.match(AUDIO_PLAYER_RE); if (audioMatch) { return ; } From 7381958225d194017ddc768dd36d0d9eca44c5d3 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Tue, 2 Jun 2026 17:52:19 +0200 Subject: [PATCH 4/5] Configurable upload Cap studio (for training) (#5808) * studio: cap training dataset uploads * studio: clean up failed dataset uploads * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: raise upload limits to 500MB * studio: make upload limit configurable * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: stream upload routes * studio: split recipe upload caps * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: tighten upload limit handling * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: import settings router directly * studio: polish upload cap setting control * studio: cap settings request bodies * studio: stub settings route in desktop auth test --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/main.py | 102 ++++++++++-- studio/backend/routes/data_recipe/seed.py | 44 ++--- studio/backend/routes/datasets.py | 31 +++- studio/backend/routes/settings.py | 59 +++++++ studio/backend/storage/studio_db.py | 47 ++++++ .../tests/test_dataset_upload_limits.py | 67 ++++++++ studio/backend/tests/test_desktop_auth.py | 42 +++-- studio/backend/tests/test_middleware.py | 92 ++++++++++- studio/backend/tests/test_sandbox_tools.py | 5 +- studio/backend/utils/upload_limits.py | 97 +++++++++++ .../src/features/recipe-studio/api/index.ts | 15 +- .../dialogs/seed/seed-dialog.tsx | 13 +- .../dialogs/seed/unstructured-drop-zone.tsx | 81 ++++++--- .../dialogs/seed/upload-limits.ts | 9 + .../src/features/settings/api/upload-limit.ts | 117 +++++++++++++ .../features/settings/tabs/general-tab.tsx | 108 +++++++++++- .../studio/sections/dataset-section.tsx | 154 +++++++++++++++--- .../frontend/src/features/training/index.ts | 3 +- studio/frontend/src/i18n/locales/en.ts | 55 ++++--- studio/frontend/src/i18n/locales/zh-CN.ts | 21 ++- 20 files changed, 1015 insertions(+), 147 deletions(-) create mode 100644 studio/backend/routes/settings.py create mode 100644 studio/backend/tests/test_dataset_upload_limits.py create mode 100644 studio/backend/utils/upload_limits.py create mode 100644 studio/frontend/src/features/recipe-studio/dialogs/seed/upload-limits.ts create mode 100644 studio/frontend/src/features/settings/api/upload-limit.ts diff --git a/studio/backend/main.py b/studio/backend/main.py index 2768f56f49..d5016980ba 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -243,6 +243,7 @@ from routes import ( training_history_router, training_router, ) +from routes.settings import router as settings_router from auth import storage from auth.authentication import get_current_subject from utils.hardware import ( @@ -523,11 +524,15 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): app.add_middleware(SecurityHeadersMiddleware) -# Cap upload body on protected POSTs; default 500 MB, env-tunable. +# Cap request bodies on protected POSTs. Upload routes get explicit multipart +# headroom, while non-upload routes keep the default body cap. import json as _json_for_413 # noqa: E402 +from utils.upload_limits import ( # noqa: E402 + UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES, + default_request_body_limit_bytes, + upload_request_limit_bytes, +) - -_MAX_BODY_BYTES = int(os.environ.get("UNSLOTH_STUDIO_MAX_BODY_MB", "500")) * 1024 * 1024 _BODY_PROTECTED_PREFIXES = ( "/v1/chat/completions", "/v1/completions", @@ -535,17 +540,50 @@ _BODY_PROTECTED_PREFIXES = ( "/api/data-recipe", "/api/datasets", "/api/chat", + "/api/settings", "/api/train", "/api/export", ) +_DATASET_UPLOAD_PASSTHROUGH_PREFIX = "/api/datasets/upload" +_DATA_RECIPE_UNSTRUCTURED_UPLOAD_PASSTHROUGH_PREFIX = ( + "/api/data-recipe/seed/upload-unstructured-file" +) +_BODY_UPLOAD_PASSTHROUGH_PREFIXES = ( + _DATASET_UPLOAD_PASSTHROUGH_PREFIX, + _DATA_RECIPE_UNSTRUCTURED_UPLOAD_PASSTHROUGH_PREFIX, +) -async def _send_413(send, total_bytes: int) -> None: +def _get_upload_passthrough_request_max_bytes(path: str) -> int: + if path.startswith(_DATA_RECIPE_UNSTRUCTURED_UPLOAD_PASSTHROUGH_PREFIX): + return upload_request_limit_bytes(UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES) + if path.startswith(_DATASET_UPLOAD_PASSTHROUGH_PREFIX): + return upload_request_limit_bytes() + return default_request_body_limit_bytes() + + +async def _send_411(send) -> None: + payload = _json_for_413.dumps( + {"detail": "Content-Length required for upload requests."}, + ).encode("utf-8") + await send( + { + "type": "http.response.start", + "status": 411, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(payload)).encode("ascii")), + ], + } + ) + await send({"type": "http.response.body", "body": payload, "more_body": False}) + + +async def _send_413(send, total_bytes: int, max_bytes: int) -> None: payload = _json_for_413.dumps( { "detail": ( - f"Request body too large " - f"({total_bytes:,} bytes; max {_MAX_BODY_BYTES:,})." + f"Request body too large ({total_bytes:,} bytes; max {max_bytes:,})." ) }, ).encode("utf-8") @@ -565,10 +603,32 @@ async def _send_413(send, total_bytes: int) -> None: class MaxBodyMiddleware: """Reject oversized bodies on protected POST/PUT/PATCH; raw ASGI so chunked uploads cannot bypass the cap.""" - def __init__(self, app, max_bytes: int, protected_prefixes: tuple): + def __init__( + self, + app, + max_bytes_getter, + protected_prefixes: tuple, + upload_passthrough_prefixes: tuple = (), + upload_passthrough_max_bytes_getter = None, + ): self.app = app - self.max_bytes = max_bytes + self.max_bytes_getter = max_bytes_getter self.protected_prefixes = protected_prefixes + self.upload_passthrough_prefixes = upload_passthrough_prefixes + self.upload_passthrough_max_bytes_getter = upload_passthrough_max_bytes_getter + + def _upload_passthrough_max_bytes(self, path: str) -> int: + if self.upload_passthrough_max_bytes_getter is None: + return int(self.max_bytes_getter()) + try: + return int(self.upload_passthrough_max_bytes_getter(path)) + except TypeError: + try: + return int(self.upload_passthrough_max_bytes_getter()) + except Exception: + return int(self.max_bytes_getter()) + except Exception: + return int(self.max_bytes_getter()) async def __call__(self, scope, receive, send): if scope["type"] != "http": @@ -582,6 +642,7 @@ class MaxBodyMiddleware: await self.app(scope, receive, send) return + max_bytes = int(self.max_bytes_getter()) declared = None for name, value in scope.get("headers", []): if name == b"content-length": @@ -590,8 +651,20 @@ class MaxBodyMiddleware: except (ValueError, UnicodeDecodeError): declared = None break - if declared is not None and declared > self.max_bytes: - await _send_413(send, declared) + + if any(path.startswith(p) for p in self.upload_passthrough_prefixes): + upload_max_bytes = self._upload_passthrough_max_bytes(path) + if declared is None: + await _send_411(send) + return + if declared > upload_max_bytes: + await _send_413(send, declared, upload_max_bytes) + return + await self.app(scope, receive, send) + return + + if declared is not None and declared > max_bytes: + await _send_413(send, declared, max_bytes) return chunks: list = [] @@ -607,8 +680,8 @@ class MaxBodyMiddleware: body = msg.get("body", b"") or b"" if body: total += len(body) - if total > self.max_bytes: - await _send_413(send, total) + if total > max_bytes: + await _send_413(send, total, max_bytes) return chunks.append(body) if not msg.get("more_body", False): @@ -632,8 +705,10 @@ class MaxBodyMiddleware: app.add_middleware( MaxBodyMiddleware, - max_bytes = _MAX_BODY_BYTES, + max_bytes_getter = default_request_body_limit_bytes, protected_prefixes = _BODY_PROTECTED_PREFIXES, + upload_passthrough_prefixes = _BODY_UPLOAD_PASSTHROUGH_PREFIXES, + upload_passthrough_max_bytes_getter = _get_upload_passthrough_request_max_bytes, ) @@ -688,6 +763,7 @@ app.include_router(inference_studio_router, prefix = "/api/inference", tags = [" # standard /v1/chat/completions path. app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"]) app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"]) +app.include_router(settings_router, prefix = "/api/settings", tags = ["settings"]) app.include_router(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"]) app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"]) app.include_router(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"]) diff --git a/studio/backend/routes/data_recipe/seed.py b/studio/backend/routes/data_recipe/seed.py index 91cf718e6e..a18f8f3e32 100644 --- a/studio/backend/routes/data_recipe/seed.py +++ b/studio/backend/routes/data_recipe/seed.py @@ -31,6 +31,14 @@ except ImportError: resolve_chunking = None from core.data_recipe.jsonable import to_preview_jsonable from utils.paths import ensure_dir, seed_uploads_root, unstructured_uploads_root +from utils.upload_limits import ( + LOCAL_SEED_UPLOAD_MAX_BYTES, + LOCAL_SEED_UPLOAD_MAX_LABEL, + UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES, + UNSTRUCTURED_RECIPE_UPLOAD_MAX_LABEL, + UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES, + UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL, +) from models.data_recipe import ( SeedInspectRequest, @@ -47,9 +55,6 @@ LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl"} UNSTRUCTURED_ALLOWED_EXTS = {".pdf", ".docx", ".txt", ".md"} SEED_UPLOAD_DIR = seed_uploads_root() UNSTRUCTURED_UPLOAD_ROOT = unstructured_uploads_root() -MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB -MAX_TOTAL_SIZE = 100 * 1024 * 1024 # 100MB - _SAFE_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$") @@ -405,20 +410,17 @@ def _extract_text_from_file(file_path: Path, ext: str) -> str: return normalize_unstructured_text(raw) -def _get_block_total_size(block_dir: Path, file_ids: list[str]) -> int: - """Sum raw upload sizes for tracked file IDs only.""" - if not block_dir.exists() or not file_ids: +def _get_block_total_size(block_dir: Path) -> int: + """Sum raw upload sizes for the whole block from server-owned files.""" + if not block_dir.exists(): return 0 - id_set = set(file_ids) total = 0 for f in block_dir.iterdir(): if not f.is_file(): continue if f.name.endswith(".extracted.txt") or f.name.endswith(".meta.json"): continue - stem = f.name.split(".")[0] - if stem in id_set: - total += f.stat().st_size + total += f.stat().st_size return total @@ -426,12 +428,9 @@ def _get_block_total_size(block_dir: Path, file_ids: list[str]) -> int: async def upload_unstructured_file( file: UploadFile = FastAPIFile(...), block_id: str = Form(...), - existing_file_ids: str = Form(""), ) -> UnstructuredFileUploadResponse: _validate_safe_id(block_id, "block_id") - tracked_ids = [fid.strip() for fid in existing_file_ids.split(",") if fid.strip()] - original_filename = file.filename or "upload" ext = Path(original_filename).suffix.lower() if ext not in UNSTRUCTURED_ALLOWED_EXTS: @@ -446,17 +445,19 @@ async def upload_unstructured_file( if size_bytes == 0: raise HTTPException(400, "Empty file not allowed") - if size_bytes > MAX_FILE_SIZE: + if size_bytes > UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES: raise HTTPException( - 413, f"File too large ({size_bytes} bytes). Maximum is 50MB." + 413, + f"File too large ({size_bytes} bytes). Maximum is {UNSTRUCTURED_RECIPE_UPLOAD_MAX_LABEL}.", ) block_dir = UNSTRUCTURED_UPLOAD_ROOT / block_id ensure_dir(block_dir) - current_total = _get_block_total_size(block_dir, file_ids = tracked_ids) - if current_total + size_bytes > MAX_TOTAL_SIZE: + current_total = _get_block_total_size(block_dir) + if current_total + size_bytes > UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES: raise HTTPException( - 413, f"Total upload limit ({MAX_TOTAL_SIZE // (1024 * 1024)}MB) exceeded" + 413, + f"Total upload limit ({UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL}) exceeded", ) file_id = uuid4().hex @@ -594,8 +595,11 @@ def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectRespons file_bytes = _decode_base64_payload(payload.content_base64) if not file_bytes: raise HTTPException(status_code = 400, detail = "empty upload payload") - if len(file_bytes) > MAX_FILE_SIZE: - raise HTTPException(status_code = 413, detail = "file too large (max 50MB)") + if len(file_bytes) > LOCAL_SEED_UPLOAD_MAX_BYTES: + raise HTTPException( + status_code = 413, + detail = f"file too large (max {LOCAL_SEED_UPLOAD_MAX_LABEL})", + ) ensure_dir(SEED_UPLOAD_DIR) stored_name = f"{uuid4().hex}_{filename}" diff --git a/studio/backend/routes/datasets.py b/studio/backend/routes/datasets.py index 206af2a66f..c34d6d8732 100644 --- a/studio/backend/routes/datasets.py +++ b/studio/backend/routes/datasets.py @@ -9,6 +9,7 @@ import base64 import io import json import sys +from contextlib import suppress from pathlib import Path from uuid import uuid4 from typing import Optional @@ -67,6 +68,7 @@ if str(backend_path) not in sys.path: # Import dataset utilities from utils.datasets import check_dataset_format +from utils.upload_limits import get_upload_limit_bytes, get_upload_limit_label from auth.authentication import get_current_subject router = APIRouter() @@ -138,6 +140,7 @@ _ARCHIVE_EXTS = (".tar", ".tar.gz", ".tgz", ".gz", ".zst", ".zip", ".txt") DATA_EXTS = _TABULAR_EXTS + _ARCHIVE_EXTS LOCAL_FILE_EXTS = (".json", ".jsonl", ".csv", ".parquet") LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl", ".parquet"} +# sync: training dataset upload limits are exposed by /api/settings/upload-limit LOCAL_DATASETS_ROOT = recipe_datasets_root() DATASET_UPLOAD_DIR = dataset_uploads_root() @@ -334,10 +337,30 @@ async def upload_dataset( stored_name = f"{uuid4().hex}_{stem}{ext}" stored_path = DATASET_UPLOAD_DIR / stored_name - # Stream file to disk in chunks to avoid holding entire file in memory - with open(stored_path, "wb") as f: - while chunk := await file.read(1024 * 1024): - f.write(chunk) + # Stream file to disk in chunks to avoid holding entire file in memory. + # Keep a route-level cap so users get a clear training-dataset-specific + # error and oversized partial files are not left in the Studio uploads directory. + upload_limit_bytes = get_upload_limit_bytes() + total_bytes = 0 + upload_complete = False + try: + with open(stored_path, "wb") as f: + while chunk := await file.read(1024 * 1024): + total_bytes += len(chunk) + if total_bytes > upload_limit_bytes: + raise HTTPException( + status_code = 413, + detail = ( + "Training dataset upload too large. " + f"Maximum is {get_upload_limit_label()}." + ), + ) + f.write(chunk) + upload_complete = True + finally: + if not upload_complete: + with suppress(OSError): + stored_path.unlink(missing_ok = True) if stored_path.stat().st_size == 0: stored_path.unlink(missing_ok = True) diff --git a/studio/backend/routes/settings.py b/studio/backend/routes/settings.py new file mode 100644 index 0000000000..275fbb678b --- /dev/null +++ b/studio/backend/routes/settings.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from auth.authentication import get_current_subject +from utils.upload_limits import ( + MAX_UPLOAD_LIMIT_MB, + MIN_UPLOAD_LIMIT_MB, + default_upload_limit_mb, + get_upload_limit_mb, + set_upload_limit_mb, + upload_limit_bytes, + upload_limit_label, +) + +router = APIRouter() + + +class UploadLimitPayload(BaseModel): + max_upload_size_mb: int = Field(..., ge = MIN_UPLOAD_LIMIT_MB, le = MAX_UPLOAD_LIMIT_MB) + + +class UploadLimitResponse(BaseModel): + max_upload_size_mb: int + max_upload_size_bytes: int + max_upload_size_label: str + default_upload_size_mb: int + min_upload_size_mb: int = MIN_UPLOAD_LIMIT_MB + max_allowed_upload_size_mb: int = MAX_UPLOAD_LIMIT_MB + + +def _upload_limit_response(limit_mb: int) -> UploadLimitResponse: + return UploadLimitResponse( + max_upload_size_mb = limit_mb, + max_upload_size_bytes = upload_limit_bytes(limit_mb), + max_upload_size_label = upload_limit_label(limit_mb), + default_upload_size_mb = default_upload_limit_mb(), + ) + + +@router.get("/upload-limit", response_model = UploadLimitResponse) +def get_upload_limit( + current_subject: str = Depends(get_current_subject), +) -> UploadLimitResponse: + return _upload_limit_response(get_upload_limit_mb()) + + +@router.put("/upload-limit", response_model = UploadLimitResponse) +def update_upload_limit( + payload: UploadLimitPayload, + current_subject: str = Depends(get_current_subject), +) -> UploadLimitResponse: + try: + limit_mb = set_upload_limit_mb(payload.max_upload_size_mb) + except ValueError as exc: + raise HTTPException(status_code = 400, detail = str(exc)) from exc + return _upload_limit_response(limit_mb) diff --git a/studio/backend/storage/studio_db.py b/studio/backend/storage/studio_db.py index 0887eb9ceb..0175e54b35 100644 --- a/studio/backend/storage/studio_db.py +++ b/studio/backend/storage/studio_db.py @@ -279,6 +279,15 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT NOT NULL PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) conn.execute( """ CREATE TABLE IF NOT EXISTS chat_settings_quarantine ( @@ -1343,6 +1352,44 @@ def list_chat_messages_for_threads(thread_ids: list[str]) -> list[dict]: conn.close() +def get_app_setting(key: str, fallback = None): + conn = get_connection() + try: + row = conn.execute( + "SELECT value_json FROM app_settings WHERE key = ?", (key,) + ).fetchone() + if row is None: + return fallback + return _json_loads(row["value_json"], fallback) + finally: + conn.close() + + +def upsert_app_settings(settings: dict[str, Any]) -> dict[str, Any]: + if not settings: + return {} + conn = get_connection() + try: + now = datetime.now(timezone.utc).isoformat() + conn.executemany( + """ + INSERT INTO app_settings (key, value_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value_json = excluded.value_json, + updated_at = excluded.updated_at + """, + [(key, json.dumps(value), now) for key, value in settings.items()], + ) + conn.commit() + rows = conn.execute( + "SELECT key, value_json FROM app_settings ORDER BY key" + ).fetchall() + return {row["key"]: _json_loads(row["value_json"], None) for row in rows} + finally: + conn.close() + + def list_chat_settings() -> dict[str, Any]: conn = get_connection() try: diff --git a/studio/backend/tests/test_dataset_upload_limits.py b/studio/backend/tests/test_dataset_upload_limits.py new file mode 100644 index 0000000000..0991059318 --- /dev/null +++ b/studio/backend/tests/test_dataset_upload_limits.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Tests for training dataset upload limits and cleanup.""" + +import asyncio +import sys +from pathlib import Path +from typing import cast + +import pytest +from fastapi import HTTPException, UploadFile + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) + +from routes import datasets as datasets_route # noqa: E402 + + +class FakeUploadFile: + def __init__(self, filename: str, chunks: list[bytes]): + self.filename = filename + self._chunks = list(chunks) + + async def read(self, _size: int = -1) -> bytes: + if not self._chunks: + return b"" + return self._chunks.pop(0) + + +@pytest.fixture(autouse = True) +def isolate_upload_dir(tmp_path, monkeypatch): + monkeypatch.setattr(datasets_route, "DATASET_UPLOAD_DIR", tmp_path) + monkeypatch.setattr(datasets_route, "get_upload_limit_bytes", lambda: 1024 * 1024) + monkeypatch.setattr(datasets_route, "get_upload_limit_label", lambda: "1MB") + return tmp_path + + +def test_dataset_upload_under_configured_cap_succeeds(isolate_upload_dir): + upload = FakeUploadFile("sample.csv", [b"a,b\n1,2\n"]) + response = asyncio.run( + datasets_route.upload_dataset( + cast(UploadFile, upload), current_subject = "test-user" + ) + ) + stored = Path(response.stored_path) + assert response.filename == "sample.csv" + assert stored.exists() + assert stored.parent == isolate_upload_dir + assert stored.read_bytes() == b"a,b\n1,2\n" + + +def test_dataset_upload_over_configured_cap_removes_partial_file(isolate_upload_dir): + upload = FakeUploadFile( + "sample.csv", + [b"x" * (1024 * 1024), b"y"], + ) + with pytest.raises(HTTPException) as exc: + asyncio.run( + datasets_route.upload_dataset( + cast(UploadFile, upload), current_subject = "test-user" + ) + ) + assert exc.value.status_code == 413 + assert "Maximum is 1MB" in exc.value.detail + assert list(isolate_upload_dir.iterdir()) == [] diff --git a/studio/backend/tests/test_desktop_auth.py b/studio/backend/tests/test_desktop_auth.py index b1522dd382..3d01342a5c 100644 --- a/studio/backend/tests/test_desktop_auth.py +++ b/studio/backend/tests/test_desktop_auth.py @@ -9,7 +9,7 @@ import sqlite3 import subprocess import sys from pathlib import Path -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace import jwt import pytest @@ -429,21 +429,31 @@ def test_desktop_capabilities_json_reports_rollout_safe_flags(): def test_health_response_reports_desktop_capability_fields(monkeypatch): - router_stub = SimpleNamespace( - auth_router = APIRouter(), - chat_history_router = APIRouter(), - data_recipe_router = APIRouter(), - datasets_router = APIRouter(), - export_router = APIRouter(), - inference_router = APIRouter(), - inference_studio_router = APIRouter(), - mcp_servers_router = APIRouter(), - models_router = APIRouter(), - providers_router = APIRouter(), - training_history_router = APIRouter(), - training_router = APIRouter(), - ) - monkeypatch.setitem(sys.modules, "routes", router_stub) + routes_module = ModuleType("routes") + routes_module.__path__ = [] + settings_module = ModuleType("routes.settings") + settings_module.router = APIRouter() + + for name, router in { + "auth_router": APIRouter(), + "chat_history_router": APIRouter(), + "data_recipe_router": APIRouter(), + "datasets_router": APIRouter(), + "export_router": APIRouter(), + "inference_router": APIRouter(), + "inference_studio_router": APIRouter(), + "mcp_servers_router": APIRouter(), + "models_router": APIRouter(), + "providers_router": APIRouter(), + "settings_router": settings_module.router, + "training_history_router": APIRouter(), + "training_router": APIRouter(), + }.items(): + setattr(routes_module, name, router) + routes_module.settings = settings_module + + monkeypatch.setitem(sys.modules, "routes", routes_module) + monkeypatch.setitem(sys.modules, "routes.settings", settings_module) import studio.backend.main as backend_main diff --git a/studio/backend/tests/test_middleware.py b/studio/backend/tests/test_middleware.py index bbaf20298d..85b82f1011 100644 --- a/studio/backend/tests/test_middleware.py +++ b/studio/backend/tests/test_middleware.py @@ -33,12 +33,19 @@ def main_module(): # ===================================================================== -def _make_protected_app(max_bytes: int, main_module): +def _make_protected_app( + max_bytes: int, + main_module, + upload_passthrough_prefixes: tuple = (), + upload_passthrough_max_bytes_getter = None, +): app = FastAPI() app.add_middleware( main_module.MaxBodyMiddleware, - max_bytes = max_bytes, - protected_prefixes = ("/v1/chat/completions", "/api/train"), + max_bytes_getter = lambda: max_bytes, + protected_prefixes = ("/v1/chat/completions", "/api/settings", "/api/train"), + upload_passthrough_prefixes = upload_passthrough_prefixes, + upload_passthrough_max_bytes_getter = upload_passthrough_max_bytes_getter, ) @app.post("/v1/chat/completions") @@ -49,6 +56,20 @@ def _make_protected_app(max_bytes: int, main_module): async def other(payload: dict): return {"ok": True, "unprotected": True} + @app.put("/api/settings/upload-limit") + async def update_upload_limit(payload: dict): + return {"ok": True, "limit": payload.get("max_upload_size_mb")} + + @app.post("/api/train/upload") + async def upload(request: Request): + total = 0 + chunks = 0 + async for chunk in request.stream(): + if chunk: + chunks += 1 + total += len(chunk) + return {"ok": True, "chunks": chunks, "total": total} + @app.get("/api/train/status") async def status_get(): return {"ok": True, "get": True} @@ -78,6 +99,16 @@ class TestMaxBodyMiddleware: assert r.status_code == 200 assert r.json()["unprotected"] is True + def test_settings_put_body_over_cap_rejected(self, main_module): + app = _make_protected_app(1024, main_module) + c = TestClient(app) + r = c.put( + "/api/settings/upload-limit", + json = {"max_upload_size_mb": 500, "padding": "x" * 5000}, + ) + assert r.status_code == 413 + assert "too large" in r.json()["detail"].lower() + def test_chunked_upload_over_cap_rejected(self, main_module): # Regression: declared-Content-Length-only check could be bypassed # by chunked transfer-encoding. @@ -121,6 +152,61 @@ class TestMaxBodyMiddleware: r = c.get("/api/train/status") assert r.status_code == 200 + def test_upload_passthrough_uses_dedicated_declared_cap(self, main_module): + app = _make_protected_app( + 128, + main_module, + upload_passthrough_prefixes = ("/api/train/upload",), + upload_passthrough_max_bytes_getter = lambda: 1024, + ) + c = TestClient(app) + r = c.post( + "/api/train/upload", + content = b"x" * 512, + headers = {"content-type": "application/octet-stream"}, + ) + assert r.status_code == 200 + assert r.json()["total"] == 512 + + def test_upload_passthrough_rejects_declared_body_over_dedicated_cap( + self, main_module + ): + app = _make_protected_app( + 128, + main_module, + upload_passthrough_prefixes = ("/api/train/upload",), + upload_passthrough_max_bytes_getter = lambda: 256, + ) + c = TestClient(app) + r = c.post( + "/api/train/upload", + content = b"x" * 512, + headers = {"content-type": "application/octet-stream"}, + ) + assert r.status_code == 413 + assert "256" in r.json()["detail"] + + def test_upload_passthrough_requires_content_length(self, main_module): + app = _make_protected_app( + 128, + main_module, + upload_passthrough_prefixes = ("/api/train/upload",), + upload_passthrough_max_bytes_getter = lambda: 1024, + ) + c = TestClient(app) + + def gen(): + yield b"x" * 64 + yield b"y" * 64 + + r = c.post( + "/api/train/upload", + content = gen(), + headers = {"content-type": "application/octet-stream"}, + ) + assert r.status_code == 411 + assert "Content-Length" in r.json()["detail"] + # ===================================================================== # SecurityHeadersMiddleware / CSP diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py index 57007a5f66..cd8957c3dd 100644 --- a/studio/backend/tests/test_sandbox_tools.py +++ b/studio/backend/tests/test_sandbox_tools.py @@ -345,8 +345,9 @@ class TestSandboxCpuRlimitDefault: class TestMaxBodyDefault: def test_default_is_500_mb(self): - src = (_BACKEND_ROOT / "main.py").read_text() - assert 'UNSLOTH_STUDIO_MAX_BODY_MB", "500"' in src + src = (_BACKEND_ROOT / "utils" / "upload_limits.py").read_text() + assert "DEFAULT_UPLOAD_LIMIT_MB = 500" in src + assert "UNSLOTH_STUDIO_MAX_BODY_MB" in src class TestBashBlocklistPosition: diff --git a/studio/backend/utils/upload_limits.py b/studio/backend/utils/upload_limits.py new file mode 100644 index 0000000000..b8a6a2474b --- /dev/null +++ b/studio/backend/utils/upload_limits.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Shared Studio upload/request size limits.""" + +from __future__ import annotations + +import os +from typing import Any + +UPLOAD_LIMIT_SETTING_KEY = "max_upload_size_mb" +DEFAULT_UPLOAD_LIMIT_MB = 500 +MIN_UPLOAD_LIMIT_MB = 1 +MAX_UPLOAD_LIMIT_MB = 8192 +_BYTES_PER_MB = 1024 * 1024 +MULTIPART_OVERHEAD_BYTES = 10 * _BYTES_PER_MB + +LOCAL_SEED_UPLOAD_MAX_BYTES = 100 * _BYTES_PER_MB +LOCAL_SEED_UPLOAD_MAX_LABEL = "100MB" +UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES = 500 * _BYTES_PER_MB +UNSTRUCTURED_RECIPE_UPLOAD_MAX_LABEL = "500MB" +UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES = 1024 * _BYTES_PER_MB +UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL = "1GB" + + +def _coerce_upload_limit_mb(value: Any) -> int | None: + if isinstance(value, bool): + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + if parsed < MIN_UPLOAD_LIMIT_MB or parsed > MAX_UPLOAD_LIMIT_MB: + return None + return parsed + + +def default_upload_limit_mb() -> int: + env_value = _coerce_upload_limit_mb(os.environ.get("UNSLOTH_STUDIO_MAX_BODY_MB")) + return env_value or DEFAULT_UPLOAD_LIMIT_MB + + +def validate_upload_limit_mb(value: Any) -> int: + parsed = _coerce_upload_limit_mb(value) + if parsed is None: + raise ValueError( + f"Upload limit must be a whole number from {MIN_UPLOAD_LIMIT_MB} to {MAX_UPLOAD_LIMIT_MB} MB." + ) + return parsed + + +def get_upload_limit_mb() -> int: + try: + from storage.studio_db import get_app_setting + + stored = get_app_setting(UPLOAD_LIMIT_SETTING_KEY, None) + except Exception: + stored = None + return _coerce_upload_limit_mb(stored) or default_upload_limit_mb() + + +def set_upload_limit_mb(value: Any) -> int: + parsed = validate_upload_limit_mb(value) + from storage.studio_db import upsert_app_settings + + upsert_app_settings({UPLOAD_LIMIT_SETTING_KEY: parsed}) + return parsed + + +def upload_limit_bytes(limit_mb: int | None = None) -> int: + return (limit_mb if limit_mb is not None else get_upload_limit_mb()) * _BYTES_PER_MB + + +def get_upload_limit_bytes() -> int: + return upload_limit_bytes() + + +def upload_limit_label(limit_mb: int | None = None) -> str: + return f"{limit_mb if limit_mb is not None else get_upload_limit_mb()}MB" + + +def get_upload_limit_label() -> str: + return upload_limit_label() + + +def default_request_body_limit_bytes() -> int: + """Default protected-route body cap for non-upload requests.""" + + return default_upload_limit_mb() * _BYTES_PER_MB + + +def upload_request_limit_bytes(file_limit_bytes: int | None = None) -> int: + """Request cap for upload routes, including multipart field overhead.""" + + return ( + file_limit_bytes if file_limit_bytes is not None else get_upload_limit_bytes() + ) + MULTIPART_OVERHEAD_BYTES diff --git a/studio/frontend/src/features/recipe-studio/api/index.ts b/studio/frontend/src/features/recipe-studio/api/index.ts index 06b4bc1f8b..b227b960d1 100644 --- a/studio/frontend/src/features/recipe-studio/api/index.ts +++ b/studio/frontend/src/features/recipe-studio/api/index.ts @@ -2,7 +2,10 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { authFetch } from "@/features/auth"; -import { formatFastApiDetail, readFastApiError } from "@/lib/format-fastapi-error"; +import { + formatFastApiDetail, + readFastApiError, +} from "@/lib/format-fastapi-error"; const DEFAULT_BASE = "/api/data-recipe"; @@ -212,8 +215,10 @@ async function parseErrorResponse(response: Response): Promise { // formatFastApiDetail returns null when it cannot flatten the value. const formatted = formatFastApiDetail(parsed.detail); if (formatted) return formatted; - if (typeof parsed.message === "string" && parsed.message) return parsed.message; - if (typeof parsed.raw_detail === "string" && parsed.raw_detail) return parsed.raw_detail; + if (typeof parsed.message === "string" && parsed.message) + return parsed.message; + if (typeof parsed.raw_detail === "string" && parsed.raw_detail) + return parsed.raw_detail; return text; } catch { return text; @@ -437,14 +442,10 @@ export async function uploadUnstructuredFile( file: File, blockId: string, signal?: AbortSignal, - existingFileIds?: string[], ): Promise { const formData = new FormData(); formData.append("file", file); formData.append("block_id", blockId); - if (existingFileIds?.length) { - formData.append("existing_file_ids", existingFileIds.join(",")); - } const res = await authFetch( `${DATA_DESIGNER_API_BASE}/seed/upload-unstructured-file`, diff --git a/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx index 54eae08f7c..878f9eba7c 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx @@ -44,6 +44,10 @@ import { } from "react"; import { cn } from "@/lib/utils"; import { UnstructuredDropZone, type FileEntry } from "./unstructured-drop-zone"; +import { + LOCAL_SEED_UPLOAD_MAX_BYTES, + LOCAL_SEED_UPLOAD_MAX_LABEL, +} from "./upload-limits"; import { getGithubEnvTokenStatus, inspectSeedDataset, @@ -73,7 +77,6 @@ const SELECTION_OPTIONS: Array<{ value: SeedSelectionType; label: string }> = [ ]; const LOCAL_ACCEPT = ".csv,.json,.jsonl"; -const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; const DEFAULT_CHUNK_SIZE = 1200; const DEFAULT_CHUNK_OVERLAP = 200; const MAX_CHUNK_SIZE = 20000; @@ -740,8 +743,10 @@ export function SeedDialog({ if (!localFile) { throw new Error("Select a local CSV/JSON/JSONL file first."); } - if (localFile.size > MAX_UPLOAD_BYTES) { - throw new Error("File too large (max 50MB)."); + if (localFile.size > LOCAL_SEED_UPLOAD_MAX_BYTES) { + throw new Error( + `File too large (max ${LOCAL_SEED_UPLOAD_MAX_LABEL}).`, + ); } const payload = await fileToBase64Payload(localFile); const response = await inspectSeedUpload({ @@ -980,7 +985,7 @@ export function SeedDialog({

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

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

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

Drop files here or click to browse

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

@@ -200,13 +226,22 @@ export function UnstructuredDropZone({ className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm" > {entry.status === "uploading" && ( - + )} {entry.status === "ok" && ( - + )} {entry.status === "error" && ( - + )} {entry.name} @@ -228,8 +263,14 @@ export function UnstructuredDropZone({
))}
- {successFiles.length} file{successFiles.length !== 1 ? "s" : ""} uploaded - {formatSize(totalSize)} / 100MB + + {successFiles.length} file{successFiles.length !== 1 ? "s" : ""}{" "} + uploaded + + + {formatSize(totalSize)} /{" "} + {UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL} +
)} diff --git a/studio/frontend/src/features/recipe-studio/dialogs/seed/upload-limits.ts b/studio/frontend/src/features/recipe-studio/dialogs/seed/upload-limits.ts new file mode 100644 index 0000000000..606179851e --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/dialogs/seed/upload-limits.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +export const LOCAL_SEED_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; +export const LOCAL_SEED_UPLOAD_MAX_LABEL = "100MB"; +export const UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES = 500 * 1024 * 1024; +export const UNSTRUCTURED_RECIPE_UPLOAD_MAX_LABEL = "500MB"; +export const UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES = 1024 * 1024 * 1024; +export const UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_LABEL = "1GB"; diff --git a/studio/frontend/src/features/settings/api/upload-limit.ts b/studio/frontend/src/features/settings/api/upload-limit.ts new file mode 100644 index 0000000000..2ef0458286 --- /dev/null +++ b/studio/frontend/src/features/settings/api/upload-limit.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { authFetch } from "@/features/auth"; +import { readFastApiError } from "@/lib/format-fastapi-error"; + +export const DEFAULT_UPLOAD_LIMIT_MB = 500; +export const DEFAULT_UPLOAD_LIMIT_BYTES = DEFAULT_UPLOAD_LIMIT_MB * 1024 * 1024; + +const UPLOAD_LIMIT_EVENT = "unsloth-upload-limit-change"; + +export type UploadLimitSettings = { + maxUploadSizeMb: number; + maxUploadSizeBytes: number; + maxUploadSizeLabel: string; + defaultUploadSizeMb: number; + minUploadSizeMb: number; + maxAllowedUploadSizeMb: number; +}; + +type ApiUploadLimitSettings = { + // biome-ignore lint/style/useNamingConvention: API schema + max_upload_size_mb: number; + // biome-ignore lint/style/useNamingConvention: API schema + max_upload_size_bytes: number; + // biome-ignore lint/style/useNamingConvention: API schema + max_upload_size_label: string; + // biome-ignore lint/style/useNamingConvention: API schema + default_upload_size_mb: number; + // biome-ignore lint/style/useNamingConvention: API schema + min_upload_size_mb: number; + // biome-ignore lint/style/useNamingConvention: API schema + max_allowed_upload_size_mb: number; +}; + +let cachedUploadLimit: UploadLimitSettings | null = null; +let inFlightUploadLimit: Promise | null = null; + +export function getCachedUploadLimitBytes() { + return cachedUploadLimit?.maxUploadSizeBytes ?? DEFAULT_UPLOAD_LIMIT_BYTES; +} + +export function getCachedUploadLimitLabel() { + return ( + cachedUploadLimit?.maxUploadSizeLabel ?? `${DEFAULT_UPLOAD_LIMIT_MB}MB` + ); +} + +export function formatUploadSize(bytes: number) { + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +export function subscribeUploadLimitSettings( + listener: (settings: UploadLimitSettings) => void, +) { + const handleChange = (event: Event) => { + listener((event as CustomEvent).detail); + }; + window.addEventListener(UPLOAD_LIMIT_EVENT, handleChange); + return () => window.removeEventListener(UPLOAD_LIMIT_EVENT, handleChange); +} + +function fromApi(settings: ApiUploadLimitSettings): UploadLimitSettings { + return { + maxUploadSizeMb: settings.max_upload_size_mb, + maxUploadSizeBytes: settings.max_upload_size_bytes, + maxUploadSizeLabel: settings.max_upload_size_label, + defaultUploadSizeMb: settings.default_upload_size_mb, + minUploadSizeMb: settings.min_upload_size_mb, + maxAllowedUploadSizeMb: settings.max_allowed_upload_size_mb, + }; +} + +function cacheUploadLimit(settings: UploadLimitSettings) { + cachedUploadLimit = settings; + window.dispatchEvent( + new CustomEvent(UPLOAD_LIMIT_EVENT, { detail: settings }), + ); + return settings; +} + +async function fetchUploadLimitSettings(): Promise { + const res = await authFetch("/api/settings/upload-limit"); + if (!res.ok) { + throw new Error(await readFastApiError(res, "Failed to load upload limit")); + } + return fromApi(await res.json()); +} + +export async function loadUploadLimitSettings() { + if (cachedUploadLimit) { + return cachedUploadLimit; + } + inFlightUploadLimit ??= fetchUploadLimitSettings() + .then(cacheUploadLimit) + .finally(() => { + inFlightUploadLimit = null; + }); + return inFlightUploadLimit; +} + +export async function updateUploadLimitSettings( + maxUploadSizeMb: number, +): Promise { + const res = await authFetch("/api/settings/upload-limit", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + // biome-ignore lint/style/useNamingConvention: API schema + body: JSON.stringify({ max_upload_size_mb: maxUploadSizeMb }), + }); + if (!res.ok) { + throw new Error( + await readFastApiError(res, "Failed to update upload limit"), + ); + } + return cacheUploadLimit(fromApi(await res.json())); +} diff --git a/studio/frontend/src/features/settings/tabs/general-tab.tsx b/studio/frontend/src/features/settings/tabs/general-tab.tsx index 601854f403..aaaf0a7115 100644 --- a/studio/frontend/src/features/settings/tabs/general-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/general-tab.tsx @@ -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( + null, + ); + const [draftUploadLimit, setDraftUploadLimit] = useState( + String(DEFAULT_UPLOAD_LIMIT_MB), + ); + const [uploadLimitError, setUploadLimitError] = useState(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 (
@@ -183,6 +243,52 @@ export function GeneralTab() { + + +
+
+
+ setDraftUploadLimit(event.target.value)} + className="h-8 w-full pr-10" + /> + + MB + +
+ +
+ {uploadLimitError ? ( + + {uploadLimitError} + + ) : null} +
+
+
+ {!chatOnly && ( (TRAINING_UPLOAD_EXTENSIONS); +const TRAINING_UPLOAD_EXTENSION_SET = new Set( + 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(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(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) => { + const handleDatasetFileChange = async ( + event: ChangeEvent, + ) => { 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() { {(id: string) => { return ( - + @@ -670,7 +753,9 @@ export function DatasetSection() { ) : ( <> {localError ? ( -

{localError}

+

+ {localError} +

) : (
@@ -692,7 +777,11 @@ export function DatasetSection() { {(id: string) => { const label = localLabelById.get(id) ?? id; return ( - + @@ -814,8 +903,10 @@ export function DatasetSection() {
- + {deriveLocalDatasetName(uploadedEvalFile)} @@ -863,7 +957,10 @@ export function DatasetSection() { {isUploading ? ( ) : ( - + )} {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), + ) } />
@@ -1015,8 +1114,8 @@ export function DatasetSection() {

{datasetSource === "upload" - ? selectedLocalDataset?.label ?? - deriveLocalDatasetName(selectedDatasetName) + ? (selectedLocalDataset?.label ?? + deriveLocalDatasetName(selectedDatasetName)) : selectedDatasetName}

@@ -1074,7 +1173,8 @@ export function DatasetSection() { {t("studio.dataset.dropFileOrClick")} - {TRAINING_UPLOAD_LABEL} + {TRAINING_DATASET_UPLOAD_LABEL} · up to{" "} + {uploadLimitLabel}; {DOCUMENT_REDIRECT_LABEL} @@ -1131,7 +1231,7 @@ export function DatasetSection() { fileName={redirectFileName} onOpenLearningRecipes={handleOpenLearningRecipes} /> -

+
); diff --git a/studio/frontend/src/features/training/index.ts b/studio/frontend/src/features/training/index.ts index 654ddedd03..05f882184f 100644 --- a/studio/frontend/src/features/training/index.ts +++ b/studio/frontend/src/features/training/index.ts @@ -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 { diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index bb76f1ebb5..83a04b60c1 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -14,6 +14,7 @@ export const en = { new: "New", rename: "Rename", save: "Save", + saving: "Saving...", search: "Search", shutdown: "Shutdown", }, @@ -54,11 +55,11 @@ export const en = { dialog: { deleteChat: { title: "Delete chat", - description: "Are you sure you want to delete this chat \"{name}\"?", + description: 'Are you sure you want to delete this chat "{name}"?', }, deleteRun: { title: "Delete training run", - description: "Are you sure you want to delete this run \"{name}\"?", + description: 'Are you sure you want to delete this run "{name}"?', }, renameChat: { title: "Rename chat", @@ -111,15 +112,21 @@ export const en = { startOnboardingDescription: "Open the setup wizard again without changing your account.", startOnboardingAction: "Start onboarding", + uploads: { + sectionTitle: "Uploads", + maxUploadSize: "Training dataset upload cap", + maxUploadSizeDescription: + "Applies to training dataset uploads. Default is {defaultSize} MB.", + }, resetPreferences: { sectionTitle: "Danger zone", label: "Reset all local preferences", description: - "Clears local-only preferences. Chats, API access, and DB-backed chat settings are not affected.", + "Clears local-only preferences. Chats, API access, and DB-backed settings are not affected.", action: "Reset preferences", confirmTitle: "Reset all local preferences?", confirmDescription: - "This clears local-only preferences, then reloads Studio. Chats, API access, and DB-backed chat settings are not affected.", + "This clears local-only preferences, then reloads Studio. Chats, API access, and DB-backed settings are not affected.", confirmAction: "Reset and reload", }, }, @@ -184,8 +191,7 @@ export const en = { clearHistoryDescription: "Delete local chat history from this device.", clearAction: "Clear", clearAllChats: "Clear all chats", - clearAllChatsDescription: - "Permanently delete every chat on this device.", + clearAllChatsDescription: "Permanently delete every chat on this device.", noChatsToClear: "No chats to clear.", clearOneChatDescription: "Permanently delete the only chat on this device.", @@ -209,8 +215,7 @@ export const en = { "{clearedCount} chats cleared; {remainingCount} chats remain. Please retry.", oneChatClearedRemain: "1 chat cleared; {remainingCount} chats remain. Please retry.", - oneChatClearedRemainOne: - "1 chat cleared; 1 chat remains. Please retry.", + oneChatClearedRemainOne: "1 chat cleared; 1 chat remains. Please retry.", storageClearFailedOne: "A storage clear failed; 1 chat may remain. Please retry.", storageClearFailed: @@ -223,7 +228,8 @@ export const en = { }, apiKeys: { title: "API", - description: "Access Unsloth programmatically via the OpenAI-compatible API.", + description: + "Access Unsloth programmatically via the OpenAI-compatible API.", readDocs: "Read the API docs", noAccess: "No API access yet.", newBadge: "New", @@ -261,10 +267,10 @@ export const en = { actionsFor: "Actions for {name}", copyPrefix: "Copy prefix", revokeToken: "Revoke token", - revokeTitle: "Revoke access token \"{name}\"?", + revokeTitle: 'Revoke access token "{name}"?', revokeDescription: "Applications using this token will immediately lose access. This cannot be undone.", - revokeAction: "Revoke \"{name}\"", + revokeAction: 'Revoke "{name}"', revoking: "Revoking...", }, about: { @@ -361,7 +367,8 @@ export const en = { fasterTrainingBadge: "2x Faster Training", baseModel: "Base model", localModel: "Local Model", - localModelTooltip: "Path to a locally downloaded model or a custom HF repo.", + localModelTooltip: + "Path to a locally downloaded model or a custom HF repo.", scanningLocalAndCachedModels: "Scanning local and cached models...", scanning: "Scanning...", scanningLocalModels: "Scanning local models...", @@ -410,8 +417,7 @@ export const en = { noLocalDatasetsYet: "No local datasets yet.", noLocalDatasetsMatchSearch: "No local datasets match search.", openDataRecipes: "Open Data Recipes", - browsingSource: - "Browsing {browsing}. Current selection stays {current}.", + browsingSource: "Browsing {browsing}. Current selection stays {current}.", localDatasets: "Local datasets", localDataset: "Local dataset", localDatasetRows: " / {count} rows", @@ -471,7 +477,8 @@ export const en = { maxStepsTooltip: "Override total optimizer steps.", epochsTooltip: "Number of full passes over the dataset.", epochsDescription: "Each epoch is one full pass over your dataset.", - maxStepsDescription: "Limits training to a fixed number of optimizer steps.", + maxStepsDescription: + "Limits training to a fixed number of optimizer steps.", contextLength: "Context Length", contextLengthTooltip: "Maximum number of tokens per training sample.", customContextLength: "Enter a custom value", @@ -487,11 +494,13 @@ export const en = { embeddingLearningRateDescription: "Leave blank to use lr/10 (recommended). Typical range is 2x-10x smaller than the main learning rate.", rank: "Rank", - rankTooltip: "Dimension of the low-rank matrices. Higher = more capacity.", + rankTooltip: + "Dimension of the low-rank matrices. Higher = more capacity.", alpha: "Alpha", alphaTooltip: "Scaling factor for LoRA updates. Usually 2x rank.", dropout: "Dropout", - dropoutTooltip: "Dropout probability for LoRA layers to reduce overfitting.", + dropoutTooltip: + "Dropout probability for LoRA layers to reduce overfitting.", visionLayers: "Vision layers", languageLayers: "Language layers", attentionModules: "Attention modules", @@ -529,7 +538,8 @@ export const en = { weightDecay: "Weight Decay", weightDecayTooltip: "L2 regularization to prevent overfitting.", warmupSteps: "Warmup Steps", - warmupStepsTooltip: "Gradually increase LR at training start for stability.", + warmupStepsTooltip: + "Gradually increase LR at training start for stability.", scheduleEpochsTooltip: "Number of full passes over the dataset. Set 0 to run by max steps.", saveSteps: "Save Steps", @@ -586,7 +596,8 @@ export const en = { exportModel: "Export Model", milestone: "Milestone", halfwayDone: "Halfway done. Training is past 50%.", - doneNextStep: "Training done. Next step: compare base vs fine-tuned outputs.", + doneNextStep: + "Training done. Next step: compare base vs fine-tuned outputs.", }, history: { title: "History", @@ -632,7 +643,8 @@ export const en = { }, charts: { settings: "Chart Settings", - settingsDescription: "Tune chart presentation while training keeps running.", + settingsDescription: + "Tune chart presentation while training keeps running.", openSettings: "Open chart settings", viewWindow: "View window", viewWindowDescription: "Show latest steps only or the full history.", @@ -667,7 +679,8 @@ export const en = { waitingForFirstEvaluationStep: "Waiting for first evaluation step...", evaluationNotConfigured: "Evaluation not configured", evalChartWillAppear: "Chart will appear once eval_steps is reached", - setEvalDatasetAndSteps: "Set eval dataset & eval_steps to track eval loss", + setEvalDatasetAndSteps: + "Set eval dataset & eval_steps to track eval loss", }, progress: { title: "Training Progress", diff --git a/studio/frontend/src/i18n/locales/zh-CN.ts b/studio/frontend/src/i18n/locales/zh-CN.ts index 4074a5760b..f7075eb887 100644 --- a/studio/frontend/src/i18n/locales/zh-CN.ts +++ b/studio/frontend/src/i18n/locales/zh-CN.ts @@ -17,6 +17,7 @@ export const zhCN = { new: "新增", rename: "重命名", save: "保存", + saving: "保存中...", search: "搜索", shutdown: "关闭服务", }, @@ -108,15 +109,21 @@ export const zhCN = { startOnboarding: "开始引导", startOnboardingDescription: "重新打开设置向导,不会更改你的账号。", startOnboardingAction: "开始引导", + uploads: { + sectionTitle: "上传", + maxUploadSize: "训练数据集上传上限", + maxUploadSizeDescription: + "适用于训练数据集上传。默认值为 {defaultSize} MB。", + }, resetPreferences: { sectionTitle: "危险区域", label: "重置所有本地偏好设置", description: - "清除仅保存在本地的偏好设置。聊天、API 访问权限和数据库中的聊天设置不会受到影响。", + "清除仅保存在本地的偏好设置。聊天、API 访问权限和数据库中的设置不会受到影响。", action: "重置偏好设置", confirmTitle: "重置所有本地偏好设置?", confirmDescription: - "这会清除仅保存在本地的偏好设置,然后重新加载 Studio。聊天、API 访问权限和数据库中的聊天设置不会受到影响。", + "这会清除仅保存在本地的偏好设置,然后重新加载 Studio。聊天、API 访问权限和数据库中的设置不会受到影响。", confirmAction: "重置并重新加载", }, }, @@ -243,10 +250,10 @@ export const zhCN = { actionsFor: "{name} 的操作", copyPrefix: "复制前缀", revokeToken: "撤销 token", - revokeTitle: "撤销访问 token \"{name}\"?", + revokeTitle: '撤销访问 token "{name}"?', revokeDescription: "使用此 token 的应用会立即失去访问权限。此操作无法撤销。", - revokeAction: "撤销 \"{name}\"", + revokeAction: '撤销 "{name}"', revoking: "撤销中...", }, about: { @@ -407,8 +414,7 @@ export const zhCN = { "可选。如果未提供,将从训练数据中切分出一小部分。", advanced: "高级", targetFormat: "目标格式", - targetFormatTooltip: - "训练数据的格式。自动检测对大多数数据集都有效。", + targetFormatTooltip: "训练数据的格式。自动检测对大多数数据集都有效。", auto: "自动", rawText: "原始文本", trainSplitStart: "训练切分起始", @@ -505,8 +511,7 @@ export const zhCN = { weightDecayTooltip: "L2 正则化,用于防止过拟合。", warmupSteps: "预热步数", warmupStepsTooltip: "在训练开始时逐步提高学习率,提升稳定性。", - scheduleEpochsTooltip: - "完整遍历数据集的次数。设为 0 则按最大步数运行。", + scheduleEpochsTooltip: "完整遍历数据集的次数。设为 0 则按最大步数运行。", saveSteps: "保存步数", saveStepsTooltip: "每 N 步保存一次检查点。0 表示禁用。", evalSteps: "评估步数", From 85692f1c1c0c19792b27f9e41d603bff776342ff Mon Sep 17 00:00:00 2001 From: oobabooga Date: Tue, 2 Jun 2026 13:26:15 -0300 Subject: [PATCH 5/5] Studio: persist Tauri window size and maximized state across launches (#5799) * Studio: persist Tauri window size and maximized state across launches * Studio: keep window state under the app home dir, not ~/.config * Undo an unnecessary change * Address Gemini's feedback * Revert to tauri-plugin-window-state implementation * fix(Studio): restore saved window size before default layout * Fix cross-platform window-state restore * fix(Studio): avoid clobbering saved window size --------- Co-authored-by: Wasim Yousef Said Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> --- studio/frontend/package-lock.json | 10 ++++ studio/frontend/package.json | 1 + studio/frontend/src/app/provider.tsx | 62 +++++++++++++++------- studio/src-tauri/Cargo.lock | 16 ++++++ studio/src-tauri/Cargo.toml | 1 + studio/src-tauri/capabilities/default.json | 3 +- studio/src-tauri/src/main.rs | 16 ++++++ 7 files changed, 88 insertions(+), 21 deletions(-) diff --git a/studio/frontend/package-lock.json b/studio/frontend/package-lock.json index 4afee8a916..e66f62d2f2 100644 --- a/studio/frontend/package-lock.json +++ b/studio/frontend/package-lock.json @@ -37,6 +37,7 @@ "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", + "@tauri-apps/plugin-window-state": "^2.4.1", "@toolwind/corner-shape": "^0.0.8-3", "@xyflow/react": "^12.10.0", "assistant-stream": "0.3.12", @@ -6239,6 +6240,15 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@tauri-apps/plugin-window-state": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz", + "integrity": "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@toolwind/corner-shape": { "version": "0.0.8-3", "resolved": "https://registry.npmjs.org/@toolwind/corner-shape/-/corner-shape-0.0.8-3.tgz", diff --git a/studio/frontend/package.json b/studio/frontend/package.json index 66b821d6c7..40e9458d93 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -46,6 +46,7 @@ "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", + "@tauri-apps/plugin-window-state": "^2.4.1", "@toolwind/corner-shape": "^0.0.8-3", "@xyflow/react": "^12.10.0", "assistant-stream": "0.3.12", diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx index 83238dadf0..6f3c7618be 100644 --- a/studio/frontend/src/app/provider.tsx +++ b/studio/frontend/src/app/provider.tsx @@ -26,6 +26,9 @@ interface AppProviderProps { type TauriWindowMode = "setup" | "app"; type WindowLayoutGuard = () => boolean; +const MIN_WINDOW_WIDTH = 900; +const MIN_WINDOW_HEIGHT = 600; + async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise { const { getCurrentWindow } = await import("@tauri-apps/api/window"); if (!isCurrent()) return; @@ -39,35 +42,54 @@ async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise { async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise { const { getCurrentWindow, currentMonitor, LogicalSize } = await import("@tauri-apps/api/window"); + const { invoke } = await import("@tauri-apps/api/core"); + const { restoreStateCurrent, StateFlags } = await import("@tauri-apps/plugin-window-state"); if (!isCurrent()) return; const win = getCurrentWindow(); - const monitor = await currentMonitor(); + // Decide first-launch vs restore from the on-disk state file BEFORE touching the + // window. Probing the window itself after restoreStateCurrent is unreliable: + // on GTK, set_size against a hidden window is deferred until show(), so + // innerSize() reads a stale value and any baseline fallback would overwrite the + // queued restore. On macOS the same probe works, hence the inconsistency + // between previous iterations of this code. + const hasSavedState = await invoke("has_saved_window_state"); if (!isCurrent()) return; - let finalW = 900; - let finalH = 600; - - if (monitor) { - const scale = monitor.scaleFactor; - const screenW = monitor.size.width / scale; - const screenH = monitor.size.height / scale; - - finalW = Math.max(900, Math.round(screenW * 0.75)); - const targetH = Math.max(600, Math.round(finalW / 1.618)); - finalH = Math.min(targetH, Math.round(screenH * 0.85)); - } - - if (!isCurrent()) return; - await win.setSize(new LogicalSize(finalW, finalH)); - if (!isCurrent()) return; - await win.setSizeConstraints({ minWidth: 900, minHeight: 600 }); - if (!isCurrent()) return; await win.setResizable(true); if (!isCurrent()) return; - await win.center(); + + if (hasSavedState) { + // Subsequent launch: the plugin handles size, position, and maximized, + // with built-in off-screen protection (monitor-intersection check) for + // positions saved on a now-disconnected display. + await restoreStateCurrent( + StateFlags.SIZE | StateFlags.POSITION | StateFlags.MAXIMIZED, + ); + } else { + // First launch: fit to the current monitor and center. + const monitor = await currentMonitor(); + if (!isCurrent()) return; + let finalW = MIN_WINDOW_WIDTH; + let finalH = MIN_WINDOW_HEIGHT; + if (monitor) { + const scale = monitor.scaleFactor; + const screenW = monitor.size.width / scale; + const screenH = monitor.size.height / scale; + finalW = Math.max(MIN_WINDOW_WIDTH, Math.round(screenW * 0.75)); + const targetH = Math.max(MIN_WINDOW_HEIGHT, Math.round(finalW / 1.618)); + finalH = Math.min(targetH, Math.round(screenH * 0.85)); + } + await win.setSize(new LogicalSize(finalW, finalH)); + if (!isCurrent()) return; + await win.center(); + } if (!isCurrent()) return; await win.show(); + if (!isCurrent()) return; + // Apply constraints after restore/show. Setting constraints before plugin restore + // can emit a Resized event and overwrite the plugin's cached saved size. + await win.setSizeConstraints({ minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT }); } async function showWindowFallback(): Promise { diff --git a/studio/src-tauri/Cargo.lock b/studio/src-tauri/Cargo.lock index df350e4011..b2398b9932 100644 --- a/studio/src-tauri/Cargo.lock +++ b/studio/src-tauri/Cargo.lock @@ -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", diff --git a/studio/src-tauri/Cargo.toml b/studio/src-tauri/Cargo.toml index 4002ab420d..2250f2774f 100644 --- a/studio/src-tauri/Cargo.toml +++ b/studio/src-tauri/Cargo.toml @@ -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" diff --git a/studio/src-tauri/capabilities/default.json b/studio/src-tauri/capabilities/default.json index 413fa14cc1..232472d6db 100644 --- a/studio/src-tauri/capabilities/default.json +++ b/studio/src-tauri/capabilities/default.json @@ -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" ] } diff --git a/studio/src-tauri/src/main.rs b/studio/src-tauri/src/main.rs index d07ff5e9d6..498cd81579 100644 --- a/studio/src-tauri/src/main.rs +++ b/studio/src-tauri/src/main.rs @@ -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> = 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"))]