unsloth/studio/frontend/src/features/settings/tabs/general-tab.tsx
oobabooga 420799b61e
Studio: add an Open button to reveal the models folder in the file manager (#6452)
* Studio: add an Open button to reveal the models folder in the file manager

* Studio: report models folder creation failures

---------

Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
2026-06-19 05:14:58 -07:00

524 lines
18 KiB
TypeScript

// 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { usePlatformStore } from "@/config/env";
import { isTauri } from "@/lib/api-base";
import { openModelsDir } from "@/features/native-intents/api";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { toast } from "@/lib/toast";
import { loadModelsFolder, type ModelsFolder } from "../api/models-folder";
import { resetOnboardingDone } from "@/features/auth";
import { useChatRuntimeStore } from "@/features/chat";
import {
setShowLlamaUpdateBanner,
useShowLlamaUpdateBanner,
} from "@/hooks/use-llama-update-pref";
import {
loadHelperPrecacheSettings,
updateHelperPrecacheSettings,
type HelperPrecacheSettings,
} from "../api/helper-precache";
import {
DEFAULT_UPLOAD_LIMIT_MB,
loadUploadLimitSettings,
updateUploadLimitSettings,
type UploadLimitSettings,
} from "../api/upload-limit";
import { useSettingsDialogStore } from "../stores/settings-dialog-store";
import { LOCALE_STORAGE_KEY, useT } from "@/i18n";
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";
import { SettingsSection } from "../components/settings-section";
import { StudioVersionSection } from "../components/studio-version-section";
// Keys cleared by "Reset all local preferences".
// NEVER include auth/session keys here — clearing them would log the user out
// or force re-onboarding. Explicitly excluded: unsloth_auth_token,
// unsloth_auth_refresh_token, unsloth_auth_must_change_password,
// unsloth_onboarding_done.
const PREFS_KEYS: string[] = [
// Appearance
"theme",
LOCALE_STORAGE_KEY,
// UI state
"sidebar_pinned",
"unsloth_sidebar_navigate_open",
"unsloth_settings_active_tab",
// Chat runtime prefs
"unsloth_chat_auto_title",
"unsloth_hf_token",
"unsloth_auto_heal_tool_calls",
"unsloth_max_tool_calls_per_message",
"unsloth_tool_call_timeout",
"unsloth_chat_inference_params",
"unsloth_chat_collapsible_state",
"unsloth_chat_preferences",
// Chat presets
"unsloth_chat_custom_presets",
"unsloth_chat_active_preset",
"unsloth_chat_system_prompts",
"unsloth_chat_system_prompts_migrated",
// Training UI prefs
"unsloth_training_config_v1",
"unsloth_prev_max_steps",
"unsloth_prev_save_steps",
// Profile personalization
"unsloth_user_profile",
// Guided tour flags
"tour:studio:v1",
// Update notifications
"unsloth_show_llama_update_banner",
];
// Set by resetAllPrefs so the unmount-commit effect skips writing back the
// in-memory draft, else cleanup would re-persist the just-cleared HF token.
let resetInProgress = false;
function resetAllPrefs() {
resetInProgress = true;
for (const key of PREFS_KEYS) {
try {
localStorage.removeItem(key);
} catch {
// ignore
}
}
window.location.reload();
}
export function GeneralTab() {
const t = useT();
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 chatOnly = usePlatformStore((s) => s.chatOnly);
const showLlamaUpdates = useShowLlamaUpdateBanner();
const redirectTo = `${pathname}${search}`;
const [draftToken, setDraftToken] = useState(hfToken ?? "");
const [showToken, setShowToken] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [uploadLimit, setUploadLimit] = useState<UploadLimitSettings | null>(
null,
);
const [draftUploadLimit, setDraftUploadLimit] = useState(
String(DEFAULT_UPLOAD_LIMIT_MB),
);
const [uploadLimitError, setUploadLimitError] = useState<string | null>(null);
const [isSavingUploadLimit, setIsSavingUploadLimit] = useState(false);
const [helperPrecache, setHelperPrecache] =
useState<HelperPrecacheSettings | null>(null);
const [helperPrecacheError, setHelperPrecacheError] = useState<string | null>(
null,
);
const [isSavingHelperPrecache, setIsSavingHelperPrecache] = useState(false);
const [modelsFolder, setModelsFolder] = useState<ModelsFolder | null>(null);
const draftRef = useRef(draftToken);
useEffect(() => {
draftRef.current = draftToken;
}, [draftToken]);
// Commit on unmount (dialog close / tab switch). Skip during reset-prefs
// flow so we don't re-persist the draft after localStorage was cleared.
useEffect(() => {
return () => {
if (resetInProgress) return;
const trimmed = draftRef.current.trim();
const current = useChatRuntimeStore.getState().hfToken;
if (trimmed !== current) {
useChatRuntimeStore.getState().setHfToken(trimmed);
}
};
}, []);
const commitToken = () => {
const trimmed = draftToken.trim();
if (trimmed !== draftToken) setDraftToken(trimmed);
if (trimmed !== hfToken) setHfToken(trimmed);
};
useEffect(() => {
let cancelled = false;
void loadUploadLimitSettings()
.then((settings) => {
if (cancelled) return;
setUploadLimit(settings);
setDraftUploadLimit(String(settings.maxUploadSizeMb));
})
.catch((error) => {
if (cancelled) return;
setUploadLimitError(
error instanceof Error ? error.message : "Failed to load upload limit.",
);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
void loadHelperPrecacheSettings()
.then((settings) => {
if (cancelled) return;
setHelperPrecache(settings);
setHelperPrecacheError(null);
})
.catch((error) => {
if (cancelled) return;
setHelperPrecacheError(
error instanceof Error
? error.message
: t("settings.general.helperLlm.loadError"),
);
});
return () => {
cancelled = true;
};
}, [t]);
useEffect(() => {
let cancelled = false;
void loadModelsFolder()
.then((folder) => {
if (cancelled) return;
setModelsFolder(folder);
})
.catch(() => {
// Non-critical: leave the row hidden if the path can't be resolved.
});
return () => {
cancelled = true;
};
}, []);
// Desktop opens the folder in the OS file manager; the browser can't, so it
// falls back to copying the path (which is the info users actually want).
const handleModelsFolder = async () => {
const folder = modelsFolder;
if (!folder) return;
if (isTauri) {
try {
await openModelsDir(folder.path);
} catch (error) {
toast.error(t("settings.general.storage.openError"), {
description: error instanceof Error ? error.message : undefined,
});
}
return;
}
if (await copyToClipboard(folder.path)) {
toast.success(t("settings.general.storage.copied"));
} else {
toast.error(t("settings.general.storage.copyError"));
}
};
const saveHelperPrecache = async (enabled: boolean) => {
setIsSavingHelperPrecache(true);
setHelperPrecacheError(null);
try {
const settings = await updateHelperPrecacheSettings(enabled);
setHelperPrecache(settings);
} catch (error) {
setHelperPrecacheError(
error instanceof Error
? error.message
: t("settings.general.helperLlm.saveError"),
);
} finally {
setIsSavingHelperPrecache(false);
}
};
const saveUploadLimit = async () => {
const parsed = Number(draftUploadLimit);
if (!Number.isInteger(parsed)) {
setUploadLimitError("Enter a whole number of MB.");
return;
}
const min = uploadLimit?.minUploadSizeMb ?? 1;
const max = uploadLimit?.maxAllowedUploadSizeMb ?? 8192;
if (parsed < min || parsed > max) {
setUploadLimitError(`Enter a value from ${min} to ${max} MB.`);
return;
}
setIsSavingUploadLimit(true);
setUploadLimitError(null);
try {
const settings = await updateUploadLimitSettings(parsed);
setUploadLimit(settings);
setDraftUploadLimit(String(settings.maxUploadSizeMb));
} catch (error) {
setUploadLimitError(
error instanceof Error ? error.message : "Failed to save upload limit.",
);
} finally {
setIsSavingUploadLimit(false);
}
};
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-xl font-semibold font-heading">
{t("settings.general.title")}
</h1>
<p className="text-xs text-muted-foreground">
{t("settings.general.description")}
</p>
</header>
<StudioVersionSection />
<SettingsSection title={t("settings.general.account")}>
<SettingsRow
label={t("settings.general.huggingFaceToken")}
description={t("settings.general.huggingFaceTokenDescription")}
>
<div className="relative w-[260px]">
<Input
type={showToken ? "text" : "password"}
placeholder="hf_…"
value={draftToken}
onChange={(e) => setDraftToken(e.target.value)}
onBlur={commitToken}
className="h-8 w-full pr-8 font-mono text-xs"
/>
<button
type="button"
onClick={() => setShowToken((s) => !s)}
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
aria-label={
showToken
? t("settings.general.hideToken")
: t("settings.general.showToken")
}
tabIndex={-1}
>
{showToken ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
</button>
</div>
</SettingsRow>
</SettingsSection>
{modelsFolder ? (
<SettingsSection title={t("settings.general.storage.sectionTitle")}>
<SettingsRow
label={t("settings.general.storage.modelsFolder")}
description={t("settings.general.storage.modelsFolderDescription")}
>
<div className="flex items-center gap-2">
<span
title={modelsFolder.path}
className="max-w-[280px] truncate font-mono text-xs text-muted-foreground"
>
{modelsFolder.path}
</span>
<Button
variant="outline"
size="sm"
onClick={() => void handleModelsFolder()}
>
{isTauri
? t("settings.general.storage.openAction")
: t("settings.general.storage.copyAction")}
</Button>
</div>
</SettingsRow>
</SettingsSection>
) : null}
<SettingsSection title={t("settings.general.chatDefaults")}>
<SettingsRow
label={t("settings.general.autoTitleNewChats")}
description={t("settings.general.autoTitleNewChatsDescription")}
>
<Switch checked={autoTitle} onCheckedChange={setAutoTitle} />
</SettingsRow>
</SettingsSection>
<StudioVersionSection />
<SettingsSection title={t("settings.general.helperLlm.sectionTitle")}>
<SettingsRow
label={t("settings.general.helperLlm.preloadOnStartup")}
description={t(
"settings.general.helperLlm.preloadOnStartupDescription",
)}
>
<div className="flex flex-col items-end gap-1">
<Switch
checked={helperPrecache?.enabled ?? false}
disabled={
!helperPrecache ||
isSavingHelperPrecache ||
helperPrecache.disabledByEnv
}
onCheckedChange={(enabled) => void saveHelperPrecache(enabled)}
/>
{helperPrecache?.disabledByEnv ? (
<span className="max-w-[260px] text-right text-xs text-muted-foreground">
{t("settings.general.helperLlm.disabledByEnv")}
</span>
) : helperPrecacheError ? (
<span className="max-w-[260px] text-right text-xs text-destructive">
{helperPrecacheError}
</span>
) : null}
</div>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.general.notifications.sectionTitle")}>
<SettingsRow
label={t("settings.general.notifications.showLlamaUpdates")}
description={t(
"settings.general.notifications.showLlamaUpdatesDescription",
)}
>
<Switch
checked={showLlamaUpdates}
onCheckedChange={setShowLlamaUpdateBanner}
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.general.uploads.sectionTitle")}>
<SettingsRow
label={t("settings.general.uploads.maxUploadSize")}
description={t("settings.general.uploads.maxUploadSizeDescription", {
defaultSize: String(
uploadLimit?.defaultUploadSizeMb ?? DEFAULT_UPLOAD_LIMIT_MB,
),
})}
>
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2">
<div className="relative w-28">
<Input
type="number"
min={uploadLimit?.minUploadSizeMb ?? 1}
max={uploadLimit?.maxAllowedUploadSizeMb ?? 8192}
step={1}
value={draftUploadLimit}
aria-label="Training dataset upload cap in MB"
onChange={(event) => setDraftUploadLimit(event.target.value)}
className="h-8 w-full pr-10"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-xs font-medium text-muted-foreground">
MB
</span>
</div>
<Button
variant="outline"
size="sm"
disabled={isSavingUploadLimit}
onClick={() => void saveUploadLimit()}
>
{isSavingUploadLimit
? t("common.saving")
: t("common.save")}
</Button>
</div>
{uploadLimitError ? (
<span className="max-w-[260px] text-right text-xs text-destructive">
{uploadLimitError}
</span>
) : null}
</div>
</SettingsRow>
</SettingsSection>
{!chatOnly && (
<SettingsSection title={t("settings.general.gettingStarted")}>
<SettingsRow
label={t("settings.general.startOnboarding")}
description={t("settings.general.startOnboardingDescription")}
>
<Button
variant="outline"
size="sm"
onClick={() => {
resetOnboardingDone();
closeDialog();
navigate({ to: "/onboarding", search: { redirectTo } });
}}
>
{t("settings.general.startOnboardingAction")}
</Button>
</SettingsRow>
</SettingsSection>
)}
<SettingsSection title={t("settings.general.resetPreferences.sectionTitle")}>
<SettingsRow
destructive
label={t("settings.general.resetPreferences.label")}
description={t("settings.general.resetPreferences.description")}
>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmOpen(true)}
className="text-destructive hover:text-destructive hover:border-destructive/60"
>
{t("settings.general.resetPreferences.action")}
</Button>
</SettingsRow>
</SettingsSection>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("settings.general.resetPreferences.confirmTitle")}
</DialogTitle>
<DialogDescription>
{t("settings.general.resetPreferences.confirmDescription")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
{t("common.cancel")}
</Button>
<Button
onClick={resetAllPrefs}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
{t("settings.general.resetPreferences.confirmAction")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}