* 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>
524 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|