Merge remote-tracking branch 'imagineer99/fix/chatbox-scroll-menu-cd4e390d' into pr-5095
This commit is contained in:
commit
02a08d1288
31 changed files with 617 additions and 95 deletions
|
|
@ -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() {
|
|||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
aria-label={`${displayTitle} account menu`}
|
||||
className="!h-[50px] gap-[8px] rounded-[8px] text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec]! dark:hover:bg-[#2e3035]! hover:text-black! dark:hover:text-white! data-[state=open]:bg-[#ececec]! dark:data-[state=open]:bg-[#2e3035]! data-[state=open]:text-black! dark:data-[state=open]:text-white!"
|
||||
>
|
||||
<div
|
||||
aria-label="User"
|
||||
className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-full border border-sidebar-border bg-primary text-[16px] font-bold text-primary-foreground"
|
||||
>
|
||||
U
|
||||
<div className="shrink-0">
|
||||
<UserAvatar
|
||||
name={displayTitle}
|
||||
imageUrl={avatarDataUrl}
|
||||
size="sm"
|
||||
className="!size-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-heading text-[13px] font-semibold text-[#383835] dark:text-[#c7c7c4]">User</span>
|
||||
<span className="truncate font-heading text-[13px] font-semibold text-[#383835] dark:text-[#c7c7c4]">{displayTitle}</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">Studio</span>
|
||||
</div>
|
||||
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
|
||||
|
|
|
|||
|
|
@ -276,8 +276,8 @@ function MermaidCopyButton({ source }: { source: string }) {
|
|||
type="button"
|
||||
className="absolute top-3.5 right-20 z-20 cursor-pointer text-muted-foreground transition-all hover:text-foreground"
|
||||
title="Copy Mermaid source"
|
||||
onClick={() => {
|
||||
if (!copyToClipboard(source)) {
|
||||
onClick={async () => {
|
||||
if (!(await copyToClipboard(source))) {
|
||||
return;
|
||||
}
|
||||
showCopied();
|
||||
|
|
@ -310,8 +310,8 @@ function CodeBlockActions({
|
|||
className={ACTION_BUTTON_CLASS}
|
||||
title="Copy code"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!copyToClipboard(source)) {
|
||||
onClick={async () => {
|
||||
if (!(await copyToClipboard(source))) {
|
||||
return;
|
||||
}
|
||||
showCopied();
|
||||
|
|
|
|||
|
|
@ -282,8 +282,8 @@ function ReasoningCopyButton({ startIndex, endIndex }: { startIndex: number; end
|
|||
.join("\n");
|
||||
});
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (copyToClipboard(reasoningText)) {
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (await copyToClipboard(reasoningText)) {
|
||||
setCopied(true);
|
||||
if (resetRef.current) clearTimeout(resetRef.current);
|
||||
resetRef.current = setTimeout(() => setCopied(false), COPY_RESET_MS);
|
||||
|
|
|
|||
|
|
@ -701,9 +701,9 @@ const CopyButton: FC = () => {
|
|||
const [copied, setCopied] = useState(false);
|
||||
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCopy = () => {
|
||||
const handleCopy = async () => {
|
||||
const text = aui.message().getCopyText();
|
||||
if (copyToClipboard(text)) {
|
||||
if (await copyToClipboard(text)) {
|
||||
setCopied(true);
|
||||
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
|
||||
resetTimeoutRef.current = setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ function CopyBtn({ text }: { text: string }) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (copyToClipboard(text)) {
|
||||
const copy = useCallback(async () => {
|
||||
if (await copyToClipboard(text)) {
|
||||
setCopied(true);
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ function CopyBtn({ text }: { text: string }) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (copyToClipboard(text)) {
|
||||
const copy = useCallback(async () => {
|
||||
if (await copyToClipboard(text)) {
|
||||
setCopied(true);
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ import { cn } from "@/lib/utils";
|
|||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-xl border px-3 py-3 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-xl border px-3 py-3 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex min-h-16 min-w-0 max-w-full w-full whitespace-pre-wrap break-words [overflow-wrap:anywhere] outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -58,23 +58,36 @@ export function useChatSidebarItems() {
|
|||
return { items, canCompare };
|
||||
}
|
||||
|
||||
function cancelIfRunning(threadId: string): void {
|
||||
const { runningByThreadId, cancelByThreadId } =
|
||||
useChatRuntimeStore.getState();
|
||||
if (!runningByThreadId[threadId]) return;
|
||||
cancelByThreadId[threadId]?.();
|
||||
}
|
||||
|
||||
export async function deleteChatItem(
|
||||
item: SidebarItem,
|
||||
activeId: string | undefined,
|
||||
onSelect: (view: { mode: "single"; newThreadNonce: string }) => void,
|
||||
) {
|
||||
const threadIds: string[] =
|
||||
item.type === "single"
|
||||
? [item.id]
|
||||
: (await db.threads.where("pairId").equals(item.id).toArray()).map(
|
||||
(t) => t.id,
|
||||
);
|
||||
|
||||
// Stop any in-flight streams before deleting, so the model doesn't keep
|
||||
// generating against a thread that no longer exists.
|
||||
for (const id of threadIds) cancelIfRunning(id);
|
||||
|
||||
await db.transaction("rw", db.threads, db.messages, async () => {
|
||||
if (item.type === "single") {
|
||||
await db.messages.where("threadId").equals(item.id).delete();
|
||||
await db.threads.delete(item.id);
|
||||
} else {
|
||||
const paired = await db.threads.where("pairId").equals(item.id).toArray();
|
||||
for (const t of paired) {
|
||||
await db.messages.where("threadId").equals(t.id).delete();
|
||||
await db.threads.delete(t.id);
|
||||
}
|
||||
for (const id of threadIds) {
|
||||
await db.messages.where("threadId").equals(id).delete();
|
||||
await db.threads.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (activeId === item.id) {
|
||||
useChatRuntimeStore.getState().setActiveThreadId(null);
|
||||
onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() });
|
||||
|
|
|
|||
|
|
@ -730,6 +730,34 @@ function ActiveThreadSync({
|
|||
return null;
|
||||
}
|
||||
|
||||
// Exposes the current thread's cancelRun() via the shared store so external
|
||||
// surfaces (e.g. the sidebar trash button) can stop an in-flight stream
|
||||
// before deleting the thread — mirroring the Stop → Trash sequence.
|
||||
function CancelRegistrar(): ReactElement | null {
|
||||
const aui = useAui();
|
||||
const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId);
|
||||
const isRunning = useChatRuntimeStore((s) =>
|
||||
mainThreadId ? Boolean(s.runningByThreadId[mainThreadId]) : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mainThreadId || !isRunning) return;
|
||||
const cancel = () => {
|
||||
try {
|
||||
aui.thread().cancelRun();
|
||||
} catch {
|
||||
// Run may have already ended between the caller's read and this call.
|
||||
}
|
||||
};
|
||||
useChatRuntimeStore.getState().registerThreadCancel(mainThreadId, cancel);
|
||||
return () => {
|
||||
useChatRuntimeStore.getState().clearThreadCancel(mainThreadId);
|
||||
};
|
||||
}, [aui, mainThreadId, isRunning]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ChatRuntimeProvider({
|
||||
children,
|
||||
modelType = "base",
|
||||
|
|
@ -762,6 +790,7 @@ export function ChatRuntimeProvider({
|
|||
<ActiveThreadSync
|
||||
enabled={modelType === "base" && !pairId && !newThreadNonce && !initialThreadId}
|
||||
/>
|
||||
<CancelRegistrar />
|
||||
{initialThreadId && (
|
||||
<ThreadAutoSwitch
|
||||
threadId={initialThreadId}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ type ChatRuntimeStore = {
|
|||
models: ChatModelSummary[];
|
||||
loras: ChatLoraSummary[];
|
||||
runningByThreadId: Record<string, boolean>;
|
||||
cancelByThreadId: Record<string, () => void>;
|
||||
autoTitle: boolean;
|
||||
hfToken: string;
|
||||
modelsError: string | null;
|
||||
|
|
@ -189,6 +190,8 @@ type ChatRuntimeStore = {
|
|||
setModels: (models: ChatModelSummary[]) => void;
|
||||
setLoras: (loras: ChatLoraSummary[]) => void;
|
||||
setThreadRunning: (threadId: string, running: boolean) => void;
|
||||
registerThreadCancel: (threadId: string, cancel: () => void) => void;
|
||||
clearThreadCancel: (threadId: string) => void;
|
||||
setAutoTitle: (enabled: boolean) => void;
|
||||
setHfToken: (token: string) => void;
|
||||
setModelsError: (error: string | null) => void;
|
||||
|
|
@ -218,6 +221,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
models: [],
|
||||
loras: [],
|
||||
runningByThreadId: {},
|
||||
cancelByThreadId: {},
|
||||
autoTitle: loadBool(AUTO_TITLE_KEY, false),
|
||||
hfToken: loadString(HF_TOKEN_KEY, ""),
|
||||
modelsError: null,
|
||||
|
|
@ -277,6 +281,19 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
}
|
||||
return { runningByThreadId: next };
|
||||
}),
|
||||
registerThreadCancel: (threadId, cancel) =>
|
||||
set((state) => {
|
||||
const next = { ...state.cancelByThreadId };
|
||||
next[threadId] = cancel;
|
||||
return { cancelByThreadId: next };
|
||||
}),
|
||||
clearThreadCancel: (threadId) =>
|
||||
set((state) => {
|
||||
if (!(threadId in state.cancelByThreadId)) return state;
|
||||
const next = { ...state.cancelByThreadId };
|
||||
delete next[threadId];
|
||||
return { cancelByThreadId: next };
|
||||
}),
|
||||
setAutoTitle: (autoTitle) =>
|
||||
set(() => {
|
||||
saveBool(AUTO_TITLE_KEY, autoTitle);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
|
|
@ -460,6 +461,27 @@ export function ExportDialog({
|
|||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Success banner for log-driven exports.
|
||||
Keep users on the log screen after completion so they can
|
||||
inspect conversion output before closing. */}
|
||||
{exportSuccess && showLogPanel && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-300">
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span>
|
||||
{destination === "hub"
|
||||
? "Export finished and pushed to Hugging Face Hub."
|
||||
: "Export finished successfully."}
|
||||
</span>
|
||||
{exportOutputPath ? (
|
||||
<code className="select-all break-all font-mono text-[12px] text-foreground/90" title={exportOutputPath}>
|
||||
{exportOutputPath}
|
||||
</code>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error banner */}
|
||||
{exportError && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
|
|
@ -577,14 +599,16 @@ export function ExportDialog({
|
|||
onClick={() => onOpenChange(false)}
|
||||
disabled={exporting}
|
||||
>
|
||||
Cancel
|
||||
{exportSuccess ? "Done" : "Cancel"}
|
||||
</Button>
|
||||
<Button onClick={onExport} disabled={exporting}>
|
||||
<Button onClick={onExport} disabled={exporting || exportSuccess}>
|
||||
{exporting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
Exporting…
|
||||
</span>
|
||||
) : exportSuccess ? (
|
||||
"Export Complete"
|
||||
) : (
|
||||
"Start Export"
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -36,14 +36,14 @@ export const EXPORT_METHODS: {
|
|||
];
|
||||
|
||||
export const QUANT_OPTIONS = [
|
||||
{ value: "q2_k_l", label: "Q2_K_L", size: "~2.9 GB" },
|
||||
{ value: "q3_k_m", label: "Q3_K_M", size: "~3.5 GB" },
|
||||
{ value: "q4_0", label: "Q4_0", size: "~4.1 GB" },
|
||||
{ value: "q4_k_m", label: "Q4_K_M", size: "~4.8 GB", recommended: true },
|
||||
{ value: "q5_0", label: "Q5_0", size: "~5.0 GB" },
|
||||
{ value: "q5_k_m", label: "Q5_K_M", size: "~5.6 GB" },
|
||||
{ value: "q6_k", label: "Q6_K", size: "~6.6 GB" },
|
||||
{ value: "q8_0", label: "Q8_0", size: "~8.2 GB" },
|
||||
{ value: "bf16", label: "BF16", size: "~14.2 GB" },
|
||||
{ value: "f16", label: "F16", size: "~14.2 GB" },
|
||||
{ value: "f32", label: "F32", size: "~28.4 GB" },
|
||||
];
|
||||
|
||||
export function getEstimatedSize(
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [draftName, setDraftName] = useState(displayName);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="mx-auto flex w-full max-w-[640px] flex-col items-center gap-6 rounded-2xl border border-border/70 bg-muted/10 px-8 py-7">
|
||||
<div className="relative">
|
||||
<UserAvatar
|
||||
name={previewName}
|
||||
imageUrl={avatarDataUrl}
|
||||
size="lg"
|
||||
className="size-[124px] text-[3.15rem]"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
void onPickFile(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
aria-label="Change profile picture"
|
||||
>
|
||||
<Camera className="size-3.5" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-[560px] flex-col gap-2">
|
||||
<Label htmlFor="profile-display-name" className="text-xs font-medium text-muted-foreground">
|
||||
Display name
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="profile-display-name"
|
||||
type="text"
|
||||
value={draftName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Button type="button" size="sm" className="h-10 px-5" onClick={saveName} disabled={!hasNameChanges}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{imageError ? (
|
||||
<p className="w-full text-xs text-destructive" role="alert">
|
||||
{imageError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<span className={cn("relative inline-flex shrink-0 overflow-hidden rounded-full", SIZE[size], className)}>
|
||||
<img src={imageUrl} alt="" className="size-full object-cover" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
style={avatarBgStyle()}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white",
|
||||
SIZE[size],
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
6
studio/frontend/src/features/profile/index.ts
Normal file
6
studio/frontend/src/features/profile/index.ts
Normal file
|
|
@ -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";
|
||||
|
|
@ -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<UserProfileState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
displayName: "",
|
||||
avatarDataUrl: null,
|
||||
setDisplayName: (displayName) => set({ displayName }),
|
||||
setAvatarDataUrl: (avatarDataUrl) => set({ avatarDataUrl }),
|
||||
}),
|
||||
{ name: "unsloth_user_profile" },
|
||||
),
|
||||
);
|
||||
|
|
@ -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%)" };
|
||||
}
|
||||
21
studio/frontend/src/features/profile/utils/jwt-subject.ts
Normal file
21
studio/frontend/src/features/profile/utils/jwt-subject.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<HTMLImageElement> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ export function ApiKeyRow({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => copyToClipboard(prefix)}>
|
||||
<DropdownMenuItem onClick={async () => { await copyToClipboard(prefix); }}>
|
||||
<HugeiconsIcon icon={Copy01Icon} className="size-3.5 mr-2" />
|
||||
Copy prefix
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ export function KeyRevealCard({
|
|||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (copyToClipboard(rawKey)) {
|
||||
const handleCopy = async () => {
|
||||
if (await copyToClipboard(rawKey)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ function CopyableCommand({
|
|||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!copyToClipboard(command)) {
|
||||
const handleCopy = async () => {
|
||||
if (!(await copyToClipboard(command))) {
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ export function UsageExamples() {
|
|||
[],
|
||||
);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (copyToClipboard(snippets[lang])) {
|
||||
const handleCopy = async () => {
|
||||
if (await copyToClipboard(snippets[lang])) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <GeneralTab />;
|
||||
case "profile":
|
||||
return <ProfileTab />;
|
||||
case "appearance":
|
||||
return <AppearanceTab />;
|
||||
case "chat":
|
||||
|
|
@ -132,7 +137,7 @@ export function SettingsDialog() {
|
|||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
|
||||
</button>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-6 pr-12">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-6">
|
||||
{renderTab(activeTab)}
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
19
studio/frontend/src/features/settings/tabs/profile-tab.tsx
Normal file
19
studio/frontend/src/features/settings/tabs/profile-tab.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">Profile</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Update how your profile appears in Studio.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ProfilePersonalizationPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,50 +32,23 @@ function copyWithExecCommand(text: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
export function copyToClipboard(text: string): boolean {
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined" && document.queryCommandSupported?.("copy") !== false) {
|
||||
if (copyWithExecCommand(text)) return true;
|
||||
}
|
||||
|
||||
// Async fallback for environments where execCommand is entirely unsupported
|
||||
// but the Clipboard API is available (rare; kept for original contract parity).
|
||||
if (typeof navigator?.clipboard?.writeText === "function") {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function copyToClipboardAsync(text: string): Promise<boolean> {
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prefer the async Clipboard API: avoids focus disruption in Radix
|
||||
// focus-trapped dialogs where execCommand always fails.
|
||||
// Primary: async Clipboard API
|
||||
if (typeof navigator?.clipboard?.writeText === "function") {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Clipboard API rejected (e.g. NotAllowedError, permission policy).
|
||||
// User activation is still valid through promise chains per spec, so
|
||||
// execCommand can succeed for callers outside focus-trapped dialogs.
|
||||
// Inside a Radix modal the focus trap will block textarea.focus() and
|
||||
// execCommand returns false harmlessly.
|
||||
return copyWithExecCommand(text);
|
||||
} catch (error) {
|
||||
console.warn("Async clipboard API failed, falling back to execCommand", error);
|
||||
// Clipboard API rejected (NotAllowedError, insecure context, etc.)
|
||||
// Fall through to execCommand fallback.
|
||||
}
|
||||
}
|
||||
|
||||
// No Clipboard API (older browser / non-secure context): still in the
|
||||
// original user-gesture frame, so execCommand can work.
|
||||
// Fallback: execCommand (works in Safari when called during user gesture)
|
||||
return copyWithExecCommand(text);
|
||||
}
|
||||
|
|
|
|||
111
unsloth/save.py
111
unsloth/save.py
|
|
@ -122,6 +122,7 @@ ALLOWED_QUANTS = {
|
|||
"q4_k_m": "Recommended. Uses Q6_K for half of the attention.wv and feed_forward.w2 tensors, else Q4_K",
|
||||
"q5_k_m": "Recommended. Uses Q6_K for half of the attention.wv and feed_forward.w2 tensors, else Q5_K",
|
||||
"q2_k": "Uses Q4_K for the attention.vw and feed_forward.w2 tensors, Q2_K for the other tensors.",
|
||||
"q2_k_l": "Q2_K_L with q8_0 output/token embeddings for higher quality than plain Q2_K.",
|
||||
"q3_k_l": "Uses Q5_K for the attention.wv, attention.wo, and feed_forward.w2 tensors, else Q3_K",
|
||||
"q3_k_m": "Uses Q4_K for the attention.wv, attention.wo, and feed_forward.w2 tensors, else Q3_K",
|
||||
"q3_k_s": "Uses Q3_K for all tensors",
|
||||
|
|
@ -153,6 +154,89 @@ def print_quantization_methods():
|
|||
print(f'"{key}" ==> {value}')
|
||||
|
||||
|
||||
def _quantize_q2_k_l(
|
||||
input_gguf: Union[str, os.PathLike],
|
||||
output_gguf: Union[str, os.PathLike],
|
||||
quantizer_location: Union[str, os.PathLike],
|
||||
n_threads: int,
|
||||
print_output: bool = True,
|
||||
):
|
||||
# "Q2_K_L" is a Unsloth-side preset, not a native llama.cpp ftype. It
|
||||
# maps to the `q2_k` ftype with `--output-tensor-type q8_0` and
|
||||
# `--token-embedding-type q8_0` so the output/embedding tensors retain
|
||||
# higher precision than a plain Q2_K quant.
|
||||
command = [
|
||||
str(quantizer_location),
|
||||
"--output-tensor-type",
|
||||
"q8_0",
|
||||
"--token-embedding-type",
|
||||
"q8_0",
|
||||
str(input_gguf),
|
||||
str(output_gguf),
|
||||
"q2_k",
|
||||
str(n_threads),
|
||||
]
|
||||
|
||||
if print_output:
|
||||
print(
|
||||
"Unsloth: Quantizing as Q2_K_L preset "
|
||||
"(q2_k + --output-tensor-type q8_0 --token-embedding-type q8_0)..."
|
||||
)
|
||||
|
||||
try:
|
||||
if print_output:
|
||||
with subprocess.Popen(
|
||||
command,
|
||||
shell = False,
|
||||
text = True,
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
bufsize = 1,
|
||||
) as sp:
|
||||
assert sp.stdout is not None
|
||||
for line in sp.stdout:
|
||||
print(line, end = "", flush = True)
|
||||
|
||||
returncode = sp.wait()
|
||||
if returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Failed to quantize {input_gguf} to q2_k_l: process exited with code {returncode}"
|
||||
)
|
||||
else:
|
||||
subprocess.run(
|
||||
command,
|
||||
shell = False,
|
||||
check = True,
|
||||
capture_output = True,
|
||||
text = True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
if print_output and hasattr(e, "stdout") and e.stdout:
|
||||
print(e.stdout)
|
||||
error_details = ""
|
||||
if hasattr(e, "stdout") and e.stdout:
|
||||
error_details += f"\nSubprocess stdout:\n{e.stdout}"
|
||||
if hasattr(e, "stderr") and e.stderr:
|
||||
error_details += f"\nSubprocess stderr:\n{e.stderr}"
|
||||
raise RuntimeError(
|
||||
f"Failed to quantize {input_gguf} to q2_k_l: {e}{error_details}"
|
||||
)
|
||||
|
||||
output_path = Path(output_gguf)
|
||||
if not output_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Quantization failed - output file {output_gguf} not created"
|
||||
)
|
||||
|
||||
if print_output:
|
||||
file_size_bytes = output_path.stat().st_size
|
||||
file_size_gb = file_size_bytes / (1024**3)
|
||||
print(
|
||||
f"Unsloth: Successfully quantized to {output_gguf} (size: {file_size_gb:.2f}GB)"
|
||||
)
|
||||
return str(output_gguf)
|
||||
|
||||
|
||||
def check_if_sentencepiece_model(
|
||||
model, temporary_location = "_unsloth_sentencepiece_temp"
|
||||
):
|
||||
|
|
@ -1305,14 +1389,23 @@ def save_to_gguf(
|
|||
gguf_directory, f"{model_name}.{quant_method.upper()}.gguf"
|
||||
)
|
||||
try:
|
||||
# Use the quantize_gguf function we created
|
||||
quantized_file = quantize_gguf(
|
||||
input_gguf = base_gguf,
|
||||
output_gguf = output_location,
|
||||
quant_type = quant_method,
|
||||
quantizer_location = quantizer_location,
|
||||
print_output = print_output,
|
||||
)
|
||||
if quant_method == "q2_k_l":
|
||||
quantized_file = _quantize_q2_k_l(
|
||||
input_gguf = base_gguf,
|
||||
output_gguf = output_location,
|
||||
quantizer_location = quantizer_location,
|
||||
n_threads = n_cpus,
|
||||
print_output = print_output,
|
||||
)
|
||||
else:
|
||||
# Use unsloth-zoo's standard quantization for all other methods
|
||||
quantized_file = quantize_gguf(
|
||||
input_gguf = base_gguf,
|
||||
output_gguf = output_location,
|
||||
quant_type = quant_method,
|
||||
quantizer_location = quantizer_location,
|
||||
print_output = print_output,
|
||||
)
|
||||
all_saved_locations.append(quantized_file)
|
||||
quants_created = True
|
||||
except Exception as e:
|
||||
|
|
@ -1880,6 +1973,7 @@ def unsloth_save_pretrained_gguf(
|
|||
"q4_k_m" : "Recommended. Uses Q6_K for half of the attention.wv and feed_forward.w2 tensors, else Q4_K",
|
||||
"q5_k_m" : "Recommended. Uses Q6_K for half of the attention.wv and feed_forward.w2 tensors, else Q5_K",
|
||||
"q2_k" : "Uses Q4_K for the attention.vw and feed_forward.w2 tensors, Q2_K for the other tensors.",
|
||||
"q2_k_l" : "Q2_K_L with --output-tensor-type q8_0 --token-embedding-type q8_0.",
|
||||
"q3_k_l" : "Uses Q5_K for the attention.wv, attention.wo, and feed_forward.w2 tensors, else Q3_K",
|
||||
"q3_k_m" : "Uses Q4_K for the attention.wv, attention.wo, and feed_forward.w2 tensors, else Q3_K",
|
||||
"q3_k_s" : "Uses Q3_K for all tensors",
|
||||
|
|
@ -2203,6 +2297,7 @@ def unsloth_push_to_hub_gguf(
|
|||
"q4_k_m" : "Recommended. Uses Q6_K for half of the attention.wv and feed_forward.w2 tensors, else Q4_K",
|
||||
"q5_k_m" : "Recommended. Uses Q6_K for half of the attention.wv and feed_forward.w2 tensors, else Q5_K",
|
||||
"q2_k" : "Uses Q4_K for the attention.vw and feed_forward.w2 tensors, Q2_K for the other tensors.",
|
||||
"q2_k_l" : "Q2_K_L with --output-tensor-type q8_0 --token-embedding-type q8_0.",
|
||||
"q3_k_l" : "Uses Q5_K for the attention.wv, attention.wo, and feed_forward.w2 tensors, else Q3_K",
|
||||
"q3_k_m" : "Uses Q4_K for the attention.wv, attention.wo, and feed_forward.w2 tensors, else Q3_K",
|
||||
"q3_k_s" : "Uses Q3_K for all tensors",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue