diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 97be8943a9..66a828cc19 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -57,6 +57,7 @@ import { ChevronDown, ChevronsUpDown, Moon, Sun } from "lucide-react"; import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; import { useTrainingRuntimeStore } from "@/features/training"; import { useSettingsDialogStore } from "@/features/settings"; +import { useEffectiveProfile, UserAvatar } from "@/features/profile"; import { usePlatformStore } from "@/config/env"; import { TOUR_OPEN_EVENT } from "@/features/tour"; import { @@ -182,6 +183,7 @@ export function AppSidebar() { useEffect(() => { if (isStudioRoute) setRunsOpen(true); }, [isStudioRoute]); const isRecipesRoute = pathname.startsWith("/data-recipes"); + const { displayTitle, avatarDataUrl } = useEffectiveProfile(); const { items: chatItems } = useChatSidebarItems(); const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); @@ -509,16 +511,19 @@ export function AppSidebar() { -
- U +
+
- User + {displayTitle} Studio
diff --git a/studio/frontend/src/features/auth/index.ts b/studio/frontend/src/features/auth/index.ts index 75db92432c..af629cfb9a 100644 --- a/studio/frontend/src/features/auth/index.ts +++ b/studio/frontend/src/features/auth/index.ts @@ -5,6 +5,7 @@ export { LoginPage } from "./login-page"; export { ChangePasswordPage } from "./change-password-page"; export { authFetch, refreshSession } from "./api"; export { + getAuthToken, getPostAuthRoute, hasAuthToken, hasRefreshToken, diff --git a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx new file mode 100644 index 0000000000..994115bc19 --- /dev/null +++ b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx @@ -0,0 +1,157 @@ +// 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 { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { getAuthToken } from "@/features/auth"; +import { toastError, toastSuccess } from "@/shared/toast"; +import { Camera } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { decodeJwtSubject } from "../utils/jwt-subject"; +import { resizeImageFileToDataUrl } from "../utils/resize-image-file"; +import { useUserProfileStore } from "../stores/user-profile-store"; +import { UserAvatar } from "./user-avatar"; + +const PROFILE_STORAGE_KEY = "unsloth_user_profile"; + +function readPersistedProfile(): { displayName: string; avatarDataUrl: string | null } | null { + try { + const raw = window.localStorage.getItem(PROFILE_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return null; + + // Zustand persist shape: { state: {...}, version } + const maybeState = "state" in parsed ? (parsed as { state?: unknown }).state : parsed; + if (!maybeState || typeof maybeState !== "object") return null; + const state = maybeState as { displayName?: unknown; avatarDataUrl?: unknown }; + + return { + displayName: typeof state.displayName === "string" ? state.displayName : "", + avatarDataUrl: typeof state.avatarDataUrl === "string" ? state.avatarDataUrl : null, + }; + } catch { + return null; + } +} + +export function ProfilePersonalizationPanel() { + const displayName = useUserProfileStore((s) => s.displayName); + const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl); + const setDisplayName = useUserProfileStore((s) => s.setDisplayName); + const setAvatarDataUrl = useUserProfileStore((s) => s.setAvatarDataUrl); + + const [imageError, setImageError] = useState(null); + const [draftName, setDraftName] = useState(displayName); + const fileInputRef = useRef(null); + + const sessionSub = decodeJwtSubject(getAuthToken()) ?? ""; + const previewName = draftName.trim() || sessionSub || "Unsloth"; + const hasNameChanges = useMemo( + () => draftName.trim() !== displayName.trim(), + [draftName, displayName], + ); + + const saveName = () => { + const trimmed = draftName.trim(); + if (trimmed !== draftName) setDraftName(trimmed); + if (trimmed !== displayName) { + setDisplayName(trimmed); + const persisted = readPersistedProfile(); + if (persisted && persisted.displayName === trimmed) { + toastSuccess("Profile name saved"); + } else { + toastError( + "Could not persist profile name", + "Name updated for this session, but may not persist after reload.", + ); + } + } + }; + + const onPickFile = async (file: File | undefined) => { + if (!file) return; + setImageError(null); + try { + const dataUrl = await resizeImageFileToDataUrl(file); + setAvatarDataUrl(dataUrl); + const persisted = readPersistedProfile(); + if (persisted && persisted.avatarDataUrl === dataUrl) { + toastSuccess("Profile photo updated"); + } else { + toastError( + "Could not persist profile photo", + "Photo updated for this session, but may not persist after reload.", + ); + } + } catch (e) { + const message = e instanceof Error ? e.message : "Could not use this image."; + setImageError(message); + toastError("Could not update profile photo", message); + } + }; + + return ( +
+
+ + { + void onPickFile(e.target.files?.[0]); + e.target.value = ""; + }} + /> + +
+ +
+ +
+ setDraftName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + saveName(); + } + }} + autoComplete="off" + placeholder={sessionSub || "Unsloth"} + className="h-10 min-w-0 flex-1 rounded-lg text-sm" + /> + +
+
+ + {imageError ? ( +

+ {imageError} +

+ ) : null} +
+ ); +} diff --git a/studio/frontend/src/features/profile/components/user-avatar.tsx b/studio/frontend/src/features/profile/components/user-avatar.tsx new file mode 100644 index 0000000000..62e37f5133 --- /dev/null +++ b/studio/frontend/src/features/profile/components/user-avatar.tsx @@ -0,0 +1,45 @@ +// 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 { cn } from "@/lib/utils"; +import { avatarBgStyle, initialsFromName } from "../utils/avatar-initials"; + +type UserAvatarProps = { + name: string; + imageUrl: string | null; + size: "sm" | "md" | "lg"; + className?: string; +}; + +const SIZE: Record<"sm" | "md" | "lg", string> = { + sm: "size-9 text-xs", + md: "size-11 text-sm", + /** ~10% larger than `size-24` / `text-2xl` for the edit-profile dialog. */ + lg: "size-[106px] text-[1.65rem]", +}; + +export function UserAvatar({ name, imageUrl, size, className }: UserAvatarProps) { + const label = initialsFromName(name); + + if (imageUrl) { + return ( + + + + ); + } + + return ( + + {label} + + ); +} diff --git a/studio/frontend/src/features/profile/hooks/use-effective-profile.ts b/studio/frontend/src/features/profile/hooks/use-effective-profile.ts new file mode 100644 index 0000000000..3b64519e92 --- /dev/null +++ b/studio/frontend/src/features/profile/hooks/use-effective-profile.ts @@ -0,0 +1,19 @@ +// 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 { getAuthToken } from "@/features/auth"; +import { decodeJwtSubject } from "../utils/jwt-subject"; +import { useUserProfileStore } from "../stores/user-profile-store"; + +export function useEffectiveProfile() { + const displayName = useUserProfileStore((s) => s.displayName); + const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl); + + const sessionSub = decodeJwtSubject(getAuthToken()); + const dn = displayName.trim(); + return { + sessionSub, + displayTitle: dn || "Unsloth", + avatarDataUrl, + }; +} diff --git a/studio/frontend/src/features/profile/index.ts b/studio/frontend/src/features/profile/index.ts new file mode 100644 index 0000000000..feec20607e --- /dev/null +++ b/studio/frontend/src/features/profile/index.ts @@ -0,0 +1,6 @@ +// 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 { ProfilePersonalizationPanel } from "./components/profile-personalization-panel"; +export { UserAvatar } from "./components/user-avatar"; +export { useEffectiveProfile } from "./hooks/use-effective-profile"; diff --git a/studio/frontend/src/features/profile/stores/user-profile-store.ts b/studio/frontend/src/features/profile/stores/user-profile-store.ts new file mode 100644 index 0000000000..5bbb4d11c9 --- /dev/null +++ b/studio/frontend/src/features/profile/stores/user-profile-store.ts @@ -0,0 +1,24 @@ +// 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 { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export interface UserProfileState { + displayName: string; + avatarDataUrl: string | null; + setDisplayName: (displayName: string) => void; + setAvatarDataUrl: (avatarDataUrl: string | null) => void; +} + +export const useUserProfileStore = create()( + persist( + (set) => ({ + displayName: "", + avatarDataUrl: null, + setDisplayName: (displayName) => set({ displayName }), + setAvatarDataUrl: (avatarDataUrl) => set({ avatarDataUrl }), + }), + { name: "unsloth_user_profile" }, + ), +); diff --git a/studio/frontend/src/features/profile/utils/avatar-initials.ts b/studio/frontend/src/features/profile/utils/avatar-initials.ts new file mode 100644 index 0000000000..926f57f3b5 --- /dev/null +++ b/studio/frontend/src/features/profile/utils/avatar-initials.ts @@ -0,0 +1,13 @@ +// 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 function initialsFromName(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return "?"; + return trimmed[0]!.toUpperCase(); +} + +/** Default blue background for avatar fallback (readable white text). */ +export function avatarBgStyle(): { backgroundColor: string } { + return { backgroundColor: "hsl(217 58% 48%)" }; +} diff --git a/studio/frontend/src/features/profile/utils/jwt-subject.ts b/studio/frontend/src/features/profile/utils/jwt-subject.ts new file mode 100644 index 0000000000..9c7596966a --- /dev/null +++ b/studio/frontend/src/features/profile/utils/jwt-subject.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +/** + * Read the JWT `sub` claim for display purposes only (not verified). + */ +export function decodeJwtSubject(token: string | null): string | null { + if (!token) return null; + try { + const parts = token.split("."); + if (parts.length < 2) return null; + const payload = parts[1]; + const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const json = atob(padded); + const parsed = JSON.parse(json) as { sub?: unknown }; + return typeof parsed.sub === "string" ? parsed.sub : null; + } catch { + return null; + } +} diff --git a/studio/frontend/src/features/profile/utils/resize-image-file.ts b/studio/frontend/src/features/profile/utils/resize-image-file.ts new file mode 100644 index 0000000000..3f829ba975 --- /dev/null +++ b/studio/frontend/src/features/profile/utils/resize-image-file.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +const MAX_EDGE = 256; +const MAX_BYTES = 380_000; + +function loadImage(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + resolve(img); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Could not load image")); + }; + img.src = url; + }); +} + +/** + * Downscale and re-encode as JPEG so localStorage stays within reasonable size. + */ +export async function resizeImageFileToDataUrl(file: File): Promise { + const img = await loadImage(file); + const w = img.naturalWidth; + const h = img.naturalHeight; + if (!w || !h) throw new Error("Invalid image dimensions"); + + const scale = Math.min(1, MAX_EDGE / Math.max(w, h)); + const cw = Math.max(1, Math.round(w * scale)); + const ch = Math.max(1, Math.round(h * scale)); + + const canvas = document.createElement("canvas"); + canvas.width = cw; + canvas.height = ch; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Canvas not available"); + ctx.drawImage(img, 0, 0, cw, ch); + + let quality = 0.88; + let dataUrl = canvas.toDataURL("image/jpeg", quality); + while (dataUrl.length > MAX_BYTES * 1.35 && quality > 0.45) { + quality -= 0.08; + dataUrl = canvas.toDataURL("image/jpeg", quality); + } + if (dataUrl.length > MAX_BYTES * 1.35) { + throw new Error("Image is still too large after compression. Try a smaller file."); + } + return dataUrl; +} diff --git a/studio/frontend/src/features/settings/settings-dialog.tsx b/studio/frontend/src/features/settings/settings-dialog.tsx index 6c5cb46436..bb22fcb89c 100644 --- a/studio/frontend/src/features/settings/settings-dialog.tsx +++ b/studio/frontend/src/features/settings/settings-dialog.tsx @@ -15,6 +15,7 @@ import { PaintBrush02Icon, Settings02Icon, SparklesIcon, + UserIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { motion, useReducedMotion } from "motion/react"; @@ -24,6 +25,7 @@ import { ApiKeysTab } from "./tabs/api-keys-tab"; import { AppearanceTab } from "./tabs/appearance-tab"; import { ChatTab } from "./tabs/chat-tab"; import { GeneralTab } from "./tabs/general-tab"; +import { ProfileTab } from "./tabs/profile-tab"; interface TabDef { id: SettingsTab; @@ -33,6 +35,7 @@ interface TabDef { const TABS: TabDef[] = [ { id: "general", label: "General", icon: Settings02Icon }, + { id: "profile", label: "Profile", icon: UserIcon }, { id: "appearance", label: "Appearance", icon: PaintBrush02Icon }, { id: "chat", label: "Chat", icon: Message01Icon }, { id: "api-keys", label: "API Keys", icon: Key01Icon }, @@ -43,6 +46,8 @@ function renderTab(tab: SettingsTab) { switch (tab) { case "general": return ; + case "profile": + return ; case "appearance": return ; case "chat": @@ -132,7 +137,7 @@ export function SettingsDialog() { > -
+
{renderTab(activeTab)}
diff --git a/studio/frontend/src/features/settings/stores/settings-dialog-store.ts b/studio/frontend/src/features/settings/stores/settings-dialog-store.ts index 75eb53048e..d1fd4a1d0f 100644 --- a/studio/frontend/src/features/settings/stores/settings-dialog-store.ts +++ b/studio/frontend/src/features/settings/stores/settings-dialog-store.ts @@ -5,6 +5,7 @@ import { create } from "zustand"; export type SettingsTab = | "general" + | "profile" | "appearance" | "chat" | "api-keys" @@ -28,7 +29,7 @@ function loadInitialTab(): SettingsTab { } catch { return "general"; } - const valid: SettingsTab[] = ["general", "appearance", "chat", "api-keys", "about"]; + const valid: SettingsTab[] = ["general", "profile", "appearance", "chat", "api-keys", "about"]; return valid.includes(stored as SettingsTab) ? (stored as SettingsTab) : "general"; } diff --git a/studio/frontend/src/features/settings/tabs/general-tab.tsx b/studio/frontend/src/features/settings/tabs/general-tab.tsx index 874508a6fb..6081e90605 100644 --- a/studio/frontend/src/features/settings/tabs/general-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/general-tab.tsx @@ -56,6 +56,8 @@ const PREFS_KEYS: string[] = [ "unsloth_training_config_v1", "unsloth_prev_max_steps", "unsloth_prev_save_steps", + // Profile personalization + "unsloth_user_profile", // Guided tour flags "tour:studio:v1", ]; diff --git a/studio/frontend/src/features/settings/tabs/profile-tab.tsx b/studio/frontend/src/features/settings/tabs/profile-tab.tsx new file mode 100644 index 0000000000..2ae283b767 --- /dev/null +++ b/studio/frontend/src/features/settings/tabs/profile-tab.tsx @@ -0,0 +1,19 @@ +// 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 { ProfilePersonalizationPanel } from "@/features/profile"; + +export function ProfileTab() { + return ( +
+
+

Profile

+

+ Update how your profile appears in Studio. +

+
+ + +
+ ); +}