mirror of
https://github.com/unslothai/unsloth
synced 2026-04-21 13:37:39 +00:00
Chat first onboarding (#5063)
* auth: default to chat * settings: relaunch onboarding * onboarding: return to launch page * studio: stop auto guided tour * ui: soften global radius * cleanup: rename onboarding exit prop * fix onboarding redirect safety * Show real Unsloth version in settings * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
f4422b0a62
commit
7ef65bd2e5
11 changed files with 131 additions and 38 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): OnboardingSearch => ({
|
||||
redirectTo: typeof search.redirectTo === "string" ? search.redirectTo : undefined,
|
||||
}),
|
||||
component: WizardLayout,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-b from-background via-background to-primary/5 p-6">
|
||||
|
|
@ -65,7 +65,7 @@ export function SplashScreen({
|
|||
<Button size="lg" onClick={onStartOnboarding}>
|
||||
Start Onboarding
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" onClick={onGoToStudio}>
|
||||
<Button size="lg" variant="outline" onClick={onSkipOnboarding}>
|
||||
Skip Onboarding
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -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 })
|
|||
<Button
|
||||
onClick={() => {
|
||||
markOnboardingDone();
|
||||
navigate({ to: "/studio" });
|
||||
window.location.assign(returnTo);
|
||||
}}
|
||||
disabled={!canProceed}
|
||||
className="px-4 !pr-4"
|
||||
>
|
||||
Go to Studio
|
||||
Finish onboarding
|
||||
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
|
||||
</Button>
|
||||
) : (
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ConfettiRef>(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 && (
|
||||
<SplashScreen
|
||||
onStartOnboarding={() => setShowSplash(false)}
|
||||
onGoToStudio={() => {
|
||||
onSkipOnboarding={() => {
|
||||
markOnboardingDone();
|
||||
window.location.href = "/studio";
|
||||
exitToReturnTo();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -91,10 +101,10 @@ export function WizardLayout() {
|
|||
}}
|
||||
>
|
||||
<Card className="relative z-10 w-full !gap-0 !m-0 !p-0 flex min-h-[560px] flex-col overflow-hidden shadow-border ring-1 ring-border md:min-h-[620px] md:flex-row lg:h-[660px]">
|
||||
<WizardSidebar />
|
||||
<WizardSidebar returnTo={returnTo} />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<WizardContent />
|
||||
<WizardFooter onBackToSplash={() => setShowSplash(true)} />
|
||||
<WizardFooter returnTo={returnTo} onBackToSplash={() => setShowSplash(true)} />
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
|
||||
</Button>
|
||||
<nav className="mt-3 hidden flex-col gap-1 md:flex">
|
||||
|
|
@ -54,10 +54,10 @@ export function WizardSidebar() {
|
|||
className="mt-3 hidden w-full md:flex"
|
||||
onClick={() => {
|
||||
markOnboardingDone();
|
||||
window.location.href = "/chat";
|
||||
window.location.assign(returnTo);
|
||||
}}
|
||||
>
|
||||
Skip to Chat
|
||||
Skip onboarding
|
||||
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
|
||||
</Button>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -13,17 +13,36 @@ import {
|
|||
MessageNotification01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
|
||||
const VERSION: string =
|
||||
(import.meta.env.VITE_APP_VERSION as string | undefined) ?? "dev";
|
||||
|
||||
export function AboutTab() {
|
||||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const defaultShell = deviceType === "windows" ? "windows" : "unix";
|
||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||
const [version, setVersion] = useState("dev");
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/health");
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { version?: string };
|
||||
if (!canceled && data.version) {
|
||||
setVersion(data.version);
|
||||
}
|
||||
} catch {
|
||||
// fall back to dev label
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
|
@ -36,7 +55,7 @@ export function AboutTab() {
|
|||
|
||||
<SettingsSection title="Studio">
|
||||
<SettingsRow label="Version">
|
||||
<code className="font-mono text-xs text-muted-foreground">{VERSION}</code>
|
||||
<code className="font-mono text-xs text-muted-foreground">{version}</code>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { resetOnboardingDone } from "@/features/auth";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import { useSettingsDialogStore } from "@/features/settings";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
|
|
@ -75,10 +78,24 @@ function resetAllPrefs() {
|
|||
}
|
||||
|
||||
export function GeneralTab() {
|
||||
const navigate = useNavigate();
|
||||
const closeDialog = useSettingsDialogStore((s) => s.closeDialog);
|
||||
const { pathname, search } = useRouterState({
|
||||
select: (s) => ({
|
||||
pathname: s.location.pathname,
|
||||
search:
|
||||
"searchStr" in s.location
|
||||
? (s.location as { searchStr?: string }).searchStr ?? ""
|
||||
: typeof window !== "undefined"
|
||||
? window.location.search
|
||||
: "",
|
||||
}),
|
||||
});
|
||||
const hfToken = useChatRuntimeStore((s) => s.hfToken);
|
||||
const setHfToken = useChatRuntimeStore((s) => s.setHfToken);
|
||||
const autoTitle = useChatRuntimeStore((s) => s.autoTitle);
|
||||
const setAutoTitle = useChatRuntimeStore((s) => s.setAutoTitle);
|
||||
const redirectTo = `${pathname}${search}`;
|
||||
|
||||
const [draftToken, setDraftToken] = useState(hfToken ?? "");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
|
@ -153,6 +170,25 @@ export function GeneralTab() {
|
|||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Getting started">
|
||||
<SettingsRow
|
||||
label="Start onboarding"
|
||||
description="Open the setup wizard again without changing your account."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
resetOnboardingDone();
|
||||
closeDialog();
|
||||
navigate({ to: "/onboarding", search: { redirectTo } });
|
||||
}}
|
||||
>
|
||||
Start onboarding
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Danger zone">
|
||||
<SettingsRow
|
||||
destructive
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ import { LiveTrainingView } from "./live-training-view";
|
|||
import { HistoricalTrainingView } from "./historical-training-view";
|
||||
import { HistoryCardGrid } from "./history-card-grid";
|
||||
|
||||
const STUDIO_TOUR_KEY = "tour:studio:v1";
|
||||
|
||||
export function StudioPage(): ReactElement {
|
||||
useTrainingRuntimeLifecycle();
|
||||
const showTrainingView = useTrainingRuntimeStore(shouldShowTrainingView);
|
||||
|
|
@ -85,8 +83,6 @@ export function StudioPage(): ReactElement {
|
|||
id: "studio",
|
||||
steps: tourSteps,
|
||||
enabled: tourEnabled,
|
||||
autoKey: isConfigTour ? STUDIO_TOUR_KEY : undefined,
|
||||
autoWhen: isConfigTour,
|
||||
});
|
||||
|
||||
const setTourOpen = tour.setOpen;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
--chart-3: oklch(0.7014 0.1193 197.5897);
|
||||
--chart-4: oklch(0.6926 0.1112 346.5775);
|
||||
--chart-5: oklch(0.7497 0.1003 85.0057);
|
||||
--radius: 0.625rem;
|
||||
--radius: 1.1rem;
|
||||
--sidebar: oklch(0.99 0 0);
|
||||
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
|
||||
|
|
@ -132,7 +132,7 @@
|
|||
--sidebar-border: oklch(0.38 0 0);
|
||||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--radius: 0.625rem;
|
||||
--radius: 1.1rem;
|
||||
--font-sans: Geist, ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
|
|
@ -198,7 +198,7 @@
|
|||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--radius: 0.625rem;
|
||||
--radius: 1.1rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
|
|
|
|||
Loading…
Reference in a new issue