+ );
+}
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 0e2083b31a..cdccf2fc72 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":
@@ -131,7 +136,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.
+
+
+
+
+
+ );
+}
From 5814e4534589558598eb64de87711e331dcfd3bb Mon Sep 17 00:00:00 2001
From: imagineer99
Date: Mon, 20 Apr 2026 19:44:51 +0100
Subject: [PATCH 3/5] Fix: textarea overflow in system prompt editor
---
studio/frontend/src/components/ui/textarea.tsx | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/studio/frontend/src/components/ui/textarea.tsx b/studio/frontend/src/components/ui/textarea.tsx
index b71e593958..36d86e28be 100644
--- a/studio/frontend/src/components/ui/textarea.tsx
+++ b/studio/frontend/src/components/ui/textarea.tsx
@@ -8,12 +8,12 @@ import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
);
}
From d3215ce11341178eee6384435603d8a7e7173622 Mon Sep 17 00:00:00 2001
From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Date: Mon, 20 Apr 2026 20:14:49 +0100
Subject: [PATCH 4/5] Studio: Show LoRA live logs and update GGUF quant options
(#5058)
* export: update GGUF quant list and ordering
* gguf: add Q2_K_L quantize flags for output and embeddings
* export: add live console logs for LoRA export flow
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* fix: stream q2_k_l quantize logs and include subprocess error details
* fix: route Q2_K_L preset to q2_k ftype with q8_0 output+embeddings
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
---
.../export/components/export-dialog.tsx | 40 +++++--
.../frontend/src/features/export/constants.ts | 6 +-
unsloth/save.py | 111 ++++++++++++++++--
3 files changed, 138 insertions(+), 19 deletions(-)
diff --git a/studio/frontend/src/features/export/components/export-dialog.tsx b/studio/frontend/src/features/export/components/export-dialog.tsx
index 401be3ff4b..176b10a52f 100644
--- a/studio/frontend/src/features/export/components/export-dialog.tsx
+++ b/studio/frontend/src/features/export/components/export-dialog.tsx
@@ -272,12 +272,13 @@ export function ExportDialog({
exportSuccess,
exportOutputPath,
}: ExportDialogProps) {
- // Live log capture is only meaningful for export methods that run
- // a slow subprocess operation with interesting stdout: merged and
- // gguf. LoRA adapter export is a fast disk write and would just
- // show a blank panel, so we hide it there.
+ // Live log capture is useful for any export path executed by the
+ // backend worker, including LoRA adapter-only export.
const showLogPanel =
- exportMethod === "merged" || exportMethod === "gguf";
+ exportMethod === "merged" ||
+ exportMethod === "gguf" ||
+ exportMethod === "lora";
+ const showCompletionScreen = exportSuccess && !showLogPanel;
const { lines: logLines, connected: logConnected, error: logError } =
useExportLogs(exporting && showLogPanel, exportMethod, open);
@@ -314,7 +315,7 @@ export function ExportDialog({
className={showLogPanel ? "sm:max-w-2xl" : "sm:max-w-lg"}
onInteractOutside={(e) => { if (exporting) e.preventDefault(); }}
>
- {exportSuccess ? (
+ {showCompletionScreen ? (
<>
@@ -460,6 +461,27 @@ export function ExportDialog({
)}
+ {/* Success banner for log-driven exports.
+ Keep users on the log screen after completion so they can
+ inspect conversion output before closing. */}
+ {exportSuccess && showLogPanel && (
+