diff --git a/studio/backend/main.py b/studio/backend/main.py index 8a40791c0..d146a8ef1 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -27,6 +27,7 @@ import mimetypes import shutil import warnings from contextlib import asynccontextmanager +from importlib.metadata import PackageNotFoundError, version as package_version # Fix broken Windows registry MIME types. Some Windows installs map .js to # "text/plain" in the registry (HKCR\.js\Content Type). Python's mimetypes @@ -78,6 +79,27 @@ import utils.hardware.hardware as _hw_module from utils.cache_cleanup import clear_unsloth_compiled_cache +def get_unsloth_version() -> str: + try: + return package_version("unsloth") + except PackageNotFoundError: + pass + + version_file = ( + _Path(__file__).resolve().parents[2] / "unsloth" / "models" / "_utils.py" + ) + try: + for line in version_file.read_text(encoding = "utf-8").splitlines(): + if line.startswith("__version__ = "): + return line.split("=", 1)[1].strip().strip('"').strip("'") + except OSError: + pass + return "dev" + + +UNSLOTH_VERSION = get_unsloth_version() + + @asynccontextmanager async def lifespan(app: FastAPI): """Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache.""" @@ -140,7 +162,7 @@ async def lifespan(app: FastAPI): # Create FastAPI app app = FastAPI( title = "Unsloth UI Backend", - version = "1.0.0", + version = UNSLOTH_VERSION, description = "Backend API for Unsloth UI - Training and Model Management", lifespan = lifespan, ) @@ -198,6 +220,7 @@ async def health_check(): "status": "healthy", "timestamp": datetime.now().isoformat(), "service": "Unsloth UI Backend", + "version": UNSLOTH_VERSION, "device_type": device_type, "chat_only": _hw_module.CHAT_ONLY, } diff --git a/studio/frontend/src/app/routes/onboarding.tsx b/studio/frontend/src/app/routes/onboarding.tsx index dcc3593b1..8d1cd6ff5 100644 --- a/studio/frontend/src/app/routes/onboarding.tsx +++ b/studio/frontend/src/app/routes/onboarding.tsx @@ -6,6 +6,8 @@ import { lazy } from "react"; import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; +export type OnboardingSearch = { redirectTo?: string }; + const WizardLayout = lazy(() => import("@/features/onboarding/components/wizard-layout").then((m) => ({ default: m.WizardLayout, @@ -16,5 +18,8 @@ export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/onboarding", beforeLoad: () => requireAuth(), + validateSearch: (search: Record): OnboardingSearch => ({ + redirectTo: typeof search.redirectTo === "string" ? search.redirectTo : undefined, + }), component: WizardLayout, }); diff --git a/studio/frontend/src/features/auth/session.ts b/studio/frontend/src/features/auth/session.ts index 3d3502e07..601217407 100644 --- a/studio/frontend/src/features/auth/session.ts +++ b/studio/frontend/src/features/auth/session.ts @@ -8,7 +8,7 @@ export const AUTH_REFRESH_TOKEN_KEY = "unsloth_auth_refresh_token"; export const ONBOARDING_DONE_KEY = "unsloth_onboarding_done"; export const AUTH_MUST_CHANGE_PASSWORD_KEY = "unsloth_auth_must_change_password"; -type PostAuthRoute = "/onboarding" | "/studio" | "/change-password" | "/chat"; +type PostAuthRoute = "/change-password" | "/chat"; function canUseStorage(): boolean { return typeof window !== "undefined"; @@ -80,5 +80,5 @@ export function resetOnboardingDone(): void { export function getPostAuthRoute(): PostAuthRoute { if (mustChangePassword()) return "/change-password"; if (usePlatformStore.getState().isChatOnly()) return "/chat"; - return isOnboardingDone() ? "/studio" : "/onboarding"; + return "/chat"; } diff --git a/studio/frontend/src/features/onboarding/components/splash-screen.tsx b/studio/frontend/src/features/onboarding/components/splash-screen.tsx index 7f5e3619c..ce828ee7b 100644 --- a/studio/frontend/src/features/onboarding/components/splash-screen.tsx +++ b/studio/frontend/src/features/onboarding/components/splash-screen.tsx @@ -7,12 +7,12 @@ import { motion } from "motion/react"; interface SplashScreenProps { onStartOnboarding: () => void; - onGoToStudio: () => void; + onSkipOnboarding: () => void; } export function SplashScreen({ onStartOnboarding, - onGoToStudio, + onSkipOnboarding, }: SplashScreenProps) { return (
@@ -65,7 +65,7 @@ export function SplashScreen({ - diff --git a/studio/frontend/src/features/onboarding/components/wizard-footer.tsx b/studio/frontend/src/features/onboarding/components/wizard-footer.tsx index 81e2f2738..4588c0a63 100644 --- a/studio/frontend/src/features/onboarding/components/wizard-footer.tsx +++ b/studio/frontend/src/features/onboarding/components/wizard-footer.tsx @@ -7,10 +7,15 @@ import { markOnboardingDone } from "@/features/auth"; import { useTrainingConfigStore } from "@/features/training"; import { ArrowLeft02Icon, ArrowRight02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; -export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void }) { +export function WizardFooter({ + returnTo, + onBackToSplash, +}: { + returnTo: string; + onBackToSplash: () => void; +}) { const { currentStep, prevStep, nextStep, canProceed } = useTrainingConfigStore( useShallow((s) => ({ currentStep: s.currentStep, @@ -19,7 +24,6 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void }) canProceed: s.canProceed(), })), ); - const navigate = useNavigate(); const isFirst = currentStep === 1; const isLast = currentStep === STEPS.length; @@ -41,7 +45,7 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void }) className="px-4" onClick={() => { markOnboardingDone(); - navigate({ to: "/studio" }); + window.location.assign(returnTo); }} > Skip @@ -51,12 +55,12 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void }) ) : ( @@ -65,7 +69,7 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void }) if (currentStep === 1 && sessionStorage.getItem("unsloth_chat_only") === "1") { sessionStorage.removeItem("unsloth_chat_only"); markOnboardingDone(); - window.location.href = "/chat"; + window.location.assign(returnTo); } else { nextStep(); } diff --git a/studio/frontend/src/features/onboarding/components/wizard-layout.tsx b/studio/frontend/src/features/onboarding/components/wizard-layout.tsx index 940ac333b..6df873ffd 100644 --- a/studio/frontend/src/features/onboarding/components/wizard-layout.tsx +++ b/studio/frontend/src/features/onboarding/components/wizard-layout.tsx @@ -2,7 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { Card } from "@/components/ui/card"; -import { useNavigate } from "@tanstack/react-router"; +import { Route as OnboardingRoute } from "@/app/routes/onboarding"; import { motion } from "motion/react"; import { Suspense, lazy, useEffect, useRef, useState } from "react"; @@ -19,13 +19,23 @@ const Confetti = lazy(() => import("@/components/ui/confetti").then((m) => ({ default: m.Confetti })), ); +function sanitizeRedirectTarget(value: string | undefined): string { + if (!value) return "/chat"; + if (!value.startsWith("/")) return "/chat"; + if (value.startsWith("//")) return "/chat"; + if (value.includes("\\")) return "/chat"; + return value; +} + export function WizardLayout() { - const navigate = useNavigate(); + const search = OnboardingRoute.useSearch(); const [showSplash, setShowSplash] = useState(true); const currentStep = useTrainingConfigStore((s) => s.currentStep); const confettiRef = useRef(null); const hasFiredRef = useRef(false); const isFinalStep = currentStep === STEPS.length; + const returnTo = sanitizeRedirectTarget(search.redirectTo); + const exitToReturnTo = () => window.location.assign(returnTo); // Only redirect on initial mount — not on re-renders after markOnboardingDone() // which would override explicit /chat navigation from skip buttons. @@ -34,10 +44,10 @@ export function WizardLayout() { if (!checkedRef.current) { checkedRef.current = true; if (isOnboardingDone()) { - navigate({ to: "/studio" }); + exitToReturnTo(); } } - }, [navigate]); + }, [returnTo]); useEffect(() => { if (isFinalStep && !hasFiredRef.current) { @@ -67,9 +77,9 @@ export function WizardLayout() { {showSplash && ( setShowSplash(false)} - onGoToStudio={() => { + onSkipOnboarding={() => { markOnboardingDone(); - window.location.href = "/studio"; + exitToReturnTo(); }} /> )} @@ -91,10 +101,10 @@ export function WizardLayout() { }} > - +
- setShowSplash(true)} /> + setShowSplash(true)} />
diff --git a/studio/frontend/src/features/onboarding/components/wizard-sidebar.tsx b/studio/frontend/src/features/onboarding/components/wizard-sidebar.tsx index 70621fc1b..d22ac19e5 100644 --- a/studio/frontend/src/features/onboarding/components/wizard-sidebar.tsx +++ b/studio/frontend/src/features/onboarding/components/wizard-sidebar.tsx @@ -10,7 +10,7 @@ import { ArrowRight02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { WizardStepItem } from "./wizard-step-item"; -export function WizardSidebar() { +export function WizardSidebar({ returnTo }: { returnTo: string }) { const currentStep = useTrainingConfigStore((s) => s.currentStep); const progress = ((currentStep - 1) / (STEPS.length - 1)) * 100; @@ -38,10 +38,10 @@ export function WizardSidebar() { className="mt-2 w-full md:hidden" onClick={() => { markOnboardingDone(); - window.location.href = "/chat"; + window.location.assign(returnTo); }} > - Skip to Chat + Skip onboarding