Merge branch 'diffusion-train-tab-2' into diffusion-krea2

This commit is contained in:
Daniel Han 2026-07-04 02:21:20 +00:00
commit ea9f7ae9f9
131 changed files with 19488 additions and 3092 deletions

View file

@ -11,7 +11,6 @@ import { Route as dataRecipeRoute } from "./routes/data-recipes.$recipeId";
import { Route as chatRoute } from "./routes/chat";
import { Route as exportRoute } from "./routes/export";
import { Route as imagesRoute } from "./routes/images";
import { Route as gridTestRoute } from "./routes/grid-test";
import { Route as indexRoute } from "./routes/index";
import { Route as loginRoute } from "./routes/login";
import { Route as hubRoute } from "./routes/hub";
@ -26,7 +25,6 @@ const routeTree = rootRoute.addChildren([
onboardingRoute,
loginRoute,
changePasswordRoute,
gridTestRoute,
hubRoute,
settingsRoute,
studioRoute,

View file

@ -78,6 +78,9 @@ const CHAT_ONLY_ALLOWED = new Set([
"/login",
"/signup",
"/change-password",
// Export stays reachable on chat-only hosts so the page can show its own grayed-out reason
// instead of a silent redirect; it self-gates via export capability, so nothing runs.
"/export",
]);
function isChatOnlyAllowed(pathname: string): boolean {

View file

@ -1,69 +0,0 @@
// 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 { DashboardGrid, DashboardLayout } from "@/components/layout";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { createRoute } from "@tanstack/react-router";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/grid-test",
beforeLoad: () => requireAuth(),
component: GridTestPage,
});
function GridTestPage() {
return (
<DashboardLayout>
<div className="space-y-8">
<div>
<h1 className="text-2xl font-semibold">Grid Test - 3 Columns</h1>
<p className="text-muted-foreground">
max-w-7xl, gap-6, responsive 123
</p>
</div>
<DashboardGrid cols={3}>
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<CardTitle>Card {i}</CardTitle>
<CardDescription>~400px at 1280px viewport</CardDescription>
</CardHeader>
<CardContent>
<div className="h-24 rounded-lg bg-muted" />
</CardContent>
</Card>
))}
</DashboardGrid>
<div>
<h2 className="text-xl font-semibold">4 Columns</h2>
<p className="text-muted-foreground">~296px per card at 1280px</p>
</div>
<DashboardGrid cols={4}>
{[1, 2, 3, 4].map((i) => (
<Card key={i} size="sm">
<CardHeader>
<CardTitle>Card {i}</CardTitle>
<CardDescription>Smaller cards</CardDescription>
</CardHeader>
<CardContent>
<div className="h-16 rounded-lg bg-muted" />
</CardContent>
</Card>
))}
</DashboardGrid>
</div>
</DashboardLayout>
);
}

View file

@ -291,13 +291,12 @@ export function AppSidebar() {
const chatOnly = usePlatformStore((s) => s.isChatOnly());
const chatOnlyReason = usePlatformStore((s) => s.chatOnlyReason);
// When Train/Export are greyed out (chat-only host), explain why on hover
// instead of disabling them silently. mlx_unavailable is the common macOS case
// after a reinstall/update dropped MLX and is recoverable via `unsloth studio update`.
const trainExportDisabledHint: string | undefined = !chatOnly
// Explain a greyed-out Train (chat-only host) on hover instead of disabling silently. Export is
// no longer disabled here: it stays navigable so its page can show a precise grayed-out reason.
const trainDisabledHint: string | undefined = !chatOnly
? undefined
: chatOnlyReason === "mlx_unavailable"
? "Training needs MLX. Run `unsloth studio update` to enable Train and Export."
? "Training needs MLX. Run `unsloth studio update` to enable Train."
: chatOnlyReason === "intel_mac"
? "Training needs Apple Silicon or a GPU. Intel Macs are chat-only."
: chatOnlyReason === "no_gpu"
@ -1216,7 +1215,7 @@ export function AppSidebar() {
pathname === "/studio" || pathname.startsWith("/studio/")
}
disabled={chatOnly}
tooltip={trainExportDisabledHint}
tooltip={trainDisabledHint}
spinner={trainingInProgress}
onClick={() => {
if (chatOnly) return;
@ -1245,7 +1244,7 @@ export function AppSidebar() {
label={t("shell.navigation.train")}
active={pathname === "/studio" || pathname.startsWith("/studio/")}
disabled={chatOnly}
tooltip={trainExportDisabledHint}
tooltip={trainDisabledHint}
spinner={trainingInProgress}
onClick={() => {
if (chatOnly) return;
@ -1266,11 +1265,8 @@ export function AppSidebar() {
icon={DownloadSquare01Icon}
label={t("shell.navigation.export")}
active={pathname === "/export" || pathname.startsWith("/export/")}
disabled={chatOnly}
tooltip={trainExportDisabledHint}
spinner={exportInProgress}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/export" });
closeMobileIfOpen();
}}

View file

@ -1,6 +1,7 @@
// 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 { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import {
@ -103,6 +104,7 @@ import {
type FormatFilter,
estimateQuantBytes,
fitsDevice,
hfModelFitsDevice,
isMlxId,
isMobileVariant,
isRecommendableFormat,
@ -1414,6 +1416,9 @@ export function HubModelPicker({
}, []);
// When on, On Device GGUF repos show their quantizations without a click.
const expandQuantizations = useChatRuntimeStore((s) => s.expandQuantizations);
// Shared with the Hub page: list only models sized within the device budget.
const fitOnDeviceOnly = useChatRuntimeStore((s) => s.fitOnDeviceOnly);
const setFitOnDeviceOnly = useChatRuntimeStore((s) => s.setFitOnDeviceOnly);
// Repos the user clicked to collapse while expand-by-default is on. Kept in
// memory only, so it resets on reload (and when the setting is toggled).
const [collapsedGguf, setCollapsedGguf] = useState<Set<string>>(
@ -1805,34 +1810,19 @@ export function HubModelPicker({
formatFilter === "all"
? rows.filter((r) => isRecommendableFormat(r.id, r.isGguf, isMac))
: rows.filter((r) => matchesFormatFilter(r.id, r.isGguf, formatFilter));
if (recommendedSort !== "recommended") return rows;
// The "recommended" sort always applies the device-fit filter; the shared
// "Fits on device" tick extends it to the other sorts too.
if (recommendedSort !== "recommended" && !fitOnDeviceOnly) return rows;
return rows.filter((r) => {
// Downloaded models always show, regardless of device fit.
if (downloadedSet.has(r.id.toLowerCase())) return true;
// Unified-memory hosts (Mac / no discrete GPU) still report system RAM,
// so fall back to that budget instead of skipping the fit check entirely.
const hasDeviceBudget =
gpu.memoryTotalGb > 0 || gpu.systemRamAvailableGb > 0;
if (!hasDeviceBudget) return true;
// GGUF/MLX repos rarely expose safetensors metadata, so fall back to the
// GGUF param count, then the repo name, for a size estimate. Anything we
// still cannot size is hidden (requireKnown) so over-budget models like a
// 1T GGUF don't slip into Recommended.
const params = r.totalParams ?? paramsFromId(r.id);
const sizeBytes =
r.estimatedSizeBytes ??
(params ? estimateQuantBytes(params) : undefined);
return fitsDevice({
sizeBytes,
gpuGb: gpu.memoryTotalGb,
systemRamGb: gpu.systemRamAvailableGb,
requireKnown: true,
});
return hfModelFitsDevice(r, gpu);
});
}, [
recommendedSearch.results,
downloadedSet,
recommendedSort,
fitOnDeviceOnly,
formatFilter,
isMac,
gpu,
@ -2111,23 +2101,6 @@ export function HubModelPicker({
[visibleCachedModelRows],
);
// Recommended models that match the current search query
const filteredRecommendedIds = useMemo(() => {
if (!showHfSection) return [];
const q = normalizeForSearch(debouncedQuery.trim());
return recommendedIds
.filter((id) => normalizeForSearch(id).includes(q))
.filter((id) =>
matchesFormatFilter(id, isKnownGgufRepo(id), formatFilter),
);
}, [
showHfSection,
debouncedQuery,
recommendedIds,
formatFilter,
isKnownGgufRepo,
]);
// Param counts come straight off the unsloth listings the picker already
// loaded, so no extra per-id fetch is needed for the VRAM badges.
const recommendedParamCountById = useMemo(() => {
@ -2138,6 +2111,42 @@ export function HubModelPicker({
return map;
}, [results, recommendedSearch.results]);
// Recommended models that match the current search query
const filteredRecommendedIds = useMemo(() => {
if (!showHfSection) return [];
const q = normalizeForSearch(debouncedQuery.trim());
return recommendedIds
.filter((id) => normalizeForSearch(id).includes(q))
.filter((id) =>
matchesFormatFilter(id, isKnownGgufRepo(id), formatFilter),
)
// Curated defaults obey the fit toggle like the live HF rows, else large
// defaults resurface in search results with the filter on.
.filter(
(id) =>
!fitOnDeviceOnly ||
downloadedSet.has(id.toLowerCase()) ||
hfModelFitsDevice(
{
id,
totalParams: recommendedParamCountById.get(id),
isGguf: isKnownGgufRepo(id),
},
gpu,
),
);
}, [
showHfSection,
debouncedQuery,
recommendedIds,
formatFilter,
isKnownGgufRepo,
fitOnDeviceOnly,
downloadedSet,
recommendedParamCountById,
gpu,
]);
const recommendedSet = useMemo(
() => new Set(filteredRecommendedIds),
[filteredRecommendedIds],
@ -2148,6 +2157,12 @@ export function HubModelPicker({
if (!showHfSection || section !== "recommended") return [];
return results
.filter(isChatSupported)
.filter(
(r) =>
!fitOnDeviceOnly ||
downloadedSet.has(r.id.toLowerCase()) ||
hfModelFitsDevice(r, gpu),
)
.map((result) => result.id)
.filter((id) => !isHiddenModelId(id))
.filter((id) => id.toLowerCase().startsWith("unsloth/"))
@ -2174,6 +2189,9 @@ export function HubModelPicker({
isKnownGgufRepo,
isChatSupported,
formatFilter,
fitOnDeviceOnly,
downloadedSet,
gpu,
isMac,
task,
]);
@ -2463,6 +2481,35 @@ export function HubModelPicker({
// selected-item checkmark never overlaps the label.
const sortMenuContentClassName =
"!p-1 !rounded-[14px] [&_[role=option]]:!pl-2 [&_[role=option]]:!py-1.5 [&_[role=option]]:!text-xs [&_[role=option]]:!rounded-[10px]";
// Device-fit toggle lives inside the sort menu (shared with the Hub page).
// The whole row is the click target (a button): a Checkbox renders as a
// <button>, and label-click forwarding to a button is unreliable, so the row
// owns the toggle and the Checkbox is presentational (pointer-events-none).
const fitOnDeviceFooter = (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
role="checkbox"
aria-checked={fitOnDeviceOnly}
onClick={() => setFitOnDeviceOnly(!fitOnDeviceOnly)}
className="flex w-full cursor-pointer select-none items-center gap-1.5 rounded-[10px] px-2 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<Checkbox
checked={fitOnDeviceOnly}
tabIndex={-1}
aria-hidden
className="pointer-events-none size-3.5 rounded-full [&_svg]:!size-2.5"
/>
Only show models that fit
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
Hides models larger than this device's memory budget. Downloaded models
stay visible.
</TooltipContent>
</Tooltip>
);
const sectionSortDropdown =
section === "recommended" ? (
<HubOptionMenu
@ -2473,6 +2520,7 @@ export function HubModelPicker({
align="end"
className={sortTriggerClassName}
contentClassName={sortMenuContentClassName}
footer={fitOnDeviceFooter}
/>
) : section === "downloaded" ? (
<HubOptionMenu
@ -2483,6 +2531,7 @@ export function HubModelPicker({
align="end"
className={sortTriggerClassName}
contentClassName={sortMenuContentClassName}
footer={fitOnDeviceFooter}
/>
) : (
<HubOptionMenu
@ -2493,6 +2542,7 @@ export function HubModelPicker({
align="end"
className={sortTriggerClassName}
contentClassName={sortMenuContentClassName}
footer={fitOnDeviceFooter}
/>
);

View file

@ -114,3 +114,35 @@ export function fitsDevice(opts: {
}
return requireKnown ? false : true;
}
/** Fit predicate for one Hub listing row, shared by the chat model selector
* and the Hub page "Fits on device" filter. GGUF repos: metadata size (actual
* weights) or the smallest-quant estimate from the param count. Safetensors /
* MLX repos: always the params-based smallest-quant estimate, matching the
* VRAM badge's quantized-load assumption; their estimatedSizeBytes is the
* full-precision checkpoint and would wrongly hide models the quantized load
* path can run. Anything unsizable is hidden (requireKnown) so over-budget
* models with no metadata don't slip through. An unknown device budget keeps
* everything. */
export function hfModelFitsDevice(
model: {
id: string;
totalParams?: number;
estimatedSizeBytes?: number;
isGguf?: boolean;
},
gpu: { memoryTotalGb: number; systemRamAvailableGb: number },
): boolean {
if (gpu.memoryTotalGb <= 0 && gpu.systemRamAvailableGb <= 0) return true;
const params = model.totalParams ?? paramsFromId(model.id);
const quantBytes = params ? estimateQuantBytes(params) : undefined;
const sizeBytes = isGgufId(model.id, model.isGguf)
? (model.estimatedSizeBytes ?? quantBytes)
: (quantBytes ?? model.estimatedSizeBytes);
return fitsDevice({
sizeBytes,
gpuGb: gpu.memoryTotalGb,
systemRamGb: gpu.systemRamAvailableGb,
requireKnown: true,
});
}

View file

@ -0,0 +1,160 @@
// 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 { Progress } from "@/components/ui/progress";
import { useMonitorOverlayStore } from "@/features/settings/stores/monitor-overlay-store";
import { useSystemInfo } from "@/hooks/use-system";
import { useT } from "@/i18n";
import { cn } from "@/lib/utils";
import { CpuIcon, GripVerticalIcon, XIcon } from "lucide-react";
import { motion } from "motion/react";
import { useRef } from "react";
function clampPercent(value: number): number {
return Math.max(0, Math.min(100, value));
}
function usageIndicatorClass(percent: number): string {
if (percent >= 90) return "bg-destructive";
if (percent >= 70) return "bg-amber-500";
return "bg-primary";
}
function usageTextClass(percent: number): string {
if (percent >= 90) return "text-destructive";
if (percent >= 70) return "text-amber-600 dark:text-amber-400";
return "text-primary";
}
function formatGb(value: number): string {
const digits = value >= 10 ? 1 : 2;
return `${value.toFixed(digits)} GB`;
}
export function FloatingMonitor() {
const t = useT();
const { isOpen, setIsOpen } = useMonitorOverlayStore();
const systemInfo = useSystemInfo({ enabled: isOpen, pollMs: 5000 });
const constraintsRef = useRef<HTMLDivElement>(null);
if (!isOpen) return null;
const ramTotal = systemInfo.memory?.total_gb ?? 0;
const ramAvailable = systemInfo.memory?.available_gb ?? 0;
const ramUsed = Math.max(0, ramTotal - ramAvailable);
const ramPercent = clampPercent(systemInfo.memory?.percent_used ?? 0);
const devices = systemInfo.gpu?.devices ?? [];
const vramTotal = devices.reduce(
(sum, device) => sum + (device.memory_total_gb ?? 0),
0,
);
const vramUsed = devices.reduce(
(sum, device) => sum + (device.vram_used_gb ?? 0),
0,
);
const vramPercent = clampPercent(
vramTotal > 0 ? (vramUsed / vramTotal) * 100 : 0,
);
const hasGpu = (systemInfo.gpu?.available ?? false) && devices.length > 0;
return (
<div
ref={constraintsRef}
className="fixed inset-0 z-50 pointer-events-none"
>
<motion.div
layout={true}
drag={true}
dragConstraints={constraintsRef}
dragElastic={0.1}
dragMomentum={false}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="settings-surface fixed bottom-4 right-4 w-64 max-w-[calc(100vw-2rem)] resize overflow-hidden rounded-xl border border-border/70 p-3 shadow-border ring-0 backdrop-blur-sm pointer-events-auto cursor-default select-none"
>
<div className="mb-2 flex items-center justify-between gap-2 border-b border-border/60 pb-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-xs font-semibold text-foreground">
<CpuIcon className="size-3.5 shrink-0 text-primary" />
<span className="truncate">
{t("settings.resources.liveMonitor.title")}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<div className="cursor-grab rounded-md px-1 text-muted-foreground/60 transition-colors hover:bg-muted/60 hover:text-muted-foreground active:cursor-grabbing">
<GripVerticalIcon className="size-3.5" />
</div>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => setIsOpen(false)}
title={t("common.close")}
aria-label={t("common.close")}
>
<XIcon className="size-3" />
</Button>
</div>
</div>
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-3 overflow-hidden"
>
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-medium font-mono">
<span>{t("settings.resources.liveMonitor.ram")}</span>
<span className={cn("tabular-nums", usageTextClass(ramPercent))}>
{Math.round(ramPercent)}%
</span>
</div>
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{formatGb(ramUsed)} / {formatGb(ramTotal)}
</div>
<Progress
value={ramPercent}
className="mt-1 h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(ramPercent)}
/>
</div>
{hasGpu && (
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-medium font-mono">
<span className="truncate flex-1 pr-2">
{t("settings.resources.liveMonitor.vram")}{" "}
{devices.length > 1
? `(${devices.length} GPUs)`
: `(${devices[0].name ?? "GPU"})`}
</span>
<span
className={cn(
"shrink-0 tabular-nums",
usageTextClass(vramPercent),
)}
>
{Math.round(vramPercent)}%
</span>
</div>
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{formatGb(vramUsed)} / {formatGb(vramTotal)}
</div>
<Progress
value={vramPercent}
className="mt-1 h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(vramPercent)}
/>
</div>
)}
</motion.div>
</motion.div>
</div>
);
}

View file

@ -42,6 +42,8 @@ export const CHAT_EXPAND_QUANTIZATIONS_KEY =
"unsloth_chat_expand_quantizations";
export const CHAT_SHOW_ALL_QUANTIZATIONS_KEY =
"unsloth_chat_show_all_quantizations";
export const MODELS_FIT_ON_DEVICE_ONLY_KEY =
"unsloth_models_fit_on_device_only";
export const CHAT_BYPASS_PERMISSIONS_KEY = "unsloth_chat_bypass_permissions";
export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY =
"unsloth_chat_web_fetch_tools_enabled";
@ -671,6 +673,9 @@ type ChatRuntimeStore = {
expandQuantizations: boolean;
/** Persisted: show non-downloaded quantizations too, not just downloaded. */
showAllQuantizations: boolean;
/** Persisted, shared by the chat model selector and the Hub page: list only
* models whose size fits this device's memory budget. */
fitOnDeviceOnly: boolean;
/** A local model picked while `loadOnSelection` is off: staged, not loaded.
* The settings sheet shows its load knobs and a Load button. */
pendingSelection: PendingModelSelection | null;
@ -793,6 +798,7 @@ type ChatRuntimeStore = {
setLoadOnSelection: (value: boolean) => void;
setExpandQuantizations: (value: boolean) => void;
setShowAllQuantizations: (value: boolean) => void;
setFitOnDeviceOnly: (value: boolean) => void;
setPendingSelection: (selection: PendingModelSelection | null) => void;
/** Stage a pick for a deferred load: revert knobs to the loaded baseline,
* record the selection, and open the settings sheet. */
@ -1111,6 +1117,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
loadOnSelection: loadBool(CHAT_LOAD_ON_SELECTION_KEY, true),
expandQuantizations: loadBool(CHAT_EXPAND_QUANTIZATIONS_KEY, false),
showAllQuantizations: loadBool(CHAT_SHOW_ALL_QUANTIZATIONS_KEY, true),
fitOnDeviceOnly: loadBool(MODELS_FIT_ON_DEVICE_ONLY_KEY, false),
pendingSelection: null,
loadedIsMultimodal: false,
loadedIsDiffusion: false,
@ -1582,6 +1589,10 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
saveBool(CHAT_SHOW_ALL_QUANTIZATIONS_KEY, showAllQuantizations);
set({ showAllQuantizations });
},
setFitOnDeviceOnly: (fitOnDeviceOnly) => {
saveBool(MODELS_FIT_ON_DEVICE_ONLY_KEY, fitOnDeviceOnly);
set({ fitOnDeviceOnly });
},
setPendingSelection: (pendingSelection) => set({ pendingSelection }),
stageModel: (selection) => {
// Refuse staging mid-load: post-load cleanup would silently drop the queued

View file

@ -127,6 +127,8 @@ export async function loadCheckpoint(params: {
export async function exportMerged(params: {
save_directory: string;
format_type?: string;
/** Compressed-tensors scheme alias (e.g. "fp8", "w4a16", "mxfp4"); overrides format_type. */
compressed_method?: string | null;
push_to_hub?: boolean;
repo_id?: string | null;
hf_token?: string | null;
@ -158,7 +160,8 @@ export async function exportBase(params: {
export async function exportGGUF(params: {
save_directory: string;
quantization_method: string;
/** A single GGUF quant method or a list (list produces multiple GGUFs from one model load). */
quantization_method: string | string[];
push_to_hub?: boolean;
repo_id?: string | null;
hf_token?: string | null;
@ -179,6 +182,10 @@ export async function exportLoRA(params: {
repo_id?: string | null;
hf_token?: string | null;
private?: boolean;
/** Also convert the adapter to a GGUF LoRA file (llama.cpp `--lora`). */
gguf?: boolean;
/** GGUF LoRA output float type (f32/f16/bf16/q8_0/auto); only used when gguf=true. */
gguf_outtype?: string;
}): Promise<ExportOperationResponse> {
const response = await authFetch("/api/export/export/lora", {
method: "POST",

View file

@ -28,7 +28,11 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { EXPORT_METHODS, type ExportMethod } from "../constants";
import {
EXPORT_METHODS,
type ExportMethod,
findMergedFormat,
} from "../constants";
import type { ExportLogEntry } from "../api/export-api";
import { getExportLogLineClass } from "../lib/log-style";
import {
@ -200,6 +204,9 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
const summaryMethodLabel = summary?.methodLabel ?? methodTitle;
const summaryQuants = summary?.quantLevels ?? quantLevels;
const summaryMethod = summary?.method ?? exportMethod;
const summaryFormats = (summary?.mergedFormats ?? []).map(
(v) => findMergedFormat(v)?.label ?? v,
);
const showProgress = isExporting || isTerminal;
return (
@ -392,14 +399,32 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
? "Export finished and pushed to Hugging Face Hub."
: "Export finished successfully."}
</span>
{run.result?.outputPath ? (
<code
className="select-all break-all font-mono text-[12px] text-foreground/90"
title={run.result.outputPath}
>
{run.result.outputPath}
</code>
) : null}
{(() => {
// List every folder written; a multi-format merged run created one per format.
const paths = run.result?.outputPaths ?? [];
const items =
paths.length > 0
? paths
: run.result?.outputPath
? [{ label: "", path: run.result.outputPath }]
: [];
const showLabels = items.length > 1;
return items.map((o, i) => (
<div key={`${o.path}-${i}`} className="flex min-w-0 flex-col gap-0.5">
{showLabels && o.label ? (
<span className="text-xs text-emerald-700/80 dark:text-emerald-300/80">
{o.label}
</span>
) : null}
<code
className="select-all break-all font-mono text-[12px] text-foreground/90"
title={o.path}
>
{o.path}
</code>
</div>
));
})()}
</div>
</div>
)}
@ -432,6 +457,14 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
<span>Export Method</span>
<span className="font-medium text-foreground">{summaryMethodLabel}</span>
</div>
{summaryMethod === "merged" && summaryFormats.length > 0 && (
<div className="flex justify-between gap-3">
<span>Formats</span>
<span className="font-medium text-foreground text-right">
{summaryFormats.join(", ")}
</span>
</div>
)}
{summaryMethod === "gguf" && summaryQuants.length > 0 && (
<div className="flex justify-between">
<span>Quantizations</span>

View file

@ -55,34 +55,172 @@ export const QUANT_OPTIONS: {
{ value: "f16", label: "F16" },
];
/** Merged-export precision formats. The compressed-tensors ones run llm-compressor for vLLM. */
export type MergedFormat =
| "16-bit (FP16)"
| "FP8 (compressed-tensors)"
| "NVFP4 (compressed-tensors)";
/**
* Merged-export precision formats, sorted by bit width. Three backends:
* - "plain": standard save (16-bit); `formatType` is the backend `format_type`.
* - "compressed": llm-compressor compressed-tensors (vLLM), NVIDIA-only; `value` is the alias.
* - "torchao": portable FP8/INT8, no NVIDIA GPU needed; `value` is the alias.
* `common` entries are quick pills, the rest the "More formats" dropdown; `needsNvidia` entries
* are hidden on non-NVIDIA hardware.
*/
export type MergedBackend = "plain" | "compressed" | "torchao";
export const MERGED_FORMATS: {
value: MergedFormat;
export type MergedFormatOption = {
value: string;
label: string;
bits: number;
backend: MergedBackend;
group: string;
common: boolean;
needsNvidia: boolean;
needsCalibration?: boolean;
hint: string;
}[] = [
/** Backend `format_type` for a "plain" save (unused for compressed/torchao). */
formatType?: string;
};
/** Kept as a string alias for back-compat with callers that typed the old union. */
export type MergedFormat = string;
export const MERGED_FORMATS: MergedFormatOption[] = [
// 16-bit
{
value: "16-bit (FP16)",
value: "16-bit",
label: "16-bit",
bits: 16,
backend: "plain",
group: "16-bit",
common: true,
needsNvidia: false,
hint: "Full precision, runs anywhere.",
formatType: "16-bit (FP16)",
},
// 8-bit
{
value: "fp8",
label: "FP8",
bits: 8,
backend: "compressed",
group: "FP8",
common: true,
needsNvidia: true,
hint: "Dynamic per-token FP8 (W8A8) for vLLM. Data-free.",
},
{
value: "FP8 (compressed-tensors)",
label: "FP8 (vLLM)",
hint: "compressed-tensors FP8 for vLLM. Needs an NVIDIA GPU.",
value: "torchao_fp8",
label: "FP8 (portable)",
bits: 8,
backend: "torchao",
group: "Portable",
common: true,
needsNvidia: false,
hint: "Device-agnostic FP8 (torchao). Produces on any hardware; loads in vLLM.",
},
{
value: "NVFP4 (compressed-tensors)",
label: "NVFP4 (vLLM)",
hint: "compressed-tensors NVFP4 for vLLM. Needs an NVIDIA GPU; calibrates.",
value: "w8a8",
label: "INT8 (W8A8)",
bits: 8,
backend: "compressed",
group: "INT",
common: true,
needsNvidia: true,
hint: "8-bit weights and 8-bit activations for vLLM. Data-free.",
},
{
value: "torchao_int8",
label: "INT8 (portable)",
bits: 8,
backend: "torchao",
group: "Portable",
common: true,
needsNvidia: false,
hint: "Device-agnostic INT8 (torchao). Produces on any hardware; loads in vLLM.",
},
{
value: "fp8_static",
label: "FP8 Static",
bits: 8,
backend: "compressed",
group: "FP8",
common: false,
needsNvidia: true,
needsCalibration: true,
hint: "Static per-tensor FP8. Calibrates on data.",
},
{
value: "w8a16",
label: "INT8 (W8A16)",
bits: 8,
backend: "compressed",
group: "INT",
common: false,
needsNvidia: true,
hint: "8-bit weight-only. Data-free.",
},
{
value: "mxfp8",
label: "MXFP8",
bits: 8,
backend: "compressed",
group: "MXFP",
common: false,
needsNvidia: true,
hint: "Microscaling FP8. Needs a newer compressed-tensors stack.",
},
// 4-bit
{
value: "w4a16",
label: "INT4 (W4A16)",
bits: 4,
backend: "compressed",
group: "INT",
common: true,
needsNvidia: true,
hint: "4-bit weight-only (GPTQ-style) for vLLM. Data-free.",
},
{
value: "mxfp4",
label: "MXFP4",
bits: 4,
backend: "compressed",
group: "MXFP",
common: true,
needsNvidia: true,
hint: "Microscaling FP4 (W4A4) for vLLM. Data-free.",
},
{
value: "nvfp4",
label: "NVFP4",
bits: 4,
backend: "compressed",
group: "FP4",
common: true,
needsNvidia: true,
needsCalibration: true,
hint: "NVIDIA FP4 (W4A4) for vLLM. Calibrates on data.",
},
];
/** Look up a merged format option by its stable value. */
export function findMergedFormat(value: string): MergedFormatOption | undefined {
return MERGED_FORMATS.find((f) => f.value === value);
}
/** Backend payload for one merged format: plain -> formatType, compressed/torchao -> the alias. */
export function mergedFormatPayload(value: string): {
formatType: string;
compressedMethod: string | null;
} {
const opt = findMergedFormat(value);
if (!opt || opt.backend === "plain") {
return {
formatType: opt?.formatType ?? "16-bit (FP16)",
compressedMethod: null,
};
}
return { formatType: "16-bit (FP16)", compressedMethod: opt.value };
}
/**
* llama.cpp effective bits-per-weight per quant; GGUF size ~= fp16_bytes * bpw / 16.
* K-quant values are published average bit-rates (Q2_K_L = Unsloth Q2_K + Q8_0

View file

@ -24,6 +24,19 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -63,11 +76,14 @@ import {
type ExportMethod,
GUIDE_STEPS,
MERGED_FORMATS,
type MergedFormat,
type MergedFormatOption,
mergedFormatPayload,
QUANT_OPTIONS,
buildQuantSizeLabels,
getEstimatedSize,
} from "./constants";
import { useHardwareInfo } from "@/hooks/use-hardware-info";
import { usePlatformStore } from "@/config/env";
import {
isExportPanelActive,
useExportRuntimeStore,
@ -78,6 +94,10 @@ import { exportTourSteps } from "./tour";
const SEARCH_INPUT_REASONS = new Set(["input-change", "input-paste", "input-clear"]);
// GGUF LoRA output float types (Q8_0 first / default). Q8_0 falls back to F16 per tensor for dims
// not divisible by the block size (32); no "auto" - the choice is explicit.
const LORA_GGUF_OUTTYPES = ["q8_0", "f16", "bf16", "f32"] as const;
type SourceTab = "local" | "checkpoint" | "hf";
type SourceMode = "checkpoint" | "model";
@ -109,7 +129,16 @@ function buildRelativeSaveDirectory(
: sourceBaseModelName;
return `${safePathSegment(rawName)}-GGUF`;
}
return `${selectedModelIdx ?? "model"}/${checkpoint}`;
// Merged / LoRA: a checkpoint keeps the "<run>/<checkpoint>" layout under outputs.
if (sourceMode === "checkpoint" && selectedModelIdx && checkpoint) {
return `${selectedModelIdx}/${checkpoint}`;
}
// Local / HF source (no checkpoint): name from the model id to avoid "model/null".
const rawName =
sourceMode === "checkpoint"
? checkpoint ?? selectedModelIdx ?? sourceBaseModelName
: sourceBaseModelName;
return `${safePathSegment(rawName)}-${exportMethod === "lora" ? "adapter" : "merged"}`;
}
function siblingGgufDirectory(sourcePath: string): string | null {
@ -177,9 +206,57 @@ export function ExportPage() {
});
// GGUF importance matrix (required for the IQ quants) and merged-export precision.
const [useImatrix, setUseImatrix] = useState(false);
const [mergedFormat, setMergedFormat] = useState<MergedFormat>("16-bit (FP16)");
// IQ quants are imatrix-only, so force it on when one is selected; otherwise we would submit
// an IQ quant with no imatrix and llama.cpp would reject it.
// Merged precision: one or more MERGED_FORMATS values, exported in one run. Seed from a live run
// so navigating away and back (which remounts this page) keeps the selection, like exportMethod.
const [selectedFormats, setSelectedFormats] = useState<string[]>(() => {
const s = useExportRuntimeStore.getState();
return isExportPanelActive(s) &&
s.summary?.method === "merged" &&
s.summary.mergedFormats.length > 0
? s.summary.mergedFormats
: ["16-bit"];
});
// LoRA-only export: optionally also emit a GGUF LoRA adapter, and its output float type.
const [loraAsGguf, setLoraAsGguf] = useState(false);
const [loraGgufOuttype, setLoraGgufOuttype] = useState<string>("q8_0");
// GGUF method: export the full model as GGUF quants, or (for an adapter checkpoint) a GGUF LoRA.
const [ggufTarget, setGgufTarget] = useState<"model" | "lora">("model");
const hardware = useHardwareInfo();
// GGUF LoRA conversion is rejected on the macOS / MLX path, so gate it out on a Mac host.
const isMacHost = usePlatformStore((s) => s.deviceType) === "mac";
// Real CUDA (not ROCm); gates the NVIDIA-only compressed-tensors formats.
const hasNvidia = hardware.cuda != null && hardware.rocm == null;
// Only gray out on an authoritative unsupported response; while unloaded the backend route guard
// stays authoritative. The backend supplies the precise reason; the fallback below is a backstop.
const exportUnsupported =
hardware.loaded && hardware.exportSupported === false;
const exportUnsupportedMessage =
hardware.exportUnsupportedMessage ??
"Export requires a supported accelerator (NVIDIA, AMD, or Intel GPU, or Apple Silicon) with PyTorch or MLX installed.";
const availableFormats = useMemo<MergedFormatOption[]>(
() =>
MERGED_FORMATS.filter((f) => {
// compressed-tensors (llm-compressor) is the NVIDIA path; shown only on an NVIDIA GPU.
if (f.backend === "compressed") return hasNvidia;
// Portable torchao is the fallback for hosts without the NVIDIA compressed path, i.e. a
// CPU / non-NVIDIA box. Hidden on NVIDIA (use compressed-tensors) and on macOS/MLX (the
// backend rejects quantized export there).
if (f.backend === "torchao") return !hasNvidia && !isMacHost;
// Plain 16-bit is available everywhere.
return true;
}),
[hasNvidia, isMacHost],
);
const toggleFormat = useCallback((value: string) => {
setSelectedFormats((prev) =>
prev.includes(value)
? prev.filter((v) => v !== value)
: [...prev, value],
);
}, []);
// availableFormats already drops NVIDIA-only formats on other hardware, so no pruning needed.
// IQ quants are imatrix-only: force imatrix on when one is selected, else llama.cpp rejects it.
const requiresImatrix = quantLevels.some(
(q) => QUANT_OPTIONS.find((o) => o.value === q)?.imatrix,
);
@ -304,6 +381,11 @@ export function ExportPage() {
const baseModelName = selectedModelData?.base_model ?? "—";
const isAdapter = !!selectedModelData?.peft_type;
const isQuantized = !!selectedModelData?.is_quantized;
// isAdapter / isQuantized come from the checkpoint's metadata and are stale in "model" source
// mode (a direct base export), so treat both as false outside checkpoint mode to avoid wrongly
// gating the methods.
const effectiveIsAdapter = sourceMode === "checkpoint" && isAdapter;
const effectiveIsQuantized = sourceMode === "checkpoint" && isQuantized;
const loraRank = selectedModelData?.lora_rank ?? null;
const trainingMethodLabel = selectedModelData?.peft_type
? "LoRA / QLoRA"
@ -416,25 +498,30 @@ export function ExportPage() {
setCheckpoint(null);
}, [selectedModelIdx]);
// For a ?run= deep link, default to the run's main checkpoint. Declared after
// the reset effect above so it runs last and isn't clobbered back to null.
// Default to the newest checkpoint when none is chosen (checkpoints are sorted newest-first).
// Declared after the reset effect above so it runs last and isn't clobbered back to null. Covers
// both a ?run= deep link and a plain finetune opened without an explicit checkpoint pick.
useEffect(() => {
if (appliedRunRef.current == null) return;
if (appliedRunRef.current !== selectedModelIdx) return;
if (sourceMode !== "checkpoint") return;
if (checkpoint != null || checkpointsForModel.length === 0) return;
setCheckpoint(checkpointsForModel[0].display_name);
}, [selectedModelIdx, checkpoint, checkpointsForModel]);
}, [sourceMode, selectedModelIdx, checkpoint, checkpointsForModel]);
// Auto-reset export method if incompatible with the selected model type
useEffect(() => {
if (!isAdapter && (exportMethod === "merged" || exportMethod === "lora")) {
// Only LoRA needs a real adapter; Merged and GGUF work for non-PEFT base models too.
if (!effectiveIsAdapter && exportMethod === "lora") {
setExportMethod(null);
}
// Quantized non-PEFT models can't export to any format
if (!isAdapter && isQuantized && exportMethod !== null) {
if (!effectiveIsAdapter && effectiveIsQuantized && exportMethod !== null) {
setExportMethod(null);
}
}, [isAdapter, isQuantized, exportMethod]);
// The GGUF LoRA target only applies to an adapter checkpoint on a non-Mac host.
if ((!effectiveIsAdapter || isMacHost) && ggufTarget !== "model") {
setGgufTarget("model");
}
}, [effectiveIsAdapter, effectiveIsQuantized, exportMethod, isMacHost, ggufTarget]);
const handleSourceTabChange = useCallback((next: string) => {
if (next === "checkpoint") {
@ -442,7 +529,7 @@ export function ExportPage() {
} else if (next === "hf" || next === "local") {
setSourceMode("model");
setModelSource(next);
setExportMethod("gguf");
// Don't force GGUF: Local / HF sources can export Merged too; a stale LoRA pick auto-resets.
} else {
return;
}
@ -508,10 +595,26 @@ export function ExportPage() {
sourceMode,
]);
const saveDirectory = customSaveDirectory?.trim() || defaultSaveDirectory;
// Each merged format uploads a full model to the repo root, so several to one repo would collide.
// GGUF method exporting an adapter checkpoint as a GGUF LoRA (vs full-model quants). Reuses the
// LoRA-adapter export path; no quant list needed.
const ggufAsLora =
exportMethod === "gguf" &&
ggufTarget === "lora" &&
effectiveIsAdapter &&
!isMacHost;
// Restrict a Hub merged export to a single format; multi-format stays available for local export.
const hubMultiFormat =
destination === "hub" && exportMethod === "merged" && selectedFormats.length > 1;
const canExport = !!(
selectedExportSource &&
exportMethod &&
(exportMethod !== "gguf" || quantLevels.length > 0)
!exportUnsupported &&
!hubMultiFormat &&
(exportMethod !== "gguf" || ggufAsLora || quantLevels.length > 0) &&
(exportMethod !== "merged" || selectedFormats.length > 0)
);
const applyHfSourceModel = useCallback((value: string) => {
@ -576,9 +679,14 @@ export function ExportPage() {
const handleStart = useCallback(async () => {
const source = sourceMode === "checkpoint" ? checkpoint : selectedSourceModel;
if (!source || !exportMethod) return;
// A GGUF export with no quant selected runs zero exports yet would still
// settle as success with no file; require at least one (mirrors canExport).
if (exportMethod === "gguf" && quantLevels.length === 0) return;
// No supported accelerator (or PyTorch/MLX missing): the backend would reject anyway; don't submit.
if (exportUnsupported) return;
// GGUF with no quant, or merged with no format, would run an unintended/empty export; require
// at least one (mirrors canExport, in case the panel's Start button bypasses the outer one).
if (exportMethod === "gguf" && !ggufAsLora && quantLevels.length === 0) return;
if (exportMethod === "merged" && selectedFormats.length === 0) return;
// A Hub merged push writes each format to the repo root; several would collide (mirrors canExport).
if (hubMultiFormat) return;
const selectedCp = sourceMode === "checkpoint"
? checkpointsForModel.find((cp) => cp.display_name === checkpoint)
@ -591,8 +699,13 @@ export function ExportPage() {
? `${hfUsername}/${modelName}`
: undefined;
const token = pushToHub && hfToken ? hfToken : undefined;
const methodLabel =
EXPORT_METHODS.find((m) => m.value === exportMethod)?.title ?? exportMethod;
// The GGUF method with the LoRA target reuses the LoRA-adapter export path.
const effectiveMethod: ExportMethod = ggufAsLora ? "lora" : exportMethod;
const emitLoraGguf =
ggufAsLora || (effectiveMethod === "lora" && loraAsGguf && !isMacHost);
const methodLabel = ggufAsLora
? "GGUF LoRA adapter"
: (EXPORT_METHODS.find((m) => m.value === exportMethod)?.title ?? exportMethod);
const adapterExport = sourceMode === "checkpoint" && isAdapter;
// Consent gate for an HF source's custom (auto_map) code, run before we hand
@ -624,11 +737,16 @@ export function ExportPage() {
trustRemoteCode,
approvedRemoteCodeFingerprint,
loadToken: hfToken || null,
exportMethod,
exportMethod: effectiveMethod,
isAdapter: adapterExport,
quantLevels,
useImatrix: effectiveImatrix,
mergedFormat,
mergedSelections: selectedFormats.map((v) => ({
...mergedFormatPayload(v),
label: MERGED_FORMATS.find((f) => f.value === v)?.label ?? v,
})),
loraGguf: emitLoraGguf,
loraGgufOuttype,
saveDirectory,
destination,
repoId,
@ -639,8 +757,9 @@ export function ExportPage() {
baseModelName: sourceBaseModelName,
checkpointLabel: selectedExportSource,
methodLabel,
method: exportMethod,
method: effectiveMethod,
quantLevels,
mergedFormats: exportMethod === "merged" ? selectedFormats : [],
destination,
},
});
@ -656,7 +775,13 @@ export function ExportPage() {
isAdapter,
quantLevels,
effectiveImatrix,
mergedFormat,
selectedFormats,
hubMultiFormat,
ggufAsLora,
loraAsGguf,
isMacHost,
loraGgufOuttype,
exportUnsupported,
destination,
saveDirectory,
hfUsername,
@ -1163,75 +1288,303 @@ export function ExportPage() {
</div>
</div>
{exportUnsupported && (
<Alert variant="destructive">
<HugeiconsIcon icon={AlertCircleIcon} className="size-4" />
<AlertTitle>Export unavailable</AlertTitle>
<AlertDescription>{exportUnsupportedMessage}</AlertDescription>
</Alert>
)}
<MethodPicker
value={exportMethod}
onChange={handleMethodChange}
disabledMethods={
!isAdapter && isQuantized
exportUnsupported
? ["merged", "lora", "gguf"]
: !isAdapter || sourceMode === "model"
? ["merged", "lora"]
: []
: !effectiveIsAdapter && effectiveIsQuantized
? ["merged", "lora", "gguf"]
: !effectiveIsAdapter
? ["lora"]
: []
}
disabledReason={
!isAdapter && isQuantized
? "Pre-quantized (BNB 4-bit) models cannot be exported without LoRA adapters"
: sourceMode === "model"
? "Only GGUF export is available for direct model export"
: !isAdapter
? "Not available for full fine-tune checkpoints (no LoRA adapters)"
exportUnsupported
? exportUnsupportedMessage
: !effectiveIsAdapter && effectiveIsQuantized
? "Pre-quantized (BNB 4-bit) models cannot be exported without LoRA adapters"
: !effectiveIsAdapter
? "LoRA-only export needs a LoRA adapter checkpoint"
: undefined
}
/>
{exportMethod === "merged" && isAdapter && (
<div className="space-y-2">
<div className="text-sm font-medium">Precision</div>
<div className="flex flex-wrap gap-2">
{MERGED_FORMATS.map((f) => (
<Button
key={f.value}
type="button"
variant={mergedFormat === f.value ? "default" : "outline"}
size="sm"
onClick={() => setMergedFormat(f.value)}
title={f.hint}
>
{f.label}
</Button>
))}
</div>
<div className="text-xs text-muted-foreground">
{MERGED_FORMATS.find((f) => f.value === mergedFormat)?.hint}
{exportMethod === "merged" && !exportUnsupported && (
<div className="space-y-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Precision</div>
<span className="text-[11px] text-muted-foreground/70">
select one or more
</span>
</div>
<div className="flex flex-wrap gap-2">
{availableFormats
.filter((f) => f.common)
.map((f) => {
const active = selectedFormats.includes(f.value);
return (
<Button
key={f.value}
type="button"
variant={active ? "default" : "outline"}
size="sm"
onClick={() => toggleFormat(f.value)}
title={f.hint}
>
{f.label}
{f.needsCalibration ? " *" : ""}
</Button>
);
})}
{availableFormats.some((f) => !f.common) && (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<Button type="button" variant="outline" size="sm">
More formats
{selectedFormats.some((v) =>
availableFormats.find(
(f) => f.value === v && !f.common,
),
)
? " ✓"
: "…"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel>
Additional formats
</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableFormats
.filter((f) => !f.common)
.map((f) => (
<DropdownMenuCheckboxItem
key={f.value}
checked={selectedFormats.includes(f.value)}
onCheckedChange={() => toggleFormat(f.value)}
onSelect={(e) => e.preventDefault()}
>
<span className="flex flex-col">
<span>
{f.label}
{f.needsCalibration ? " *" : ""}
</span>
<span className="text-[10px] text-muted-foreground">
{f.hint}
</span>
</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{selectedFormats.length > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-[11px] text-muted-foreground">
{selectedFormats.length} selected:{" "}
{selectedFormats
.map(
(v) =>
MERGED_FORMATS.find((f) => f.value === v)
?.label ?? v,
)
.join(", ")}
</span>
{selectedFormats.length > 1 && (
<button
type="button"
onClick={() => setSelectedFormats(["16-bit"])}
className="text-[11px] text-muted-foreground/70 hover:text-foreground transition-colors"
>
Reset to 16-bit
</button>
)}
</div>
)}
{hubMultiFormat && (
<div className="text-[11px] text-amber-600 dark:text-amber-500">
Hub export supports one format at a time (each writes to
the repository root). Select a single format, or export
locally to produce several at once.
</div>
)}
{selectedFormats.some(
(v) =>
MERGED_FORMATS.find((f) => f.value === v)
?.needsCalibration,
) && (
<div className="text-[11px] text-muted-foreground">
* calibrates on data (uses a small calibration set).
</div>
)}
{!hasNvidia && (
<div className="text-[11px] text-muted-foreground">
No NVIDIA GPU detected: compressed-tensors formats are
hidden. 16-bit and portable FP8/INT8 (torchao) still
work here and load in vLLM.
</div>
)}
</div>
</div>
)}
{exportMethod === "gguf" && (
<>
<QuantPicker
value={quantLevels}
onChange={setQuantLevels}
sizes={quantSizeLabels}
/>
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div className="space-y-0.5">
<div className="text-sm font-medium">
Importance matrix (imatrix)
{exportMethod === "lora" && effectiveIsAdapter && !exportUnsupported && (
<div className="space-y-3">
<div className="space-y-2">
<div className="text-sm font-medium">Adapter format</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={!loraAsGguf ? "default" : "outline"}
size="sm"
onClick={() => setLoraAsGguf(false)}
title="Standard PEFT adapter (adapter_model.safetensors)."
>
Adapter (safetensors)
</Button>
<Button
type="button"
variant={loraAsGguf ? "default" : "outline"}
size="sm"
disabled={isMacHost}
onClick={() => setLoraAsGguf(true)}
title={
isMacHost
? "GGUF LoRA export is not available on macOS/MLX. Use the safetensors adapter."
: "llama.cpp GGUF LoRA, loadable with `llama-cli --lora`."
}
>
GGUF adapter
</Button>
</div>
<div className="text-xs text-muted-foreground">
{isMacHost
? "GGUF LoRA is not available on macOS/MLX; exporting the safetensors adapter."
: loraAsGguf
? "Converts the adapter to a GGUF LoRA (llama.cpp `--lora`). The base model stays separate."
: "Standard PEFT adapter files. Pair with the base model at inference."}
</div>
</div>
{loraAsGguf && (
<div className="space-y-1.5">
<div className="text-sm font-medium">Output type</div>
<Select
value={loraGgufOuttype}
onValueChange={(v) => setLoraGgufOuttype(v)}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LORA_GGUF_OUTTYPES.map((t) => (
<SelectItem key={t} value={t}>
{t.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{exportMethod === "gguf" && !exportUnsupported && (
<div className="space-y-3">
{effectiveIsAdapter && !isMacHost && (
<div className="space-y-2">
<div className="text-sm font-medium">Export target</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={ggufTarget === "model" ? "default" : "outline"}
size="sm"
onClick={() => setGgufTarget("model")}
title="Merge the adapter into the base model, then quantize the full model to GGUF."
>
Full model
</Button>
<Button
type="button"
variant={ggufTarget === "lora" ? "default" : "outline"}
size="sm"
onClick={() => setGgufTarget("lora")}
title="Export just the adapter as a GGUF LoRA (llama.cpp `--lora`); the base model stays separate."
>
LoRA adapter
</Button>
</div>
<div className="text-xs text-muted-foreground">
{requiresImatrix
? "Required for the selected IQ low-bit quant. Auto-downloads the upstream Unsloth imatrix for the base model."
: "Improves quant quality and unlocks the IQ low-bit quants. Auto-downloads the upstream Unsloth imatrix for the base model."}
{ggufTarget === "lora"
? "Converts the adapter to a GGUF LoRA (llama.cpp `--lora`). The base model stays separate."
: "Merges the adapter into the base model, then quantizes the full model to GGUF."}
</div>
</div>
<Switch
checked={effectiveImatrix}
onCheckedChange={setUseImatrix}
disabled={requiresImatrix}
/>
</div>
</>
)}
{ggufAsLora ? (
<div className="space-y-1.5">
<div className="text-sm font-medium">Output type</div>
<Select
value={loraGgufOuttype}
onValueChange={(v) => setLoraGgufOuttype(v)}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LORA_GGUF_OUTTYPES.map((t) => (
<SelectItem key={t} value={t}>
{t.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<>
<QuantPicker
value={quantLevels}
onChange={setQuantLevels}
sizes={quantSizeLabels}
/>
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div className="space-y-0.5">
<div className="text-sm font-medium">
Importance matrix (imatrix)
</div>
<div className="text-xs text-muted-foreground">
{requiresImatrix
? "Required for the selected IQ low-bit quant. Auto-downloads the upstream Unsloth imatrix for the base model."
: "Improves quant quality and unlocks the IQ low-bit quants. Auto-downloads the upstream Unsloth imatrix for the base model."}
</div>
</div>
<Switch
checked={effectiveImatrix}
onCheckedChange={setUseImatrix}
disabled={requiresImatrix}
/>
</div>
</>
)}
</div>
)}
{estimatedSize && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">

View file

@ -5,7 +5,6 @@ import { create } from "zustand";
import {
cancelExport,
cleanupExport,
exportBase,
exportGGUF,
exportLoRA,
exportMerged,
@ -119,6 +118,8 @@ export interface ExportRunSummary {
methodLabel: string;
method: ExportMethod;
quantLevels: string[];
/** Merged: the selected format values (for the summary "Formats" row and to reseed the picker). */
mergedFormats: string[];
destination: ExportDestination;
}
@ -140,8 +141,16 @@ export interface RunExportParams {
quantLevels: string[];
/** GGUF: use an importance matrix (auto-download); required for the IQ quants. */
useImatrix?: boolean;
/** Merged: precision/format ("16-bit (FP16)" or a compressed-tensors option). */
mergedFormat?: string;
/** Merged: precision formats, each exported to its own sibling directory. Defaults to 16-bit.
* `label` is the display name for the success banner's per-format output line. */
mergedSelections?: {
formatType: string;
compressedMethod: string | null;
label: string;
}[];
/** LoRA: also emit a GGUF LoRA adapter (llama.cpp `--lora`), and its output float type. */
loraGguf?: boolean;
loraGgufOuttype?: string;
saveDirectory: string;
destination: ExportDestination;
repoId?: string;
@ -172,7 +181,13 @@ interface ExportRuntimeState {
* settling the run by polling /api/export/status instead. Logs keep streaming. */
reconnecting: boolean;
startedAt: number | null;
result: { outputPath: string | null; destination: ExportDestination } | null;
/** `outputPath` is the first path (back-compat); `outputPaths` is one entry per written folder
* so a multi-format merged run can list every sibling directory it created. */
result: {
outputPath: string | null;
outputPaths: { label: string; path: string }[];
destination: ExportDestination;
} | null;
error: string | null;
cancelRequested: boolean;
hasHydrated: boolean;
@ -317,6 +332,10 @@ export const useExportRuntimeStore = create<ExportRuntimeStore>()((set, get) =>
phase: "success" as const,
result: {
outputPath: status.last_op_output_path ?? null,
// A run recovered from the backend only knows the last output path.
outputPaths: status.last_op_output_path
? [{ label: "", path: status.last_op_output_path }]
: [],
destination: state.result?.destination ?? "local",
},
};
@ -351,7 +370,9 @@ export const useExportRuntimeStore = create<ExportRuntimeStore>()((set, get) =>
const quantTotal =
params.exportMethod === "gguf"
? Math.max(1, params.quantLevels.length)
: 1;
: params.exportMethod === "merged"
? Math.max(1, params.mergedSelections?.length ?? 1)
: 1;
set({
runId,
@ -431,67 +452,72 @@ export const useExportRuntimeStore = create<ExportRuntimeStore>()((set, get) =>
}
if (!isCurrent()) return;
// 2. Run the export. Capture the resolved output_path for the success
// banner; multi-quant GGUF shares one directory, so keep the last.
// 2. Run the export. Collect every resolved output_path so the success
// banner can list each sibling directory a multi-format run created.
set({ phase: "exporting" });
let lastOutputPath: string | null = null;
const outputs: { label: string; path: string }[] = [];
if (params.exportMethod === "merged") {
if (params.isAdapter) {
// Each selected format writes its own sibling directory (PEFT or non-PEFT base alike).
const selections =
params.mergedSelections && params.mergedSelections.length > 0
? params.mergedSelections
: [{ formatType: "16-bit (FP16)", compressedMethod: null, label: "16-bit" }];
for (let i = 0; i < selections.length; i += 1) {
if (!isCurrent()) return;
set({ quantIndex: i });
const sel = selections[i];
const { outputPath } = await runRecoverableOp(() =>
exportMerged({
save_directory: params.saveDirectory,
format_type: params.mergedFormat,
format_type: sel.formatType,
compressed_method: sel.compressedMethod,
push_to_hub: pushToHub,
repo_id: params.repoId,
hf_token: params.token,
private: params.privateRepo,
}),
);
lastOutputPath = outputPath;
} else {
const { outputPath } = await runRecoverableOp(() =>
exportBase({
save_directory: params.saveDirectory,
push_to_hub: pushToHub,
repo_id: params.repoId,
hf_token: params.token,
private: params.privateRepo,
base_model_id: params.baseModelId,
}),
);
lastOutputPath = outputPath;
}
} else if (params.exportMethod === "gguf") {
for (let i = 0; i < params.quantLevels.length; i += 1) {
if (!isCurrent()) return;
set({ quantIndex: i });
const quant = params.quantLevels[i];
const { outputPath } = await runRecoverableOp(() =>
exportGGUF({
save_directory: params.saveDirectory,
quantization_method: quant,
push_to_hub: pushToHub,
repo_id: params.repoId,
hf_token: params.token,
imatrix: params.useImatrix,
}),
);
lastOutputPath = outputPath ?? lastOutputPath;
if (outputPath) outputs.push({ label: sel.label, path: outputPath });
if (!isCurrent()) return;
set({ quantIndex: i + 1 });
}
} else if (params.exportMethod === "gguf") {
// Send the whole quant list in ONE call: the model is merged once and every GGUF comes
// from that single merge (unsloth save_to_gguf loops internally).
const { outputPath } = await runRecoverableOp(() =>
exportGGUF({
save_directory: params.saveDirectory,
quantization_method: params.quantLevels,
push_to_hub: pushToHub,
repo_id: params.repoId,
hf_token: params.token,
imatrix: params.useImatrix,
}),
);
if (outputPath) outputs.push({ label: "GGUF", path: outputPath });
if (!isCurrent()) return;
set({ quantIndex: get().quantTotal });
} else if (params.exportMethod === "lora") {
const { outputPath } = await runRecoverableOp(() =>
exportLoRA({
save_directory: params.saveDirectory,
push_to_hub: pushToHub,
repo_id: params.repoId,
hf_token: params.token,
// A local GGUF LoRA export still reloads a possibly-gated base config, so fall back to
// the load token when there is no hub-upload token (both are the same HF token).
hf_token: params.token ?? params.loadToken ?? null,
private: params.privateRepo,
gguf: params.loraGguf ?? false,
gguf_outtype: params.loraGgufOuttype ?? "q8_0",
}),
);
lastOutputPath = outputPath;
if (outputPath) {
outputs.push({
label: params.loraGguf ? "GGUF LoRA adapter" : "LoRA adapter",
path: outputPath,
});
}
}
if (!isCurrent()) return;
@ -499,7 +525,11 @@ export const useExportRuntimeStore = create<ExportRuntimeStore>()((set, get) =>
phase: "success",
isExporting: false,
reconnecting: false,
result: { outputPath: lastOutputPath, destination: params.destination },
result: {
outputPath: outputs[0]?.path ?? null,
outputPaths: outputs,
destination: params.destination,
},
});
} catch (err) {
if (!isCurrent()) return;

View file

@ -38,6 +38,7 @@ export function HubOptionMenu<T extends string>({
showChevron = true,
title,
triggerContent,
footer,
}: {
value: T;
options: readonly HubOption<T>[];
@ -49,9 +50,12 @@ export function HubOptionMenu<T extends string>({
showChevron?: boolean;
title?: string;
triggerContent?: ReactNode;
/** Rendered under the options behind a separator; clicks keep the menu open. */
footer?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
// -1 = nothing highlighted (no hover, no keyboard nav yet).
const [activeIndex, setActiveIndex] = useState(-1);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const listboxRef = useRef<HTMLDivElement | null>(null);
const idBase = useId();
@ -63,9 +67,9 @@ export function HubOptionMenu<T extends string>({
}, [options, value]);
const selected = options[selectedIndex];
const resolvedActiveIndex =
options.length === 0
options.length === 0 || activeIndex < 0
? -1
: Math.min(Math.max(activeIndex, 0), options.length - 1);
: Math.min(activeIndex, options.length - 1);
const activeOptionId =
resolvedActiveIndex >= 0 ? `${idBase}-option-${resolvedActiveIndex}` : undefined;
@ -92,11 +96,13 @@ export function HubOptionMenu<T extends string>({
(nextOpen: boolean) => {
setOpen(nextOpen);
if (nextOpen) {
activateIndex(selectedIndex);
// Nothing highlighted until the user hovers or uses the keyboard;
// keyboard nav anchors on the selected option (handleContentKeyDown).
activateIndex(-1);
requestAnimationFrame(() => listboxRef.current?.focus());
}
},
[activateIndex, selectedIndex],
[activateIndex],
);
const handleContentKeyDown = useCallback(
@ -112,12 +118,21 @@ export function HubOptionMenu<T extends string>({
}
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((currentIndex + 1) % options.length);
// First arrow press highlights the selected option, then steps.
setActiveIndex(
resolvedActiveIndex < 0
? selectedIndex
: (currentIndex + 1) % options.length,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((currentIndex - 1 + options.length) % options.length);
setActiveIndex(
resolvedActiveIndex < 0
? selectedIndex
: (currentIndex - 1 + options.length) % options.length,
);
return;
}
if (event.key === "Home") {
@ -197,6 +212,7 @@ export function HubOptionMenu<T extends string>({
aria-activedescendant={activeOptionId}
tabIndex={0}
onKeyDown={handleContentKeyDown}
onPointerLeave={() => activateIndex(-1)}
className="outline-none"
>
{options.map((option, index) => {
@ -235,6 +251,12 @@ export function HubOptionMenu<T extends string>({
);
})}
</div>
{footer && (
// -mt-3 cancels the surface's 16px flex gap down to 4px. No side
// padding: the footer label carries the same padding as the options
// so its checkbox lines up with the option text.
<div className="-mt-3 border-t border-border/60 pt-1">{footer}</div>
)}
</PopoverContent>
</Popover>
);

View file

@ -15,6 +15,7 @@ import {
PackageIcon,
RamMemoryIcon,
RemoveCircleIcon,
CpuIcon
} from "@hugeicons/core-free-icons";
import type { IconSvgElement } from "@hugeicons/react";
import { HugeiconsIcon } from "@hugeicons/react";
@ -43,6 +44,7 @@ export function ModelsHeader({
isDataset,
gpuLabel,
ramLabel,
coreLabel,
activeCheckpoint,
activeGgufVariant,
onTitleClick,
@ -53,6 +55,7 @@ export function ModelsHeader({
isDataset: boolean;
gpuLabel: string;
ramLabel: string;
coreLabel: string;
activeCheckpoint: string | null;
activeGgufVariant: string | null;
onTitleClick: () => void;
@ -84,7 +87,8 @@ export function ModelsHeader({
value={String(localCount)}
/>
<StatPill icon={ChipIcon} label="VRAM" value={gpuLabel} />
<StatPill icon={RamMemoryIcon} label="CPU RAM" value={ramLabel} />
<StatPill icon={RamMemoryIcon} label="RAM" value={ramLabel} />
<StatPill icon={CpuIcon} label="CPU" value={coreLabel} />
{activeCheckpoint && (
<div className="hub-tag-soft ml-1 inline-flex items-center gap-1.5 px-2 py-1 text-[11.5px]">

View file

@ -1,6 +1,7 @@
// 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 { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import {
@ -68,6 +69,8 @@ export const ModelsToolbar = memo(function ModelsToolbar({
onFormatFilterChange,
capabilityFilter,
onCapabilityFilterChange,
fitOnDeviceOnly,
onFitOnDeviceOnlyChange,
onManageLocalFolders,
onOpenFineTune,
}: {
@ -84,6 +87,9 @@ export const ModelsToolbar = memo(function ModelsToolbar({
onFormatFilterChange: (value: ModelFormatFilter) => void;
capabilityFilter: CapabilityFilter;
onCapabilityFilterChange: (value: CapabilityFilter) => void;
/** Shared with the chat model selector: hide models over the device budget. */
fitOnDeviceOnly: boolean;
onFitOnDeviceOnlyChange: (value: boolean) => void;
onManageLocalFolders: () => void;
/** Opens the curated "Fine-tune ready" channel (discover only). Exposed as a
* format-dropdown option rather than a standalone feed section. */
@ -350,6 +356,33 @@ export const ModelsToolbar = memo(function ModelsToolbar({
onValueChange={onSortChange}
ariaLabel="Sort models"
className={cn(triggerBase, "w-[128px]")}
footer={
isDataset ? undefined : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
role="checkbox"
aria-checked={fitOnDeviceOnly}
onClick={() => onFitOnDeviceOnlyChange(!fitOnDeviceOnly)}
className="flex w-full cursor-pointer select-none items-center gap-2 rounded-[10px] px-3 py-2 text-left text-[12.5px] text-muted-foreground transition-colors hover:text-foreground"
>
<Checkbox
checked={fitOnDeviceOnly}
tabIndex={-1}
aria-hidden
className="pointer-events-none size-3.5 rounded-full [&_svg]:!size-2.5"
/>
Only show models that fit
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
Hides models larger than this device's memory budget.
Downloaded models stay visible.
</TooltipContent>
</Tooltip>
)
}
/>
)}

View file

@ -5,6 +5,7 @@ import {
loadRememberedLoadSettings,
rememberedLoadSettingsKey,
} from "@/components/assistant-ui/model-selector/remembered-load-settings";
import { hfModelFitsDevice } from "@/components/assistant-ui/model-selector/recommended-fit";
import { useHubInventory } from "@/features/hub/inventory";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useGpuInfo } from "@/hooks/use-gpu-info";
@ -327,6 +328,9 @@ export function ModelsPage() {
const activeCheckpoint =
checkpoint && !isExternalModelId(checkpoint) ? checkpoint : null;
const activeGgufVariant = useChatRuntimeStore((s) => s.activeGgufVariant);
// Shared with the chat model selector: list only models sized for this device.
const fitOnDeviceOnly = useChatRuntimeStore((s) => s.fitOnDeviceOnly);
const setFitOnDeviceOnly = useChatRuntimeStore((s) => s.setFitOnDeviceOnly);
useEffect(() => {
let cancelled = false;
@ -697,7 +701,12 @@ export function ModelsPage() {
!isHiddenModelId(row.id) &&
matchesFormat(detectResultFormat(row.result), effectiveDiscoverFormat) &&
matchesCapability(row.capabilities, deferredCapabilityFilter) &&
(!activeChannel?.finetunableOnly || isUnslothFinetunable(row.result)),
(!activeChannel?.finetunableOnly || isUnslothFinetunable(row.result)) &&
// Models already on disk stay visible regardless of device fit,
// matching the chat model selector.
(!fitOnDeviceOnly ||
row.isAvailableOnDevice ||
hfModelFitsDevice(row.result, gpu)),
);
}, [
discoverRows,
@ -705,6 +714,8 @@ export function ModelsPage() {
effectiveDiscoverFormat,
deferredCapabilityFilter,
activeChannel,
fitOnDeviceOnly,
gpu,
]);
const listRows = filteredDiscoverRows;
@ -724,8 +735,21 @@ export function ModelsPage() {
effectiveLocalRows,
)
.filter((row) => !isHiddenModelId(row.id))
.filter((row) => matchesFormat(row.result.isGguf, "gguf")),
[hubFeed.trending.results, modelDiscoveryInventorySignature],
.filter((row) => matchesFormat(row.result.isGguf, "gguf"))
// Same fit filter as the main Discover list, so the feed carousel
// honors the toggle too.
.filter(
(row) =>
!fitOnDeviceOnly ||
row.isAvailableOnDevice ||
hfModelFitsDevice(row.result, gpu),
),
[
hubFeed.trending.results,
modelDiscoveryInventorySignature,
fitOnDeviceOnly,
gpu,
],
);
const feedRows = useMemo(() => {
if (!isFeedMode) return [];
@ -1061,11 +1085,15 @@ export function ModelsPage() {
const { vramInfo, minMemory } = useHubModelVram(selectedModel, gpu);
const gpuLabel = gpu.available
? `${Math.floor(gpu.memoryTotalGb)} GB`
? `${Math.round(gpu.memoryTotalGb)} GB`
: "Unavailable";
const ramLabel =
gpu.systemRamAvailableGb > 0
? `${Math.floor(gpu.systemRamAvailableGb)} GB`
gpu.systemRamTotalGb > 0
? `${Math.round(gpu.systemRamTotalGb)} GB`
: "Unavailable";
const coreLabel =
gpu.cpuCore > 0 && gpu.cpuThread > 0
? `${gpu.cpuCore}/${gpu.cpuThread}`
: "Unavailable";
const openNewChat = useCallback(() => {
@ -1429,6 +1457,7 @@ export function ModelsPage() {
isDataset={isDatasetMode}
gpuLabel={gpuLabel}
ramLabel={ramLabel}
coreLabel={coreLabel}
activeCheckpoint={activeCheckpoint}
activeGgufVariant={activeGgufVariant}
onTitleClick={handleResetToDiscover}
@ -1448,6 +1477,8 @@ export function ModelsPage() {
onFormatFilterChange={setFormatFilter}
capabilityFilter={capabilityFilter}
onCapabilityFilterChange={setCapabilityFilter}
fitOnDeviceOnly={fitOnDeviceOnly}
onFitOnDeviceOnlyChange={setFitOnDeviceOnly}
onManageLocalFolders={handleManageLocalFolders}
onOpenFineTune={() => handleOpenList("finetune")}
/>

View file

@ -0,0 +1,82 @@
// 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 { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
export type EmbeddingModelSettings = {
embeddingModel: string;
defaultEmbeddingModel: string;
isCustom: boolean;
};
type ApiEmbeddingModelSettings = {
// biome-ignore lint/style/useNamingConvention: API schema
embedding_model: string;
// biome-ignore lint/style/useNamingConvention: API schema
default_embedding_model: string;
// biome-ignore lint/style/useNamingConvention: API schema
is_custom: boolean;
};
/** 409 from the backend: the model could not be verified as an embedding model
* (wrong type, gated repo, or offline). Retry with force to save anyway. */
export class EmbeddingModelVerificationError extends Error {}
function fromApi(settings: ApiEmbeddingModelSettings): EmbeddingModelSettings {
return {
embeddingModel: settings.embedding_model,
defaultEmbeddingModel: settings.default_embedding_model,
isCustom: settings.is_custom,
};
}
export async function loadEmbeddingModelSettings(): Promise<EmbeddingModelSettings> {
const res = await authFetch("/api/settings/embedding-model");
if (!res.ok) {
throw new Error(
await readFastApiError(res, "Failed to load embedding model setting"),
);
}
return fromApi(await res.json());
}
export async function updateEmbeddingModelSettings(
embeddingModel: string,
options?: { hfToken?: string; force?: boolean },
): Promise<EmbeddingModelSettings> {
const res = await authFetch("/api/settings/embedding-model", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// biome-ignore lint/style/useNamingConvention: API schema
embedding_model: embeddingModel,
// biome-ignore lint/style/useNamingConvention: API schema
hf_token: options?.hfToken || null,
force: options?.force ?? false,
}),
});
if (res.status === 409) {
throw new EmbeddingModelVerificationError(
await readFastApiError(res, "Could not verify the embedding model"),
);
}
if (!res.ok) {
throw new Error(
await readFastApiError(res, "Failed to save embedding model"),
);
}
return fromApi(await res.json());
}
export async function resetEmbeddingModelSettings(): Promise<EmbeddingModelSettings> {
const res = await authFetch("/api/settings/embedding-model", {
method: "DELETE",
});
if (!res.ok) {
throw new Error(
await readFastApiError(res, "Failed to reset embedding model"),
);
}
return fromApi(await res.json());
}

View file

@ -0,0 +1,73 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// Build the `unsloth start <agent>` command for the API-keys panel.
// `unsloth start` reads UNSLOTH_STUDIO_URL (default 127.0.0.1:8888) and only
// auto-mints a key for a loopback server, so the bare command is correct only for
// the default local server. For a non-default port or tunnel/remote base, emit the
// URL (plus a key for non-loopback) so the copy targets what the UI shows.
const DEFAULT_STUDIO_PORT = "8888";
const DEFAULT_AGENT = "claude";
// URL.hostname brackets IPv6 literals (`new URL("http://[::1]:8888").hostname` is
// "[::1]"), so strip the brackets before matching the bare "::1" loopback rules below.
function normalizeHost(host: string): string {
const lower = host.toLowerCase();
return lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
}
// The bare `unsloth start` probes exactly http://127.0.0.1:8888, so only that literal
// host earns the bare command. `localhost` can resolve to ::1 (and `::1` is never
// probed), so both keep an explicit UNSLOTH_STUDIO_URL -- harmless when they alias
// 127.0.0.1, correct when they don't.
function isDefaultLocalHost(host: string): boolean {
return host === "127.0.0.1";
}
// Match the CLI auto-mint rule (is_loopback_url): localhost, ::1, and all of 127.0.0.0/8.
function isLoopbackHost(host: string): boolean {
if (host === "localhost" || host === "::1") return true;
const octets = host.split(".");
return (
octets.length === 4 &&
octets[0] === "127" &&
octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)
);
}
export function buildAgentCommand(
base: string | null | undefined,
key: string | null | undefined,
os: "unix" | "windows",
agent: string = DEFAULT_AGENT,
): string {
const bare = `unsloth start ${agent}`;
let url: URL | null = null;
try {
if (base) url = new URL(base);
} catch {
url = null;
}
// Unknown base: fall back to the bare default-local command.
if (!url) return bare;
const host = normalizeHost(url.hostname);
const loopback = isLoopbackHost(host);
// Default local server (http://127.0.0.1/localhost:8888): bare command
// auto-discovers it. The CLI's bare default probes plain HTTP, so an HTTPS
// loopback on the same port must keep its explicit UNSLOTH_STUDIO_URL.
if (url.protocol === "http:" && isDefaultLocalHost(host) && url.port === DEFAULT_STUDIO_PORT) {
return bare;
}
// Non-default server: set the URL; non-loopback also needs an explicit key.
let cmd = bare;
if (!loopback && key) cmd += ` --api-key ${key}`;
const studioUrl = url.origin;
return os === "windows"
? `$env:UNSLOTH_STUDIO_URL="${studioUrl}"; ${cmd}`
: `UNSLOTH_STUDIO_URL=${studioUrl} ${cmd}`;
}

View file

@ -0,0 +1,134 @@
// 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 {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Spinner } from "@/components/ui/spinner";
import type { PipelineType } from "@huggingface/hub";
import { useHubModelSearch } from "@/features/hub/hooks/use-hub-model-search";
import { useDebouncedValue } from "@/hooks";
import { type ReactElement, useMemo, useRef } from "react";
// HF pipeline filter for embedding models; matches the backend's
// is_embedding_model signals (sentence-similarity / feature-extraction).
const EMBEDDING_TASKS: readonly PipelineType[] = [
"sentence-similarity",
"feature-extraction",
];
type EmbeddingModelComboboxProps = {
value: string;
/** Fires on typing, selection, and Enter with the current text. */
onChange: (value: string) => void;
accessToken?: string;
disabled?: boolean;
placeholder?: string;
ariaLabel?: string;
className?: string;
};
export function EmbeddingModelCombobox({
value,
onChange,
accessToken,
disabled,
placeholder,
ariaLabel,
className,
}: EmbeddingModelComboboxProps): ReactElement {
const selectingRef = useRef(false);
const anchorRef = useRef<HTMLDivElement>(null);
// Fully controlled: the parent updates value on every keystroke, so the
// prop itself is the search query.
const debouncedQuery = useDebouncedValue(value);
const { results, isLoading } = useHubModelSearch(debouncedQuery, {
task: EMBEDDING_TASKS,
accessToken,
excludeGguf: true,
enabled: !disabled,
// Curated unsloth listing when empty (the global top-downloads page holds
// no unsloth mirrors to float); a typed query searches the whole Hub.
ownerScope: debouncedQuery.trim() ? "all" : "unsloth",
});
const items = useMemo(() => {
const ids = results.map((item) => item.id);
const selected = value.trim();
if (selected && !ids.includes(selected)) {
ids.push(selected);
}
return ids;
}, [results, value]);
return (
<div
ref={anchorRef}
className={className}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (!(event.target instanceof HTMLInputElement)) return;
event.preventDefault();
const typed = event.target.value.trim();
if (typed) {
onChange(typed);
} else if (items.length > 0) {
onChange(items[0]);
}
}}
>
<Combobox
items={items}
filteredItems={items}
filter={null}
value={value.trim() ? value : null}
onValueChange={(next) => onChange(next ?? "")}
onInputValueChange={(next) => {
if (selectingRef.current) {
selectingRef.current = false;
return;
}
onChange(next);
}}
itemToStringValue={(item) => item}
autoHighlight={true}
>
<ComboboxInput
className="h-8 w-full font-mono [&_input]:text-[11px]"
placeholder={placeholder}
aria-label={ariaLabel}
disabled={disabled}
/>
<ComboboxContent anchor={anchorRef}>
{isLoading ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
<Spinner className="size-3.5" />
Searching...
</div>
) : (
<ComboboxEmpty>No embedding models found</ComboboxEmpty>
)}
<ComboboxList>
{(id: string) => (
<ComboboxItem
key={id}
value={id}
onPointerDown={() => {
selectingRef.current = true;
}}
>
<span className="truncate font-mono text-[11px]">{id}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
);
}

View file

@ -32,45 +32,60 @@ import {
loadOpenAIAutoSwitchSettings,
updateOpenAIAutoSwitchSettings,
} from "../api/openai-auto-switch";
import { buildAgentCommand } from "./agent-command";
// API call type; OS axis applies to curl only (Python is OS-identical).
type ExampleType =
| "curl"
| "python"
| "javascript"
| "curlTools"
| "pythonTools"
| "javascriptTools"
| "curlAdvanced"
| "pythonAdvanced";
| "pythonAdvanced"
| "javascriptAdvanced";
type Os = "unix" | "windows";
// plain = bare call; tools = server-side tools; advanced = sampling + thinking + tools.
type Variant = "plain" | "tools" | "advanced";
const TYPE_TABS: { id: ExampleType; label: string }[] = [
{ id: "curl", label: "curl" },
{ id: "python", label: "Python" },
{ id: "javascript", label: "JavaScript" },
{ id: "curlTools", label: "curl + tools" },
{ id: "pythonTools", label: "Python + tools" },
{ id: "javascriptTools", label: "JavaScript + tools" },
{ id: "curlAdvanced", label: "curl + advanced" },
{ id: "pythonAdvanced", label: "Python + advanced" },
{ id: "javascriptAdvanced", label: "JavaScript + advanced" },
];
const TYPE_LABEL_KEY: Partial<Record<ExampleType, TranslationKey>> = {
curlTools: "settings.apiKeys.exampleCurlTools",
pythonTools: "settings.apiKeys.examplePythonTools",
javascriptTools: "settings.apiKeys.exampleJavaScriptTools",
curlAdvanced: "settings.apiKeys.exampleCurlAdvanced",
pythonAdvanced: "settings.apiKeys.examplePythonAdvanced",
javascriptAdvanced: "settings.apiKeys.exampleJavaScriptAdvanced",
};
const OS_AWARE: Record<ExampleType, boolean> = {
curl: true,
python: false,
javascript: false,
curlTools: true,
pythonTools: false,
javascriptTools: false,
curlAdvanced: true,
pythonAdvanced: false,
javascriptAdvanced: false,
};
const CURL_TYPES = new Set<ExampleType>(["curl", "curlTools", "curlAdvanced"]);
const JAVASCRIPT_TYPES = new Set<ExampleType>([
"javascript",
"javascriptTools",
"javascriptAdvanced",
]);
const PROMPT = "Can Unsloth Studio do API calling?";
// Auto-switch demo: a second call naming a different downloaded GGUF so the
@ -82,7 +97,6 @@ const SWITCH_MODEL = "your-other-downloaded-GGUF";
const SWITCH_PROMPT = "Now answer as a different model.";
// web_search + python + terminal are the reliable built-in tools.
const TOOLS = ["web_search", "python", "terminal"];
// Sampling/thinking knobs for the "+ advanced" examples.
const ADV = {
temperature: 0.7,
top_p: 0.8,
@ -93,37 +107,18 @@ const ADV = {
} as const;
const DOC_LINKS = [
{
label: "Claude Code",
href: "https://unsloth.ai/docs/basics/claude-code",
},
{
label: "Codex",
href: "https://unsloth.ai/docs/basics/codex",
},
{
label: "OpenClaw",
href: "https://unsloth.ai/docs/integrations/openclaw",
},
{
label: "OpenCode",
href: "https://unsloth.ai/docs/integrations/opencode",
},
{
label: "Hermes Agent",
href: "https://unsloth.ai/docs/integrations/hermes-agent",
},
{ label: "Claude Code", href: "https://unsloth.ai/docs/basics/claude-code" },
{ label: "Codex", href: "https://unsloth.ai/docs/basics/codex" },
{ label: "OpenClaw", href: "https://unsloth.ai/docs/integrations/openclaw" },
{ label: "OpenCode", href: "https://unsloth.ai/docs/integrations/opencode" },
{ label: "Hermes Agent", href: "https://unsloth.ai/docs/integrations/hermes-agent" },
];
// JSON-encode; also a valid Python literal, so odd model names never break output.
const j = (s: string): string => JSON.stringify(s);
// Embed in a POSIX single-quoted string: close, escaped quote, reopen.
const shSingle = (s: string): string => s.replace(/'/g, "'\\''");
// Embed in a PowerShell single-quoted string: '' is a literal quote.
const psSingle = (s: string): string => s.replace(/'/g, "''");
const toolsJson = TOOLS.map(j).join(", ");
// Shared body fields (after model/messages, before stream) per variant.
function bodyExtraLines(variant: Variant, indent: string): string[] {
const lines: string[] = [];
if (variant === "advanced") {
@ -152,7 +147,6 @@ function curlBodyPretty(model: string, variant: Variant): string {
return `{\n${lines.join("\n")}\n }`;
}
// One-line JSON for the Windows body file (PowerShell mangles inline quotes to curl.exe).
function winBody(model: string, variant: Variant): string {
const body: Record<string, unknown> = {
model,
@ -172,7 +166,7 @@ function winBody(model: string, variant: Variant): string {
body.enabled_tools = TOOLS;
}
body.stream = true;
return JSON.stringify(body);
return JSON.stringify(body, null, 2);
}
// A leading comment (valid in both bash and PowerShell) noting the model field
@ -193,7 +187,6 @@ function curlUnix(
-d '${shSingle(curlBodyPretty(model, variant))}'`;
}
// Windows PowerShell: curl aliases to Invoke-WebRequest, so use curl.exe + body file.
function curlWindows(
base: string,
key: string,
@ -233,7 +226,6 @@ function pythonSnippet(
variant: Variant,
autoSwitch: boolean,
): string {
// Standard OpenAI args are named; Unsloth extensions go through extra_body.
const named =
variant === "advanced"
? `
@ -258,7 +250,6 @@ function pythonSnippet(
${extra.join("\n")}
},`
: "";
// With tools, some chunks are tool-lifecycle events with no choices; guard it.
const loop =
variant !== "plain"
? `for chunk in response:
@ -281,6 +272,70 @@ response = client.chat.completions.create(
${loop}${autoSwitch ? pythonSwitchDemo() : ""}`;
}
function javascriptSnippet(
base: string,
key: string,
model: string,
variant: Variant,
autoSwitch: boolean,
): string {
const options: string[] = [];
if (variant === "advanced") {
options.push(` temperature: ${ADV.temperature},`);
options.push(` top_p: ${ADV.top_p},`);
options.push(` max_tokens: ${ADV.max_tokens},`);
}
// The JS SDK forwards unknown options into the request body, so these go at the
// top level (the Python SDK needs them under extra_body instead).
if (variant === "advanced") {
options.push(` top_k: ${ADV.top_k},`);
options.push(` min_p: ${ADV.min_p},`);
options.push(` repetition_penalty: ${ADV.repetition_penalty},`);
options.push(` enable_thinking: true,`);
}
if (variant !== "plain") {
options.push(` enable_tools: true,`);
options.push(` enabled_tools: [${toolsJson}],`);
}
const trailingOptions = options.length ? `\n${options.join("\n")}` : "";
return `import OpenAI from "openai";
const client = new OpenAI({
baseURL: ${j(`${base}/v1`)},
apiKey: ${j(key)},
});
const response = await client.chat.completions.create({
model: ${j(model)},
messages: [{ role: "user", content: ${j(PROMPT)} }],${trailingOptions}
stream: true,
});
for await (const chunk of response) {
process.stdout.write(chunk.choices?.[0]?.delta?.content || "");
}${autoSwitch ? javascriptSwitchDemo() : ""}`;
}
function javascriptSwitchDemo(): string {
return `
// "Switch model by request" is on: replace the model below with another GGUF you
// have downloaded and Studio loads it before serving. Unknown names keep serving
// the current model.
const switchResponse = await client.chat.completions.create({
model: ${j(SWITCH_MODEL)},
messages: [{ role: "user", content: ${j(SWITCH_PROMPT)} }],
stream: true,
});
for await (const chunk of switchResponse) {
process.stdout.write(chunk.choices?.[0]?.delta?.content || "");
}`;
}
function buildSnippets(
base: string,
key: string,
@ -292,17 +347,24 @@ function buildSnippets(
return {
curl: curl(base, key, model, "plain", autoSwitch),
python: pythonSnippet(base, key, model, "plain", autoSwitch),
javascript: javascriptSnippet(base, key, model, "plain", autoSwitch),
curlTools: curl(base, key, model, "tools", autoSwitch),
pythonTools: pythonSnippet(base, key, model, "tools", autoSwitch),
javascriptTools: javascriptSnippet(base, key, model, "tools", autoSwitch),
curlAdvanced: curl(base, key, model, "advanced", autoSwitch),
pythonAdvanced: pythonSnippet(base, key, model, "advanced", autoSwitch),
javascriptAdvanced: javascriptSnippet(
base,
key,
model,
"advanced",
autoSwitch,
),
};
}
const KEY_PLACEHOLDER = "sk-unsloth-YOUR_KEY";
const MODEL_FALLBACK = "unsloth/gemma-4-E4B-it-GGUF:UD-Q5_K_XL";
// Default ON: when a tunnel exists, examples should show the public base_url.
const USE_TUNNEL_KEY = "unsloth_api_use_tunnel";
function readUseTunnelPref(): boolean {
@ -319,11 +381,10 @@ function writeUseTunnelPref(value: boolean): void {
try {
window.localStorage.setItem(USE_TUNNEL_KEY, value ? "true" : "false");
} catch {
// Non-fatal: the toggle still applies for this session.
// Non-fatal
}
}
// Active local checkpoint as repo[:variant]; external/none falls back to a default.
function useLoadedModelName(): string {
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const ggufVariant = useChatRuntimeStore((s) => s.activeGgufVariant);
@ -338,7 +399,6 @@ function useLoadedModelName(): string {
}, [checkpoint, ggufVariant]);
}
// shiki highlighting via the app's shared code plugin + themes (same as chat).
const SHIKI_THEMES = [unslothLightTheme, unslothDarkTheme] as [
typeof unslothLightTheme,
typeof unslothDarkTheme,
@ -352,7 +412,6 @@ function HighlightedCode({
code: string;
language: string;
}) {
// Fence so Streamdown's shiki plugin highlights it (no markdown inside a fence).
const markdown = useMemo(
() => `\`\`\`${language}\n${code}\n\`\`\``,
[code, language],
@ -383,6 +442,7 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
);
const [copied, setCopied] = useState(false);
const [copiedUrl, setCopiedUrl] = useState(false);
const [copiedAgent, setCopiedAgent] = useState(false);
const [useTunnel, setUseTunnel] = useState<boolean>(readUseTunnelPref);
// null while loading; the same setting the General tab exposes (shared cache).
const [autoSwitch, setAutoSwitch] = useState<OpenAIAutoSwitchSettings | null>(
@ -390,7 +450,6 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
);
const [savingAutoSwitch, setSavingAutoSwitch] = useState(false);
// Tunnel may start after the first /api/health read; refresh so it surfaces here.
useEffect(() => {
void fetchDeviceType({ force: true });
}, []);
@ -410,10 +469,7 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
}, []);
const model = useLoadedModelName();
// Real key while revealed (before "Done"); otherwise a placeholder.
const key = apiKey || KEY_PLACEHOLDER;
// Toggle on + tunnel up: public tunnel URL. Off: backend direct host:port
// (origin is only a last-resort fallback).
const origin = typeof window !== "undefined" ? window.location.origin : "";
const base =
useTunnel && cloudflareUrl ? cloudflareUrl : (serverUrl ?? origin);
@ -423,13 +479,20 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
() => buildSnippets(base, key, model, os, autoSwitchOn),
[base, key, model, os, autoSwitchOn],
);
// Agent command must target the server the panel shows, not the :8888 default.
const agentCommand = useMemo(
() => buildAgentCommand(base, key, os),
[base, key, os],
);
const osAware = OS_AWARE[lang];
const shikiLang = CURL_TYPES.has(lang)
? os === "windows"
? "powershell"
: "bash"
: "python";
: JAVASCRIPT_TYPES.has(lang)
? "javascript"
: "python";
const handleCopy = async () => {
if (await copyToClipboard(snippets[lang])) {
@ -464,6 +527,13 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
}
};
const handleCopyAgent = async () => {
if (await copyToClipboard(agentCommand)) {
setCopiedAgent(true);
setTimeout(() => setCopiedAgent(false), 1800);
}
};
return (
<section className="flex min-w-0 max-w-full flex-col">
<h2 className="mb-2 text-sm font-semibold text-foreground">
@ -539,8 +609,6 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
</Tooltip>
)}
</div>
{/* Always rendered (dimmed when off) so toggling never changes the
row height and shifts the code block below. */}
<button
type="button"
onClick={handleCopyUrl}
@ -629,15 +697,39 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
/>
{copied ? t("settings.apiKeys.copied") : t("settings.apiKeys.copy")}
</button>
{/* key on the snippet so Streamdown remounts and re-highlights when
only a substring (e.g. the base URL) changes; its block memo
otherwise keeps the stale render. */}
<HighlightedCode
key={snippets[lang]}
code={snippets[lang]}
language={shikiLang}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5 border-t border-border px-3 py-2.5">
<span className="text-[11px] font-semibold text-foreground">
{t("settings.apiKeys.codingAgents")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("settings.apiKeys.codingAgentsHint")}
</span>
<div className="relative mt-0.5 min-w-0">
<code className="block min-w-0 overflow-x-auto rounded border border-border bg-muted/30 px-2 py-1.5 pr-14 font-mono text-[11px] text-foreground">
{agentCommand}
</code>
<button
type="button"
onClick={handleCopyAgent}
className="absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 rounded border border-border bg-background/80 px-1.5 py-0.5 text-[11px] text-muted-foreground backdrop-blur transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("settings.apiKeys.copySnippet")}
>
<HugeiconsIcon
icon={copiedAgent ? Tick02Icon : Copy01Icon}
className={cn("size-3.5", copiedAgent && "text-emerald-600")}
/>
</button>
</div>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("settings.apiKeys.codingAgentsSwap")}
</span>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
<span>{t("settings.apiKeys.setupDocs")}</span>
{DOC_LINKS.map((link) => (

View file

@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import {
Cancel01Icon,
CloudIcon,
CpuIcon,
Globe02Icon,
HelpCircleIcon,
Message01Icon,
@ -33,6 +34,8 @@ import { ChatTab } from "./tabs/chat-tab";
import { ConnectionsTab } from "./tabs/connections-tab";
import { GeneralTab } from "./tabs/general-tab";
import { ProfileTab } from "./tabs/profile-tab";
import { ResourcesTab } from "./tabs/resources-tab";
import { FloatingMonitor } from "@/components/floating-monitor";
interface TabDef {
id: SettingsTab;
@ -49,6 +52,11 @@ const TABS: TabDef[] = [
labelKey: "settings.tabs.appearance",
icon: PaintBrush02Icon,
},
{
id: "resources",
labelKey: "settings.tabs.resources",
icon: CpuIcon,
},
{
id: "chat",
labelKey: "settings.tabs.chat",
@ -77,6 +85,8 @@ function renderTab(tab: SettingsTab) {
return <ProfileTab />;
case "appearance":
return <AppearanceTab />;
case "resources":
return <ResourcesTab />;
case "chat":
return <ChatTab />;
case "connections":
@ -100,6 +110,7 @@ export function SettingsDialog() {
general: null,
profile: null,
appearance: null,
resources: null,
chat: null,
connections: null,
"api-keys": null,
@ -115,110 +126,113 @@ export function SettingsDialog() {
}, [open, activeTab]);
return (
<Dialog open={open} onOpenChange={(o) => !o && closeDialog()}>
<DialogContent
showCloseButton={false}
overlayClassName="bg-black/30 supports-backdrop-filter:backdrop-blur-[2px]"
onCloseAutoFocus={(e) => {
// Restore focus to the element that triggered openDialog(). Radix's
// FocusScope races our rAF-scheduled tab focus and loses the
// previous-focus reference, so restore it by hand.
if (opener && opener.isConnected) {
e.preventDefault();
opener.focus({ preventScroll: true });
}
}}
className={cn(
// Cap at 820px but shrink to the viewport so it doesn't clip on
// iPad-portrait widths (640-820px) where fixed `w-[820px]` overflows.
"settings-surface !max-w-[min(820px,calc(100vw-2rem))] h-[560px] w-[min(820px,calc(100vw-2rem))] p-0 overflow-hidden",
// Soft shadow, no outline ring. Pin --radius to the light value so
// corner rounding matches in dark mode.
"shadow-border rounded-xl ring-0 [--radius:1.1rem]",
"max-sm:h-dvh max-sm:w-dvw max-sm:!max-w-none max-sm:rounded-none",
)}
>
<DialogTitle className="sr-only">
{t("settings.dialog.title")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("settings.dialog.description")}
</DialogDescription>
<div className="flex h-full min-h-0 max-sm:flex-col">
<aside className="font-heading flex w-[216px] shrink-0 flex-col border-r border-sidebar-border bg-muted/20 p-2 dark:border-r-0 max-sm:w-full max-sm:border-r-0 max-sm:border-b max-sm:border-sidebar-border">
<h2 className="pl-3 pr-2.5 pt-3.5 pb-3.5 text-[19px] font-semibold text-foreground max-sm:hidden">
{t("settings.dialog.title")}
</h2>
<nav className="flex flex-col gap-0.5 max-sm:flex-row max-sm:overflow-x-auto">
{TABS.map((tab) => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
ref={(node) => {
tabButtonRefs.current[tab.id] = node;
}}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"relative flex h-[32px] items-center gap-2.5 rounded-full pl-3 pr-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
"max-sm:shrink-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "text-black dark:text-white"
: "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#3a3d43] hover:text-black dark:hover:text-white",
)}
>
{active && (
<motion.span
layoutId="settings-active-pill"
className="absolute inset-0 rounded-full bg-[#ececec] dark:bg-[#3a3d43]"
transition={
reduced
? { duration: 0 }
: {
<>
<Dialog open={open} onOpenChange={(o) => !o && closeDialog()}>
<DialogContent
showCloseButton={false}
overlayClassName="bg-black/30 supports-backdrop-filter:backdrop-blur-[2px]"
onCloseAutoFocus={(e) => {
// Restore focus to the element that triggered openDialog(). Radix's
// FocusScope races our rAF-scheduled tab focus and loses the
// previous-focus reference, so restore it by hand.
if (opener && opener.isConnected) {
e.preventDefault();
opener.focus({ preventScroll: true });
}
}}
className={cn(
// Cap at 820px but shrink to the viewport so it doesn't clip on
// iPad-portrait widths (640-820px) where fixed `w-[820px]` overflows.
"settings-surface !max-w-[min(820px,calc(100vw-2rem))] h-[560px] w-[min(820px,calc(100vw-2rem))] p-0 overflow-hidden",
// Soft shadow, no outline ring. Pin --radius to the light value so
// corner rounding matches in dark mode.
"shadow-border rounded-xl ring-0 [--radius:1.1rem]",
"max-sm:h-dvh max-sm:w-dvw max-sm:!max-w-none max-sm:rounded-none",
)}
>
<DialogTitle className="sr-only">
{t("settings.dialog.title")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("settings.dialog.description")}
</DialogDescription>
<div className="flex h-full min-h-0 max-sm:flex-col">
<aside className="font-heading flex w-[216px] shrink-0 flex-col border-r border-sidebar-border bg-muted/20 p-2 dark:border-r-0 max-sm:w-full max-sm:border-r-0 max-sm:border-b max-sm:border-sidebar-border">
<h2 className="pl-3 pr-2.5 pt-3.5 pb-3.5 text-[19px] font-semibold text-foreground max-sm:hidden">
{t("settings.dialog.title")}
</h2>
<nav className="flex flex-col gap-0.5 max-sm:flex-row max-sm:overflow-x-auto">
{TABS.map((tab) => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
ref={(node) => {
tabButtonRefs.current[tab.id] = node;
}}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"relative flex h-[32px] items-center gap-2.5 rounded-full pl-3 pr-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
"max-sm:shrink-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "text-black dark:text-white"
: "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#3a3d43] hover:text-black dark:hover:text-white",
)}
>
{active && (
<motion.span
layoutId="settings-active-pill"
className="absolute inset-0 rounded-full bg-[#ececec] dark:bg-[#3a3d43]"
transition={
reduced
? { duration: 0 }
: {
type: "spring",
stiffness: 500,
damping: 35,
mass: 0.5,
}
}
}
/>
)}
<HugeiconsIcon
icon={tab.icon}
strokeWidth={1.75}
className="relative z-10 size-icon"
/>
)}
<HugeiconsIcon
icon={tab.icon}
strokeWidth={1.75}
className="relative z-10 size-icon"
/>
<span className="relative z-10 min-w-0 truncate">
{t(tab.labelKey)}
</span>
{tab.badgeKey ? (
<span className="relative z-10 ml-auto rounded-full bg-emerald-500/10 px-2 py-1 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
{t(tab.badgeKey)}
<span className="relative z-10 min-w-0 truncate">
{t(tab.labelKey)}
</span>
) : null}
</button>
);
})}
</nav>
</aside>
{tab.badgeKey ? (
<span className="relative z-10 ml-auto rounded-full bg-emerald-500/10 px-2 py-1 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
{t(tab.badgeKey)}
</span>
) : null}
</button>
);
})}
</nav>
</aside>
<main className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<button
type="button"
onClick={closeDialog}
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-full text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#3a3d43] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("settings.dialog.closeAriaLabel")}
>
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
</button>
<div className="hover-scrollbar flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6 [scrollbar-gutter:stable]">
{renderTab(activeTab)}
</div>
</main>
</div>
</DialogContent>
</Dialog>
<main className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<button
type="button"
onClick={closeDialog}
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-full text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#3a3d43] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("settings.dialog.closeAriaLabel")}
>
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
</button>
<div className="hover-scrollbar flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6 [scrollbar-gutter:stable]">
{renderTab(activeTab)}
</div>
</main>
</div>
</DialogContent>
</Dialog>
<FloatingMonitor />
</>
);
}

View file

@ -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";
interface MonitorOverlayState {
isOpen: boolean;
isMinimized: boolean;
setIsOpen: (open: boolean) => void;
toggleMinimized: () => void;
}
export const useMonitorOverlayStore = create<MonitorOverlayState>()(
persist(
(set) => ({
isOpen: false,
isMinimized: false,
setIsOpen: (isOpen) => set({ isOpen }),
toggleMinimized: () => set((state) => ({ isMinimized: !state.isMinimized })),
}),
{ name: "unsloth_monitor_overlay" }
)
);

View file

@ -7,6 +7,7 @@ export type SettingsTab =
| "general"
| "profile"
| "appearance"
| "resources"
| "chat"
| "connections"
| "api-keys"
@ -60,6 +61,7 @@ function loadInitialTab(): SettingsTab {
"general",
"profile",
"appearance",
"resources",
"chat",
"connections",
"api-keys",

View file

@ -17,6 +17,7 @@ import { fetchApiKeys, revokeApiKey, type ApiKey } from "../api/api-keys";
import { ApiMonitorConsole } from "../components/api-monitor-console";
import { ApiKeyRow } from "../components/api-key-row";
import { CreateKeyForm } from "../components/create-key-form";
import { ModelAutoSwitchSection } from "../components/model-auto-switch-section";
import { KeyRevealCard } from "../components/key-reveal-card";
import { UsageExamples } from "../components/usage-examples";
@ -171,6 +172,8 @@ export function ApiKeysTab() {
<UsageExamples apiKey={revealed} />
<ModelAutoSwitchSection />
<Dialog open={revokeTarget !== null} onOpenChange={(o) => !o && setRevokeTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>

View file

@ -41,6 +41,13 @@ import {
rotatePreviewLinks,
updatePreviewSharing,
} from "../api/preview-sharing";
import {
type EmbeddingModelSettings,
EmbeddingModelVerificationError,
loadEmbeddingModelSettings,
resetEmbeddingModelSettings,
updateEmbeddingModelSettings,
} from "../api/embedding-model";
import {
DEFAULT_UPLOAD_LIMIT_MB,
type UploadLimitSettings,
@ -48,7 +55,7 @@ import {
updateUploadLimitSettings,
} from "../api/upload-limit";
import { ChangePasswordDialog } from "../components/change-password-dialog";
import { ModelAutoSwitchSection } from "../components/model-auto-switch-section";
import { EmbeddingModelCombobox } from "../components/embedding-model-combobox";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import { StudioVersionSection } from "../components/studio-version-section";
@ -81,6 +88,7 @@ const PREFS_KEYS: string[] = [
"unsloth_chat_load_on_selection",
"unsloth_chat_expand_quantizations",
"unsloth_chat_show_all_quantizations",
"unsloth_models_fit_on_device_only",
// Chat presets
"unsloth_chat_custom_presets",
"unsloth_chat_active_preset",
@ -96,6 +104,7 @@ const PREFS_KEYS: string[] = [
"tour:studio:v1",
// Update notifications
"unsloth_show_llama_update_banner",
"unsloth_monitor_overlay",
];
// Set by resetAllPrefs so the unmount-commit effect skips writing back the
@ -163,6 +172,16 @@ export function GeneralTab() {
const [revokePreviewOpen, setRevokePreviewOpen] = useState(false);
const [isRevokingPreview, setIsRevokingPreview] = useState(false);
const [modelsFolder, setModelsFolder] = useState<ModelsFolder | null>(null);
const [embeddingModel, setEmbeddingModel] =
useState<EmbeddingModelSettings | null>(null);
const [draftEmbeddingModel, setDraftEmbeddingModel] = useState("");
const [embeddingModelError, setEmbeddingModelError] = useState<string | null>(
null,
);
// Set after a 409 (unverifiable model); offers "Save anyway".
const [embeddingModelNeedsForce, setEmbeddingModelNeedsForce] =
useState(false);
const [isSavingEmbeddingModel, setIsSavingEmbeddingModel] = useState(false);
const draftRef = useRef(draftToken);
useEffect(() => {
@ -257,6 +276,27 @@ export function GeneralTab() {
};
}, [t]);
useEffect(() => {
let cancelled = false;
void loadEmbeddingModelSettings()
.then((settings) => {
if (cancelled) return;
setEmbeddingModel(settings);
setDraftEmbeddingModel(settings.embeddingModel);
})
.catch((error) => {
if (cancelled) return;
setEmbeddingModelError(
error instanceof Error
? error.message
: t("settings.general.rag.loadError"),
);
});
return () => {
cancelled = true;
};
}, [t]);
useEffect(() => {
let cancelled = false;
void loadModelsFolder()
@ -349,6 +389,58 @@ export function GeneralTab() {
}
};
const saveEmbeddingModel = async (force: boolean) => {
const trimmed = draftEmbeddingModel.trim();
if (!trimmed) {
setEmbeddingModelError(t("settings.general.rag.emptyError"));
return;
}
setIsSavingEmbeddingModel(true);
setEmbeddingModelError(null);
try {
const settings = await updateEmbeddingModelSettings(trimmed, {
hfToken: hfToken || undefined,
force,
});
setEmbeddingModel(settings);
setDraftEmbeddingModel(settings.embeddingModel);
setEmbeddingModelNeedsForce(false);
toast.success(t("settings.general.rag.saved"), {
description: t("settings.general.rag.reindexWarning"),
});
} catch (error) {
if (error instanceof EmbeddingModelVerificationError) {
setEmbeddingModelNeedsForce(true);
}
setEmbeddingModelError(
error instanceof Error
? error.message
: t("settings.general.rag.saveError"),
);
} finally {
setIsSavingEmbeddingModel(false);
}
};
const resetEmbeddingModel = async () => {
setIsSavingEmbeddingModel(true);
setEmbeddingModelError(null);
setEmbeddingModelNeedsForce(false);
try {
const settings = await resetEmbeddingModelSettings();
setEmbeddingModel(settings);
setDraftEmbeddingModel(settings.embeddingModel);
} catch (error) {
setEmbeddingModelError(
error instanceof Error
? error.message
: t("settings.general.rag.saveError"),
);
} finally {
setIsSavingEmbeddingModel(false);
}
};
const saveUploadLimit = async () => {
const parsed = Number(draftUploadLimit);
if (!Number.isInteger(parsed)) {
@ -499,38 +591,6 @@ export function GeneralTab() {
</SettingsRow>
</SettingsSection>
<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>
<ModelAutoSwitchSection />
<SettingsSection
title={t("settings.general.previewSharing.sectionTitle")}
>
@ -567,6 +627,77 @@ export function GeneralTab() {
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.general.rag.sectionTitle")}>
<SettingsRow
label={t("settings.general.rag.embeddingModel")}
description={t("settings.general.rag.embeddingModelDescription", {
defaultModel: embeddingModel?.defaultEmbeddingModel ?? "",
})}
>
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2">
<EmbeddingModelCombobox
value={draftEmbeddingModel}
onChange={(next) => {
setDraftEmbeddingModel(next);
setEmbeddingModelNeedsForce(false);
setEmbeddingModelError(null);
}}
accessToken={hfToken || undefined}
disabled={!embeddingModel}
placeholder={embeddingModel?.defaultEmbeddingModel ?? ""}
ariaLabel={t("settings.general.rag.embeddingModel")}
className="w-[220px]"
/>
<Button
variant="outline"
size="sm"
disabled={
!embeddingModel ||
isSavingEmbeddingModel ||
draftEmbeddingModel.trim() === embeddingModel.embeddingModel
}
onClick={() => void saveEmbeddingModel(false)}
>
{isSavingEmbeddingModel
? t("common.saving")
: t("common.save")}
</Button>
</div>
{embeddingModelError ? (
<span className="max-w-[300px] text-right text-xs text-destructive">
{embeddingModelError}
</span>
) : null}
<div className="flex items-center gap-2">
{embeddingModelNeedsForce ? (
<Button
variant="outline"
size="sm"
disabled={isSavingEmbeddingModel}
onClick={() => void saveEmbeddingModel(true)}
>
{t("settings.general.rag.saveAnyway")}
</Button>
) : null}
{embeddingModel?.isCustom ? (
<Button
variant="ghost"
size="sm"
disabled={isSavingEmbeddingModel}
onClick={() => void resetEmbeddingModel()}
>
{t("settings.general.rag.resetAction")}
</Button>
) : null}
</div>
<span className="max-w-[300px] text-right text-xs text-muted-foreground">
{t("settings.general.rag.reindexWarning")}
</span>
</div>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.general.uploads.sectionTitle")}>
<SettingsRow
label={t("settings.general.uploads.maxUploadSize")}
@ -632,6 +763,36 @@ export function GeneralTab() {
</SettingsSection>
)}
<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.resetPreferences.sectionTitle")}
>

View file

@ -0,0 +1,477 @@
// 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 { Progress } from "@/components/ui/progress";
import { Switch } from "@/components/ui/switch";
import { openModelsDir } from "@/features/native-intents";
import { useSystemInfo, type GpuDevice } from "@/hooks/use-system";
import { isTauri } from "@/lib/api-base";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { useT } from "@/i18n";
import { useEffect, useMemo, useState } from "react";
import { loadModelsFolder, type ModelsFolder } from "../api/models-folder";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import { useMonitorOverlayStore } from "../stores/monitor-overlay-store";
import { LayersIcon } from "lucide-react";
const POLL_MS = 3000;
function isFiniteNumber(value: number | null | undefined): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function clampPercent(value: number | null | undefined): number {
if (!isFiniteNumber(value)) return 0;
return Math.max(0, Math.min(100, value));
}
function usageIndicatorClass(percent: number): string {
if (percent >= 90) return "bg-destructive";
if (percent >= 70) return "bg-amber-500";
return "bg-primary";
}
function usageTextClass(percent: number): string {
if (percent >= 90) return "text-destructive";
if (percent >= 70) return "text-amber-600 dark:text-amber-400";
return "text-primary";
}
function formatGb(value: number | null | undefined): string {
const safe = isFiniteNumber(value) ? Math.max(0, value) : 0;
const digits = safe >= 10 ? 1 : 2;
return `${safe.toFixed(digits)} GB`;
}
function formatMb(value: number | null | undefined): string {
const safe = isFiniteNumber(value) ? Math.max(0, value) : 0;
return `${Math.round(safe).toLocaleString()} MB`;
}
function formatPercent(value: number | null | undefined): string {
return `${Math.round(clampPercent(value))}%`;
}
function formatFrequency(mhz: number | null | undefined): string | null {
if (!isFiniteNumber(mhz) || mhz <= 0) return null;
if (mhz >= 1000) return `${(mhz / 1000).toFixed(2)} GHz`;
return `${Math.round(mhz)} MHz`;
}
function formatUptime(seconds: number | null | undefined): string {
if (!isFiniteNumber(seconds) || seconds <= 0) return "0m";
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
return `${Math.max(1, minutes)}m`;
}
function MetricTile({
label,
value,
detail,
percent,
}: {
label: string;
value: string;
detail: string;
percent: number;
}) {
const safePercent = clampPercent(percent);
return (
<div className="flex min-w-0 flex-col gap-2 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{label}
</span>
<span
className={cn(
"shrink-0 font-mono text-xs tabular-nums",
usageTextClass(safePercent),
)}
>
{formatPercent(safePercent)}
</span>
</div>
<div className="min-w-0">
<div className="truncate font-mono text-sm tabular-nums text-foreground">
{value}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{detail}
</div>
</div>
<Progress
value={safePercent}
aria-label={label}
className="h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(safePercent)}
/>
</div>
);
}
function InfoRow({
label,
value,
detail,
}: {
label: string;
value: string;
detail?: string;
}) {
return (
<div className="flex min-w-0 items-center justify-between gap-4 py-2.5">
<span className="min-w-0 truncate text-sm font-medium text-foreground">
{label}
</span>
<span
title={detail ?? value}
className="min-w-0 max-w-[60%] truncate text-right font-mono text-xs tabular-nums text-muted-foreground"
>
{detail ? `${value} (${detail})` : value}
</span>
</div>
);
}
function deviceOrdinal(device: GpuDevice): number | undefined {
return device.visible_ordinal ?? device.index;
}
export function ResourcesTab() {
const t = useT();
const [liveUpdates, setLiveUpdates] = useState(true);
const { isOpen, setIsOpen } = useMonitorOverlayStore();
const systemInfo = useSystemInfo({
enabled: liveUpdates,
pollMs: liveUpdates ? POLL_MS : undefined,
});
const [modelsFolder, setModelsFolder] = useState<ModelsFolder | null>(null);
const [modelsFolderLoaded, setModelsFolderLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
void loadModelsFolder()
.then((folder) => {
if (cancelled) return;
setModelsFolder(folder);
setModelsFolderLoaded(true);
})
.catch(() => {
if (cancelled) return;
setModelsFolderLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
const metrics = useMemo(() => {
const devices = systemInfo.gpu?.devices ?? [];
const ramTotal = systemInfo.memory?.total_gb ?? 0;
const ramAvailable = systemInfo.memory?.available_gb ?? 0;
const ramUsed = Math.max(0, ramTotal - ramAvailable);
const diskTotal = systemInfo.disk?.total_gb ?? 0;
const diskFree = systemInfo.disk?.free_gb ?? 0;
const diskUsed = Math.max(0, diskTotal - diskFree);
const vramTotal = devices.reduce(
(sum, device) => sum + (device.memory_total_gb ?? 0),
0,
);
const vramUsed = devices.reduce(
(sum, device) => sum + (device.vram_used_gb ?? 0),
0,
);
const vramFree = devices.reduce(
(sum, device) =>
sum +
(device.vram_free_gb ??
Math.max(0, (device.memory_total_gb ?? 0) - (device.vram_used_gb ?? 0))),
0,
);
const vramPercent = vramTotal > 0 ? (vramUsed / vramTotal) * 100 : 0;
return {
devices,
ramTotal,
ramUsed,
diskTotal,
diskFree,
diskUsed,
vramTotal,
vramUsed,
vramFree,
vramPercent,
};
}, [systemInfo]);
const handleModelsFolder = async () => {
const folder = modelsFolder;
if (!folder) return;
if (isTauri) {
try {
await openModelsDir(folder.path);
} catch (error) {
toast.error(t("settings.resources.storage.openError"), {
description: error instanceof Error ? error.message : undefined,
});
}
return;
}
if (await copyToClipboard(folder.path)) {
toast.success(t("settings.resources.storage.copied"));
} else {
toast.error(t("settings.resources.storage.copyError"));
}
};
const cpuCoresLabel =
systemInfo.cpu?.logical_count && systemInfo.cpu?.physical_count
? t("settings.resources.liveMonitor.cpuCores", {
logical: systemInfo.cpu.logical_count,
physical: systemInfo.cpu.physical_count,
})
: t("settings.resources.environment.unknown");
const cpuFrequencyLabel = formatFrequency(systemInfo.cpu?.frequency_mhz);
const hasGpu =
(systemInfo.gpu?.available ?? false) && metrics.devices.length > 0;
const backendLabel = (
systemInfo.gpu?.backend ?? systemInfo.device_backend ?? "cpu"
).toUpperCase();
const modelsFolderPath = modelsFolder
? modelsFolder.path
: modelsFolderLoaded
? t("settings.resources.environment.unknown")
: t("common.loading");
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-xl font-semibold font-heading">
{t("settings.resources.title")}
</h1>
<p className="text-xs text-muted-foreground">
{t("settings.resources.description")}
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant={isOpen ? "secondary" : "outline"}
size="sm"
className="gap-1.5 h-8 text-xs rounded-full px-3"
onClick={() => setIsOpen(!isOpen)}
>
<LayersIcon className="size-3.5" />
{isOpen
? t("settings.resources.disableOverlay")
: t("settings.resources.floatingWindow")}
</Button>
<div className="flex shrink-0 items-center gap-2 rounded-full border border-border/60 px-2.5 py-1.5 text-xs font-medium text-foreground">
<span>{t("settings.resources.liveUpdates")}</span>
<Switch
aria-label={t("settings.resources.liveUpdates")}
checked={liveUpdates}
onCheckedChange={setLiveUpdates}
/>
</div>
</div>
</header>
<SettingsSection title={t("settings.resources.liveMonitor.title")}>
<div className="grid gap-2 py-3 sm:grid-cols-2">
<MetricTile
label={t("settings.resources.liveMonitor.cpu")}
value={cpuFrequencyLabel ?? cpuCoresLabel}
detail={
cpuFrequencyLabel
? cpuCoresLabel
: t("settings.resources.liveMonitor.currentLoad")
}
percent={systemInfo.cpu?.usage_percent ?? 0}
/>
<MetricTile
label={t("settings.resources.liveMonitor.ram")}
value={`${formatGb(metrics.ramUsed)} / ${formatGb(metrics.ramTotal)}`}
detail={t("settings.resources.liveMonitor.free", {
value: formatGb(systemInfo.memory?.available_gb),
})}
percent={systemInfo.memory?.percent_used ?? 0}
/>
<MetricTile
label={t("settings.resources.liveMonitor.disk")}
value={`${formatGb(metrics.diskUsed)} / ${formatGb(metrics.diskTotal)}`}
detail={t("settings.resources.liveMonitor.free", {
value: formatGb(metrics.diskFree),
})}
percent={systemInfo.disk?.percent_used ?? 0}
/>
<MetricTile
label={t("settings.resources.liveMonitor.vram")}
value={
hasGpu
? `${formatGb(metrics.vramUsed)} / ${formatGb(metrics.vramTotal)}`
: t("settings.resources.liveMonitor.noGpu")
}
detail={
hasGpu
? t("settings.resources.liveMonitor.free", {
value: formatGb(metrics.vramFree),
})
: backendLabel
}
percent={metrics.vramPercent}
/>
</div>
</SettingsSection>
<SettingsSection title={t("settings.resources.gpu.title")}>
{hasGpu ? (
metrics.devices.map((device, index) => {
const ordinal = deviceOrdinal(device);
const total = device.memory_total_gb ?? 0;
const used = device.vram_used_gb ?? 0;
const free = device.vram_free_gb ?? Math.max(0, total - used);
const percent =
device.vram_utilization_pct ??
(total > 0 ? (used / total) * 100 : null);
const safePercent = clampPercent(percent);
return (
<div
key={`${device.index ?? index}-${device.name ?? "gpu"}`}
className="flex min-w-0 flex-col gap-2 py-3"
>
<div className="flex min-w-0 items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{device.name ??
t("settings.resources.gpu.unknownDevice")}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{ordinal === undefined
? backendLabel
: `${t("settings.resources.gpu.deviceWithIndex", {
index: ordinal,
})}, ${backendLabel}`}
</div>
</div>
<div className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
<span>
{formatPercent(safePercent)}{" "}
{t("settings.resources.gpu.vramUtilization")}
</span>
</div>
</div>
<div className="grid gap-1 text-xs text-muted-foreground sm:grid-cols-3 sm:gap-2">
<span className="min-w-0 truncate font-mono tabular-nums">
{t("settings.resources.gpu.used", {
value: formatGb(used),
})}
</span>
<span className="min-w-0 truncate font-mono tabular-nums sm:text-center">
{t("settings.resources.gpu.free", {
value: formatGb(free),
})}
</span>
<span className="min-w-0 truncate font-mono tabular-nums sm:text-right">
{t("settings.resources.gpu.total", {
value: formatGb(total),
})}
</span>
</div>
<Progress
value={safePercent}
aria-label={device.name ?? "GPU"}
className="h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(safePercent)}
/>
</div>
);
})
) : (
<div className="py-3 text-sm text-muted-foreground">
{t("settings.resources.gpu.noGpu")}
</div>
)}
</SettingsSection>
<SettingsSection title={t("settings.resources.storage.title")}>
<InfoRow
label={t("settings.resources.storage.systemDisk")}
value={t("settings.resources.storage.diskUsage", {
used: formatGb(metrics.diskUsed),
total: formatGb(metrics.diskTotal),
})}
detail={t("settings.resources.storage.diskFree", {
free: formatGb(metrics.diskFree),
})}
/>
<SettingsRow
label={t("settings.resources.storage.modelsFolder")}
description={t("settings.resources.storage.modelsFolderDescription")}
className="max-sm:flex-col max-sm:items-start max-sm:gap-2"
>
<div className="flex min-w-0 items-center gap-2 max-sm:max-w-[calc(100vw-5rem)]">
<span
title={modelsFolder?.path}
className="min-w-0 max-w-[280px] truncate font-mono text-xs text-muted-foreground max-sm:max-w-[180px]"
>
{modelsFolderPath}
</span>
<Button
variant="outline"
size="sm"
disabled={!modelsFolder}
onClick={() => void handleModelsFolder()}
>
{isTauri
? t("settings.resources.storage.openAction")
: t("settings.resources.storage.copyAction")}
</Button>
</div>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.resources.environment.title")}>
<InfoRow
label={t("settings.resources.environment.backend")}
value={backendLabel}
/>
<InfoRow
label={t("settings.resources.environment.python")}
value={systemInfo.python_version}
/>
<InfoRow
label={t("settings.resources.environment.torch")}
value={
systemInfo.ml_packages.torch ??
t("settings.resources.environment.notInstalled")
}
/>
<InfoRow
label={t("settings.resources.environment.transformers")}
value={
systemInfo.ml_packages.transformers ??
t("settings.resources.environment.notInstalled")
}
/>
<InfoRow
label={t("settings.resources.environment.uptime")}
value={formatUptime(systemInfo.uptime_seconds)}
/>
<InfoRow
label={t("settings.resources.environment.processMemory")}
value={formatMb(systemInfo.memory?.process_used_mb)}
/>
</SettingsSection>
</div>
);
}

View file

@ -29,6 +29,7 @@ import {
import { getTrainingMethodLabel } from "@/features/training/lib/training-methods";
import type { TrainingViewData } from "@/features/training";
import { useGpuUtilization } from "@/hooks";
import type { GpuUtilization } from "@/hooks/use-gpu-utilization";
import { cn } from "@/lib/utils";
import {
ChartAverageIcon,
@ -42,7 +43,7 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Link, useNavigate } from "@tanstack/react-router";
import { type ReactElement, type ReactNode, useState } from "react";
import { type ReactElement, type ReactNode, useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { ChartSettingsSheet } from "./charts/chart-settings-sheet";
import {
@ -123,18 +124,17 @@ export function ProgressSection({
const [stopDialogOpen, setStopDialogOpen] = useState(false);
const [stopRequestedLocal, setStopRequestedLocal] = useState(false);
// Auto-resets when training stops; no useEffect needed
const stopRequested = data.isTrainingRunning && stopRequestedLocal;
const pct =
data.totalSteps > 0
? Math.min(
100,
Math.max(
0,
Math.round((data.currentStep / data.totalSteps) * 100),
),
)
100,
Math.max(
0,
Math.round((data.currentStep / data.totalSteps) * 100),
),
)
: Math.round(data.progressPercent);
const elapsed = data.elapsedSeconds;
@ -214,16 +214,16 @@ export function ProgressSection({
},
...(data.trainingMethod !== "full"
? [
{
section: "LoRA",
rows: [
configRow(t("studio.progress.rank"), cfgLoraRank),
configRow(t("studio.progress.alpha"), cfgLoraAlpha),
configRow(t("studio.progress.dropout"), cfgLoraDropout),
configRow(t("studio.progress.variant"), cfgLoraVariant),
],
},
]
{
section: "LoRA",
rows: [
configRow(t("studio.progress.rank"), cfgLoraRank),
configRow(t("studio.progress.alpha"), cfgLoraAlpha),
configRow(t("studio.progress.dropout"), cfgLoraDropout),
configRow(t("studio.progress.variant"), cfgLoraVariant),
],
},
]
: []),
];
@ -350,8 +350,8 @@ export function ProgressSection({
{stepsPerSecond == null
? t("studio.progress.noStepsPerSecond")
: t("studio.progress.stepsPerSecond", {
value: stepsPerSecond.toFixed(2),
})}
value: stepsPerSecond.toFixed(2),
})}
</span>
{data.currentNumTokens != null && (
<span>{t("studio.progress.tokens", { value: data.currentNumTokens })}</span>
@ -373,14 +373,50 @@ function LiveGpuPanel({
isTrainingRunning: boolean;
}): ReactElement {
const t = useT();
const gpu = useGpuUtilization(isTrainingRunning);
const [selectedGpu, setSelectedGpu] = useState(0);
const gpuData = useGpuUtilization(isTrainingRunning);
const gpus: GpuUtilization[] =
Array.isArray(gpuData?.devices) && gpuData.devices.length > 0
? gpuData.devices
: gpuData && Object.keys(gpuData).length > 0
? [gpuData]
: [];
useEffect(() => {
if (selectedGpu > 0 && selectedGpu >= gpus.length) {
setSelectedGpu(0);
}
}, [gpus.length, selectedGpu]);
const gpuCount = gpus.length;
const currentGpu: Partial<GpuUtilization> = gpus[selectedGpu] || gpus[0] || {};
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{t("studio.progress.gpuMonitor")}
</p>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<p className="text-xs font-medium text-muted-foreground">
{t("studio.progress.gpuMonitor")}
</p>
{gpuCount > 1 && (
<select
value={selectedGpu}
onChange={(e) => setSelectedGpu(Number(e.target.value))}
className="h-6 cursor-pointer rounded-md border border-border bg-popover px-1.5 py-0.5 text-[11px] text-popover-foreground outline-none hover:bg-muted focus:border-primary transition-colors font-medium appearance-none"
title="Select GPU"
>
{gpus.map((device, index) => (
<option
key={device.index ?? index}
value={index}
className="bg-popover text-popover-foreground dark:bg-zinc-900 dark:text-zinc-100"
>
GPU {device.visible_ordinal ?? index} - {device.backend} ({device.vram_total_gb ? `${Math.round(device.vram_total_gb)}GB` : "N/A"})
</option>
))}
</select>
)}
</div>
<span className="text-[11px] text-muted-foreground">
{t("studio.progress.live")}
</span>
@ -388,51 +424,44 @@ function LiveGpuPanel({
<div className="grid grid-cols-2 gap-2.5">
<GpuStat
label={t("studio.progress.utilization")}
icon={
<HugeiconsIcon
icon={DashboardSpeed01Icon}
className="size-3.5"
/>
}
icon={<HugeiconsIcon icon={DashboardSpeed01Icon} className="size-3.5" />}
value={
gpu.gpu_utilization_pct != null
? `${gpu.gpu_utilization_pct}%`
currentGpu.gpu_utilization_pct != null
? `${currentGpu.gpu_utilization_pct}%`
: "--"
}
pct={gpu.gpu_utilization_pct ?? 0}
pct={currentGpu.gpu_utilization_pct ?? 0}
/>
<GpuStat
label={t("studio.progress.temperature")}
icon={
<HugeiconsIcon icon={TemperatureIcon} className="size-3.5" />
}
icon={<HugeiconsIcon icon={TemperatureIcon} className="size-3.5" />}
value={
gpu.temperature_c != null ? `${gpu.temperature_c}°C` : "--"
currentGpu.temperature_c != null ? `${currentGpu.temperature_c}°C` : "--"
}
pct={gpu.temperature_c ?? 0}
pct={currentGpu.temperature_c ?? 0}
max={100}
/>
<GpuStat
label={t("studio.progress.vram")}
icon={<HugeiconsIcon icon={RamMemoryIcon} className="size-3.5" />}
value={
gpu.vram_used_gb != null && gpu.vram_total_gb != null
? `${gpu.vram_used_gb} / ${gpu.vram_total_gb} GB`
currentGpu.vram_used_gb != null && currentGpu.vram_total_gb != null
? `${currentGpu.vram_used_gb} / ${currentGpu.vram_total_gb} GB`
: "--"
}
pct={gpu.vram_utilization_pct ?? 0}
pct={currentGpu.vram_utilization_pct ?? 0}
/>
<GpuStat
label={t("studio.progress.power")}
icon={<HugeiconsIcon icon={ZapIcon} className="size-3.5" />}
value={
gpu.power_draw_w != null
? gpu.power_limit_w != null
? `${gpu.power_draw_w} / ${gpu.power_limit_w} W`
: `${gpu.power_draw_w} W`
currentGpu.power_draw_w != null
? currentGpu.power_limit_w != null
? `${currentGpu.power_draw_w} / ${currentGpu.power_limit_w} W`
: `${currentGpu.power_draw_w} W`
: "--"
}
pct={gpu.power_utilization_pct ?? 0}
pct={currentGpu.power_utilization_pct ?? 0}
/>
</div>
</div>
@ -560,7 +589,10 @@ function TrainingHeaderActions({
<HugeiconsIcon icon={StopIcon} className="size-3" />
{stopRequested ? t("studio.training.stopping") : t("studio.training.stopAction")}
</Button>
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
<AlertDialogContent
className="w-max max-w-[95vw]"
overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]"
>
<AlertDialogHeader>
<AlertDialogTitle>{t("studio.training.stopTitle")}</AlertDialogTitle>
<AlertDialogDescription>

View file

@ -1,6 +1,7 @@
// 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 { useDebouncedValue } from "./use-debounced-value";
export { useGpuInfo } from "./use-gpu-info";
export { useGpuUtilization } from "./use-gpu-utilization";
@ -9,3 +10,4 @@ export { useHfDatasetSplits } from "./use-hf-dataset-splits";
export { useHfTokenValidation } from "./use-hf-token-validation";
export { useTauriBackend } from "./use-tauri-backend";
export { useCollapseScrollLock } from "./use-collapse-scroll-lock";
export { useSystemInfo } from "./use-system";

View file

@ -3,19 +3,26 @@
import { authFetch } from "@/features/auth";
import { useEffect, useState } from "react";
import type { SystemInfoResponse } from "./use-system";
export interface GpuInfo {
available: boolean;
name: string;
memoryTotalGb: number;
cpuCore: number;
cpuThread: number;
systemRamAvailableGb: number;
systemRamTotalGb: number
}
const DEFAULT_GPU: GpuInfo = {
available: false,
name: "Unknown",
memoryTotalGb: 0,
cpuCore: 0,
cpuThread: 0,
systemRamAvailableGb: 0,
systemRamTotalGb: 0
};
// Module-level cache so multiple components share one fetch.
@ -30,24 +37,30 @@ async function fetchGpuOnce(): Promise<GpuInfo> {
try {
const res = await authFetch("/api/system");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const ramAvailableGb = data?.memory?.available_gb ?? 0;
const data = await res.json() as SystemInfoResponse;
const gpuData = data?.gpu;
if (!gpuData?.available || !gpuData.devices?.length) {
// No discrete GPU (e.g. Mac): still surface system RAM so memory math
// (unified memory) has a budget to work with.
const info: GpuInfo = { ...DEFAULT_GPU, systemRamAvailableGb: ramAvailableGb };
cachedGpu = info;
return info;
}
const devices = gpuData.devices as Array<{ name?: string; memory_total_gb?: number }>;
const totalGb = devices.reduce((sum, d) => sum + (d.memory_total_gb ?? 0), 0);
const info: GpuInfo = {
available: true,
name: devices[0]?.name ?? "Unknown",
memoryTotalGb: totalGb,
systemRamAvailableGb: ramAvailableGb,
// CPU/RAM exist even on hosts without a GPU, so populate them on every path.
// No discrete GPU (e.g. Mac): still surface system RAM so memory math
// (unified memory) has a budget to work with.
const base = {
cpuCore: data?.cpu?.physical_count ?? 0,
cpuThread: data?.cpu?.logical_count ?? 0,
systemRamAvailableGb: data?.memory?.available_gb ?? 0,
systemRamTotalGb: data?.memory?.total_gb ?? 0,
};
const devices = gpuData?.devices ?? [];
const info: GpuInfo =
gpuData?.available && devices.length
? {
...base,
available: true,
name: devices[0]?.name ?? "Unknown",
memoryTotalGb: devices.reduce((sum, d) => sum + (d.memory_total_gb ?? 0), 0),
}
: { ...DEFAULT_GPU, ...base };
cachedGpu = info;
return info;
} catch {
@ -78,4 +91,4 @@ export function useGpuInfo(): GpuInfo {
}, []);
return gpu;
}
}

View file

@ -7,6 +7,9 @@ import { useEffect, useRef, useState } from "react";
export interface GpuUtilization {
available: boolean;
backend: string | null;
devices?: GpuUtilization[];
index?: number;
visible_ordinal?: number;
gpu_utilization_pct: number | null;
temperature_c: number | null;
vram_used_gb: number | null;
@ -57,11 +60,10 @@ export function useGpuUtilization(
const json = (await res.json()) as GpuUtilization;
if (!cancelled) setData(json);
} catch {
// Silently ignore — next poll will retry
// Retry on the next poll.
}
}
// Fetch immediately, then set up interval
void poll();
timerRef.current = setInterval(() => void poll(), intervalMs);

View file

@ -25,6 +25,13 @@ export interface HardwareInfo {
transformers: string | null;
unsloth: string | null;
llamaCpp: string | null;
// Whether export can run here (true only on a supported accelerator), with a torch-aware
// reason. `null` until the authoritative response lands, so callers don't briefly enable
// export; `loaded` flips true once a real (non-error) response arrives.
exportSupported: boolean | null;
exportUnsupportedReason: string | null;
exportUnsupportedMessage: string | null;
loaded: boolean;
}
const DEFAULT: HardwareInfo = {
@ -38,6 +45,10 @@ const DEFAULT: HardwareInfo = {
transformers: null,
unsloth: null,
llamaCpp: null,
exportSupported: null,
exportUnsupportedReason: null,
exportUnsupportedMessage: null,
loaded: false,
};
// Module-level cache so multiple components share one fetch.
@ -87,6 +98,10 @@ async function fetchOnce(): Promise<HardwareInfo> {
transformers: data?.versions?.transformers ?? null,
unsloth: data?.versions?.unsloth ?? null,
llamaCpp: data?.llama_cpp ?? null,
exportSupported: data?.export_supported ?? null,
exportUnsupportedReason: data?.export_unsupported_reason ?? null,
exportUnsupportedMessage: data?.export_unsupported_message ?? null,
loaded: true,
};
if (generation === cacheGeneration) {
cached = info;

View file

@ -0,0 +1,130 @@
// 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 { authFetch } from "@/features/auth";
import { useEffect, useState } from "react";
export interface GpuDevice {
index?: number;
index_kind?: string;
visible_ordinal?: number;
name?: string;
memory_total_gb?: number;
vram_used_gb?: number;
vram_free_gb?: number;
vram_utilization_pct?: number | null;
}
export interface SystemInfoResponse {
platform: string;
python_version: string;
device_backend: "cuda" | "rocm" | "cpu" | "mlx" | "xpu";
uptime_seconds: number | null;
cpu: {
logical_count: number;
physical_count: number;
usage_percent: number;
frequency_mhz: number | null;
};
memory: {
total_gb: number;
available_gb: number;
percent_used: number;
process_used_mb: number;
};
disk: {
total_gb: number;
free_gb: number;
percent_used: number;
};
gpu: {
available: boolean;
backend?: string;
backend_cuda_visible_devices?: string | null;
parent_visible_gpu_ids?: number[];
index_kind?: string;
devices: GpuDevice[];
};
ml_packages: {
torch?: string;
transformers?: string;
};
}
let cachedSystem: SystemInfoResponse | null = null;
let systemFetchPromise: Promise<SystemInfoResponse> | null = null;
const DEFAULT_SYSTEM: SystemInfoResponse = {
platform: "Unknown",
python_version: "Unknown",
device_backend: "cpu",
uptime_seconds: 0,
cpu: { logical_count: 0, physical_count: 0, usage_percent: 0, frequency_mhz: null },
memory: { total_gb: 0, available_gb: 0, percent_used: 0, process_used_mb: 0 },
disk: { total_gb: 0, free_gb: 0, percent_used: 0 },
gpu: { available: false, devices: [] },
ml_packages: {}
};
async function fetchSystemOnce({
force = false,
}: { force?: boolean } = {}): Promise<SystemInfoResponse> {
if (systemFetchPromise) return systemFetchPromise;
if (!force && cachedSystem) return cachedSystem;
systemFetchPromise = (async () => {
try {
const res = await authFetch("/api/system");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
cachedSystem = data as SystemInfoResponse;
return cachedSystem;
} catch {
cachedSystem = null;
return DEFAULT_SYSTEM;
} finally {
systemFetchPromise = null;
}
})();
return systemFetchPromise;
}
interface UseSystemInfoOptions {
pollMs?: number;
enabled?: boolean;
}
export function useSystemInfo({
pollMs,
enabled = true,
}: UseSystemInfoOptions = {}): SystemInfoResponse {
const [systemInfo, setSystemInfo] = useState<SystemInfoResponse>(cachedSystem ?? DEFAULT_SYSTEM);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let timeoutId: number | null = null;
const update = (force: boolean) => {
void fetchSystemOnce({ force })
.then((info) => {
if (!cancelled) setSystemInfo(info);
})
.finally(() => {
if (cancelled || !pollMs) return;
timeoutId = window.setTimeout(() => update(true), pollMs);
});
};
update(Boolean(pollMs));
return () => {
cancelled = true;
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
}, [enabled, pollMs]);
return systemInfo;
}

View file

@ -2,10 +2,11 @@
- `locales/en.ts` is the complete baseline message file.
- Non-English locale files may be partial. Missing keys must fall back to English at runtime.
- Use BCP 47 locale tags for new languages, for example `zh-CN`, `ja-JP`, and `ko-KR`.
- Use BCP 47 locale tags for new languages, for example `zh-CN`, `pt-BR`, `ja-JP`, and `ko-KR`.
- Do not change fallback logic to hide missing translations.
- Do not add automatic DOM translation, MutationObserver text replacement, or runtime guess-based translation.
- Preserve interpolation variables exactly, for example `{count}`, `{model}`, and `{provider}`.
- Keep product and technical names unchanged unless there is an established localized name, for example `Unsloth Studio`, `LoRA`, `GGUF`, and `Hugging Face`.
- Keep translation changes small and reviewable. Prefer separate commits for runtime changes, UI migration, and locale text.
- When adding user-facing Studio UI text, add the English message key first and add non-English overrides only when the translation is clear.
- Run `npx tsx src/i18n/check-parity.ts` before committing to ensure there are no shape mismatches or placeholder discrepancies in the non-English overlays.

View file

@ -3,13 +3,14 @@
// Parity check between en.ts and every non-English locale.
// - Locale files may be partial; missing keys must fall back to English.
// - All zh-CN keys must exist in en (no extras).
// - All non-English keys must exist in en (no extras).
// - Placeholder set must match per leaf between en and the overlay.
//
// Run: npx tsx src/i18n/check-parity.ts
import { en } from "./locales/en.ts";
import { zhCN } from "./locales/zh-CN.ts";
import { ptBR } from "./locales/pt-br.ts";
import { ja } from "./locales/ja.ts";
type Tree = { readonly [k: string]: string | Tree };
@ -90,6 +91,7 @@ function checkExtras(
const overlays: Record<string, Tree> = {
"zh-CN": zhCN as unknown as Tree,
"pt-BR": ptBR as unknown as Tree,
"ja": ja as unknown as Tree,
};
let anyError = false;
@ -112,4 +114,4 @@ for (const [locale, overlay] of Object.entries(overlays)) {
}
if (anyError) process.exit(1);
console.log("\nAll locale overlays pass parity.");
console.log("\nAll locale overlays pass parity.");

View file

@ -93,6 +93,7 @@ export const en = {
general: "General",
profile: "Profile",
appearance: "Appearance",
resources: "System",
chat: "Chat",
connections: "Connections",
apiKeys: "API",
@ -194,6 +195,20 @@ export const en = {
maxUploadSize: "Training dataset upload cap",
maxUploadSizeDescription: "Default is {defaultSize} MB.",
},
rag: {
sectionTitle: "Documents & RAG",
embeddingModel: "Embedding model",
embeddingModelDescription:
"Hugging Face model or local path used to index and search your documents. Default is {defaultModel}.",
reindexWarning:
"Only affects newly indexed documents. Re-upload existing ones after changing the model.",
emptyError: "Enter a Hugging Face model id or local path.",
loadError: "Failed to load the embedding model setting.",
saveError: "Failed to save the embedding model.",
saved: "Embedding model saved.",
saveAnyway: "Save anyway",
resetAction: "Reset to default",
},
storage: {
sectionTitle: "Storage",
modelsFolder: "Models folder",
@ -262,6 +277,58 @@ export const en = {
"Keep the sidebar expanded instead of collapsing to icons.",
},
},
resources: {
title: "System",
description: "Monitor this Studio server's hardware and storage.",
liveUpdates: "Live updates",
floatingWindow: "Floating window",
disableOverlay: "Disable overlay",
liveMonitor: {
title: "Live monitor",
cpu: "CPU",
ram: "RAM",
disk: "Disk",
vram: "VRAM",
cpuCores: "{logical} logical / {physical} physical cores",
currentLoad: "Current load",
free: "{value} free",
noGpu: "No visible GPU",
},
gpu: {
title: "GPU devices",
noGpu: "No visible GPU detected. CPU-only resources are shown above.",
unknownDevice: "Unknown GPU",
deviceWithIndex: "GPU {index}",
vramUtilization: "VRAM",
used: "{value} used",
free: "{value} free",
total: "{value} total",
},
storage: {
title: "Storage",
systemDisk: "System disk",
diskUsage: "{used} used / {total}",
diskFree: "{free} free",
modelsFolder: "Models folder",
modelsFolderDescription: "Where downloaded models are stored.",
openAction: "Open",
copyAction: "Copy path",
copied: "Path copied",
openError: "Couldn't open the folder",
copyError: "Couldn't copy the path",
},
environment: {
title: "Environment",
backend: "Backend",
python: "Python",
torch: "Torch",
transformers: "Transformers",
uptime: "Uptime",
processMemory: "Process memory",
notInstalled: "Not installed",
unknown: "Unknown",
},
},
chat: {
title: "Chat",
description: "Manage chat history stored on this device.",
@ -360,8 +427,10 @@ export const en = {
usageTools: "Tools",
exampleCurlTools: "curl + tools",
examplePythonTools: "Python + tools",
exampleJavaScriptTools: "JavaScript + tools",
exampleCurlAdvanced: "curl + advanced",
examplePythonAdvanced: "Python + advanced",
exampleJavaScriptAdvanced: "JavaScript + advanced",
osUnix: "Linux / macOS / WSL",
osWindows: "Windows",
secureHttps: "Secure HTTPS",
@ -372,6 +441,10 @@ export const en = {
copy: "Copy",
copied: "Copied",
setupDocs: "Setup docs:",
codingAgents: "Coding agents",
codingAgentsHint:
"Launch a coding agent against this server. It uses the loaded model; a local server mints an API key automatically, a remote one includes it in the command.",
codingAgentsSwap: "Swap claude for codex, openclaw, opencode, hermes, or pi.",
relativeNever: "never",
relativeJustNow: "just now",
relativeHoursAgo: "{count}h ago",

View file

@ -0,0 +1,934 @@
// 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 const ptBR = {
common: {
cancel: "Cancelar",
close: "Fechar",
delete: "Excluir",
done: "Concluído",
error: "Erro",
export: "Exportar",
help: "Ajuda",
loading: "Carregando...",
new: "Novo",
rename: "Renomear",
save: "Salvar",
saving: "Salvando...",
search: "Buscar",
shutdown: "Desligar",
},
shell: {
beta: "BETA",
brand: "unsloth",
product: "Unsloth Studio",
accountMenu: "Menu de conta {name}",
updateAvailable: "Atualização disponível",
aria: {
home: "Início do Unsloth",
closeSidebar: "Fechar barra lateral",
openSidebar: "Abrir barra lateral",
chatOptions: "Opções de chat",
runOptions: "Opções de execução",
},
navigation: {
newChat: "Novo Chat",
returnToChat: "Retornar ao Chat",
compare: "Comparar",
search: "Buscar",
hub: "Hub",
train: "Treinar",
recipes: "Receitas",
export: "Exportar",
recents: "Recentes",
settings: "Configurações",
api: "API",
lightMode: "Modo Claro",
darkMode: "Modo Escuro",
guidedTour: "Tour Guiado",
help: "Ajuda",
logOut: "Sair",
shutdown: "Desligar",
},
notFound: {
title: "Página não encontrada",
description: "{path} não existe.",
backToChat: "Voltar para o chat",
},
dialog: {
deleteChat: {
title: "Excluir chat",
description: 'Tem certeza de que deseja excluir este chat "{name}"?',
},
deleteRun: {
title: "Excluir execução de treino",
description: 'Tem certeza de que deseja excluir esta execução "{name}"?',
},
renameChat: {
title: "Renomear chat",
placeholder: "Título do chat",
},
renameRun: {
title: "Renomear execução",
placeholder: "Nome da execução",
},
},
toast: {
cannotDeleteRunningRun: "Não é possível excluir uma execução de treino em andamento",
failedToDeleteChat: "Falha ao excluir o chat",
failedToDeleteRun: "Falha ao excluir a execução",
failedToRenameChat: "Falha ao renomear o chat",
failedToRenameRun: "Falha ao renomear a execução",
},
},
settings: {
title: "Configurações",
dialog: {
title: "Configurações",
description: "Gerencie suas preferências do Unsloth.",
closeAriaLabel: "Fechar configurações",
},
tabs: {
general: "Geral",
profile: "Perfil",
appearance: "Aparência",
resources: "Sistema",
chat: "Chat",
connections: "Conexões",
apiKeys: "API",
about: "Sobre",
},
general: {
title: "Geral",
description: "Preferências globais do Unsloth.",
account: "Conta",
huggingFaceToken: "Token do Hugging Face",
huggingFaceTokenDescription:
"Usado para carregar modelos restritos e enviar artefatos.",
tokenSaved: "Token salvo",
hideToken: "Ocultar token",
showToken: "Mostrar token",
password: "Senha",
passwordDescription: "Altere a senha desta conta do Studio.",
passwordDialog: {
trigger: "Alterar senha",
title: "Alterar senha",
description:
"Insira sua senha atual e escolha uma nova (no mínimo {minLength} caracteres).",
currentPassword: "Senha atual",
newPassword: "Nova senha",
confirmPassword: "Confirmar nova senha",
currentTooShort:
"A senha atual deve ter no mínimo {minLength} caracteres.",
newTooShort: "A nova senha deve ter no mínimo {minLength} caracteres.",
mismatch: "As senhas não coincidem.",
samePassword:
"A nova senha deve ser diferente da senha atual.",
update: "Atualizar senha",
updating: "Atualizando...",
updated: "Senha atualizada.",
updateFailed: "Falha ao atualizar a senha.",
},
chatDefaults: "Padrões do chat",
autoTitleNewChats: "Gerar título automático para novos chats",
autoTitleNewChatsDescription:
"Gera um título curto a partir da primeira mensagem.",
helperLlm: {
sectionTitle: "LLM Auxiliar",
preloadOnStartup: "Pré-carregar LLM Auxiliar na inicialização",
preloadOnStartupDescription:
"Baixa o modelo auxiliar do Assistente de IA em segundo plano ao iniciar. Desativado por padrão; o Assistente de IA ainda pode buscá-lo sob demanda.",
disabledByEnv:
"Desativado por UNSLOTH_HELPER_MODEL_DISABLE no ambiente de backend.",
loadError: "Falha ao carregar as configurações do LLM Auxiliar.",
saveError: "Falha ao salvar as configurações do LLM Auxiliar.",
},
notifications: {
sectionTitle: "Notificações",
showLlamaUpdates: "Notificações de atualização do llama.cpp",
showLlamaUpdatesDescription:
"Notifica quando uma nova versão do llama.cpp estiver disponível. Desative se você apenas realiza treinos.",
},
gettingStarted: "Primeiros passos",
startOnboarding: "Iniciar integração",
startOnboardingDescription:
"Reabre o assistente de configuração sem alterar sua conta.",
startOnboardingAction: "Iniciar integração",
uploads: {
sectionTitle: "Uploads",
maxUploadSize: "Limite de upload do dataset de treino",
maxUploadSizeDescription:
"O padrão é {defaultSize} MB.",
},
storage: {
sectionTitle: "Armazenamento",
modelsFolder: "Pasta de modelos",
modelsFolderDescription:
"Onde os modelos baixados são armazenados.",
openAction: "Abrir",
copyAction: "Copiar caminho",
copied: "Caminho copiado",
openError: "Não foi possível abrir a pasta",
copyError: "Não foi possível copiar o caminho",
},
resetPreferences: {
sectionTitle: "Zona de perigo",
label: "Redefinir todas as preferências locais",
description:
"Limpa apenas as preferências locais. Chats, acesso à API e configurações salvas no banco de dados são mantidos.",
action: "Redefinir preferências",
confirmTitle: "Redefinir todas as preferências locais?",
confirmDescription:
"Limpa as preferências locais e recarrega o Unsloth. Chats, acesso à API e configurações salvas no banco de dados são mantidos.",
confirmAction: "Redefinir e recarregar",
},
},
profile: {
title: "Perfil",
description: "Como seu perfil aparece no Unsloth.",
changePicture: "Alterar foto de perfil",
displayName: "Nome de exibição",
nickname: "Como o Unsloth deve chamar você?",
nicknamePlaceholder: "Apelido",
nicknameSaved: "Nome preferido salvo",
avatarShape: "Formato da foto de perfil",
avatarShapeCircle: "Círculo",
avatarShapeRounded: "Arredondado",
chooseSloth: "Ou escolha uma preguiça",
nameSaved: "Nome de perfil salvo",
namePersistErrorTitle: "Não foi possível persistir o nome de perfil",
namePersistErrorDescription:
"Nome atualizado para esta sessão, mas pode não persistir após recarregar.",
photoUpdated: "Foto de perfil atualizada",
photoPersistErrorTitle: "Não foi possível persistir a foto de perfil",
photoPersistErrorDescription:
"Foto atualizada para esta sessão, mas pode não persistir após recarregar.",
photoUpdateErrorTitle: "Não foi possível atualizar a foto de perfil",
imageUseError: "Não foi possível usar esta imagem.",
},
appearance: {
title: "Aparência",
description: "Como o Unsloth Studio se parece neste dispositivo.",
theme: {
title: "Tema",
label: "Esquema de cores",
description: "Claro, escuro ou seguir o sistema.",
system: "Sistema",
light: "Claro",
dark: "Escuro",
},
language: {
title: "Idioma",
label: "Idioma de exibição",
description: "O idioma utilizado pelo Unsloth.",
},
layout: {
title: "Layout",
compactSidebar: "Fixar barra lateral por padrão",
compactSidebarDescription:
"Mantém a barra lateral expandida em vez de recolhê-la em ícones.",
},
},
resources: {
title: "Sistema",
description: "Monitore o hardware e o armazenamento deste servidor Studio.",
liveUpdates: "Atualizações ao vivo",
floatingWindow: "Janela flutuante",
disableOverlay: "Desativar sobreposição",
liveMonitor: {
title: "Monitor ao vivo",
cpu: "CPU",
ram: "RAM",
disk: "Disco",
vram: "VRAM",
cpuCores: "{logical} lógicos / {physical} físicos",
currentLoad: "Carga atual",
free: "{value} livres",
noGpu: "Nenhuma GPU visível",
},
gpu: {
title: "Dispositivos GPU",
noGpu: "Nenhuma GPU visível detectada. Os recursos somente CPU aparecem acima.",
unknownDevice: "GPU desconhecida",
deviceWithIndex: "GPU {index}",
vramUtilization: "VRAM",
used: "{value} usados",
free: "{value} livres",
total: "{value} total",
},
storage: {
title: "Armazenamento",
systemDisk: "Disco do sistema",
diskUsage: "{used} usados / {total}",
diskFree: "{free} livres",
modelsFolder: "Pasta de modelos",
modelsFolderDescription: "Onde os modelos baixados são armazenados.",
openAction: "Abrir",
copyAction: "Copiar caminho",
copied: "Caminho copiado",
openError: "Não foi possível abrir a pasta",
copyError: "Não foi possível copiar o caminho",
},
environment: {
title: "Ambiente",
backend: "Backend",
python: "Python",
torch: "Torch",
transformers: "Transformers",
uptime: "Tempo ativo",
processMemory: "Memória do processo",
notInstalled: "Não instalado",
unknown: "Desconhecido",
},
},
chat: {
title: "Chat",
description: "Gerencie o histórico de chat armazenado neste dispositivo.",
modelDisclaimer: "Mostrar aviso do modelo",
modelDisclaimerDescription:
'Mostra "LLMs podem cometer erros" abaixo da caixa de chat.',
artifacts: {
title: "Canvas",
collapseHtmlBlocks: "Recolher blocos HTML",
collapseHtmlBlocksDescription:
"O modo Canvas recolhe o HTML completo automaticamente. Ative isso para também recolher documentos HTML delimitados quando o Canvas estiver desativado.",
allowNetworkAccess: "Permitir acesso à rede no canvas",
allowNetworkAccessDescription:
"Permite que as pré-visualizações do canvas carreguem scripts, estilos, fontes, mídia e recursos de rede de CDNs. Mantenha desativado para pré-visualizações totalmente offline.",
},
data: "Dados",
exportHistory: "Exportar histórico de chat",
exportHistoryDescription:
"Baixe todos os chats e mensagens em formato JSON.",
exportAction: "Exportar",
exportingAction: "Exportando...",
exportConversations: "Exportar Recentes e Projetos",
exportConversationsDescription:
"Baixe os Recentes ou Recentes mais chats de projetos como JSONL bruto, CSV ou ShareGPT JSONL, combinados ou por chat.",
exportConversationsAction: "Exportar",
exportScopeRecents: "Recentes",
exportScopeAll: "Recentes + Projetos",
exportCombinedSuffix: "(combinado)",
exportPerChatSuffix: "(por chat)",
importChats: "Importar chats",
importChatsDescription:
"Importe um arquivo exportado em JSONL, NDJSON ou CSV para os Recentes.",
importChatsAction: "Importar",
importNoConversations: "Nenhuma conversa encontrada no arquivo.",
importedOneChat: "Importada 1 conversa para os Recentes.",
importedChatCount: "Importadas {count} conversas para os Recentes.",
importFailed: "Falha na importação.",
clearHistory: "Limpar histórico de chat",
clearHistoryDescription: "Exclui o histórico de chat deste dispositivo.",
clearAction: "Limpar",
clearAllChats: "Limpar todos os chats",
clearAllChatsDescription: "Exclui permanentemente todos os chats deste dispositivo.",
noChatsToClear: "Nenhum chat para limpar.",
clearOneChatDescription:
"Exclui permanentemente o único chat deste dispositivo.",
clearChatCountDescription:
"Exclui permanentemente todos os {count} chats deste dispositivo.",
clearChatsAction: "Limpar chats",
clearOneChatTitle: "Limpar 1 chat?",
clearChatsTitle: "Limpar {count} chats?",
clearChatsConfirmDescription:
"Exclui permanentemente todos os chats deste dispositivo. Esta ação não pode ser desfeita.",
clearingAction: "Limpando...",
clearOneChatAction: "Limpar 1 chat",
clearChatCountAction: "Limpar {count} chats",
clearedAllChats: "Todos os chats foram limpos",
clearedOneChat: "1 chat foi limpo",
clearedChatCount: "{count} chats foram limpos",
someChatsCouldNotBeCleared: "Não foi possível limpar alguns chats",
chatsClearedRemainOne:
"{clearedCount} chats limpos; 1 chat restante. Por favor, tente novamente.",
chatsClearedRemain:
"{clearedCount} chats limpos; {remainingCount} chats restantes. Por favor, tente novamente.",
oneChatClearedRemain:
"1 chat limpo; {remainingCount} chats restantes. Por favor, tente novamente.",
oneChatClearedRemainOne: "1 chat limpo; 1 chat restante. Por favor, tente novamente.",
storageClearFailedOne:
"Falha ao limpar o armazenamento; 1 chat pode ter restado. Por favor, tente novamente.",
storageClearFailed:
"Falha ao limpar o armazenamento; {count} chats podem ter restado. Por favor, tente novamente.",
failedToClearChats: "Falha ao limpar os chats",
},
connections: {
title: "Conexões",
description: "Gerencie provedores e conexões externas.",
},
apiKeys: {
title: "API",
description:
"Acesse o Unsloth por meio da API compatível com OpenAI.",
readDocs: "Leia a documentação da API",
noAccess: "Nenhum acesso à API ainda.",
newBadge: "Novo",
accessTokens: "Tokens de acesso",
loadError: "Não foi possível carregar o acesso à API.",
createError: "Não foi possível criar o token de acesso.",
revokeError: "Não foi possível revogar o token de acesso.",
never: "Nunca",
tokenNamePlaceholder: "Nome do token (ex: producao)",
newAccessTokenName: "Nome do novo token de acesso",
createToken: "Criar token",
creating: "Criando...",
newTokenCreated: "Novo token de acesso criado",
accessTokenCopied: "Token de acesso copiado",
copyAccessToken: "Copiar token de acesso",
copyNow: "Copie agora - isto não será exibido novamente.",
usageExamples: "Exemplos de uso",
usageTools: "Ferramentas",
exampleCurlTools: "curl + ferramentas",
examplePythonTools: "Python + ferramentas",
exampleJavaScriptTools: "JavaScript + ferramentas",
exampleCurlAdvanced: "curl + avançado",
examplePythonAdvanced: "Python + avançado",
exampleJavaScriptAdvanced: "JavaScript + avançado",
osUnix: "Linux / macOS / WSL",
osWindows: "Windows",
secureHttps: "HTTPS Seguro",
secureHttpsHint:
"A porta 0.0.0.0 ainda está acessível globalmente. Para segurança total, inicie o Unsloth Studio com --secure para expor apenas este link HTTPS.",
copyTunnelUrl: "Copiar URL do túnel",
copySnippet: "Copiar trecho de código",
copy: "Copiar",
copied: "Copiado",
setupDocs: "Docs de configuração:",
relativeNever: "nunca",
relativeJustNow: "agora mesmo",
relativeHoursAgo: "há {count}h",
relativeDaysAgo: "há {count}d",
relativeMonthsAgo: "há {count} meses",
relativeYearsAgo: "há {count} anos",
expired: "expirado",
today: "hoje",
inDays: "em {count}d",
created: "Criado {value}",
used: "Usado {value}",
expires: "Expira {value}",
actionsFor: "Ações para {name}",
copyPrefix: "Copiar prefixo",
revokeToken: "Revogar token",
revokeTitle: 'Revogar token de acesso "{name}"?',
revokeDescription:
"Aplicativos que usam este token perderão o acesso imediatamente. Esta ação não pode ser desfeita.",
revokeAction: 'Revogar "{name}"',
revoking: "Revogando...",
},
about: {
title: "Sobre",
description:
"Documentação, notas de lançamento, feedback e informações da build.",
studioVersion: "Versão do Unsloth",
packageVersion: "Versão do Pacote",
llamaCppVersion: "Versão do llama.cpp",
hardware: "Hardware",
gpu: "GPU",
cuda: "CUDA",
rocm: "ROCm",
updates: "Atualização",
help: "Ajuda",
documentation: "Documentação",
releaseNotes: "Notas de lançamento",
whatsNew: "O que há de novo",
feedback: "Feedback",
reportIssue: "Reportar um problema",
license: {
sectionTitle: "Licença",
studioLabel: "Unsloth Studio",
studioLicense: "AGPL-3.0",
studioDescription:
"Código aberto sob a licença GNU AGPL v3.0.",
libraryLabel: "Unsloth Core",
libraryLicense: "Apache-2.0",
libraryDescription: "Licenciado sob Apache 2.0.",
},
dangerZone: "Zona de perigo",
shutDownStudio: "Desligar Unsloth Studio",
shutDownStudioDescription:
"Interrompe o servidor Unsloth e encerra sua sessão.",
shutDown: "Desligar",
update: {
title: "Atualizar Unsloth Studio",
commandText: "Texto de {label}",
copied: "Copiado",
copyCommand: "Copiar comando",
commandCopied: "{label} copiado",
copyNamedCommand: "Copiar {label}",
checkingInstall: "Verificando como o Unsloth foi instalado...",
installIntro: "Para instalar ou atualizar o Unsloth:",
localUpdateHeading: "Atualização local",
installCommandUnix: "Comando de instalação para macOS/Linux",
installCommandWindows: "Comando de instalação para Windows",
localInstallDetected:
"Instalação local detectada. Atualize a partir do seu repositório original para evitar substituí-lo pelo PyPI.",
pullThenUpdate: "Puxe as últimas alterações (git pull) e depois execute o instalador local:",
gitPullCommand: "comando git pull",
localInstallerCommand: "comando do instalador local",
sourceInstallDetected:
"Instalação do pacote por código-fonte ou VCS detectada. Reinstale a partir do caminho local original ou URL do Git.",
repoCheckoutFallback:
"Se você ainda tiver o repositório baixado, execute o instalador local a partir dele:",
restartAfterUpdate: "Reinicie o Unsloth após a atualização.",
desktopManaged:
"O aplicativo de desktop mantém seu backend integrado atualizado e avisará quando uma nova versão estiver disponível.",
unknownInstall:
"Não foi possível detectar como o Unsloth foi instalado. Para instalações via instalador ou PyPI, use os comandos acima.",
localCheckout:
"Para instalações de repositório local, execute o instalador local a partir desse diretório:",
docs: "Docs de instalação:",
docsInstall: "Instalação",
docsUpdating: "Atualização",
docsMac: "Mac",
docsWindows: "Windows",
},
},
},
studio: {
routeTitle: "Treinar",
title: "Estúdio de Fine-tuning",
subtitles: {
configure: "Configure e inicie o treinamento",
trainingInProgress: "Treinamento em andamento",
viewPastRuns: "Visualizar execuções de treino anteriores",
viewingPastRun: "Visualizando execução anterior",
},
tabs: {
configure: "Configurar",
currentRun: "Execução Atual",
history: "Histórico",
},
loadingRuntime: "Carregando ambiente de execução de treino...",
backToHistory: "Voltar ao histórico",
sections: {
model: "Modelo",
dataset: "Dataset",
params: "Parâmetros",
training: "Treinamento",
charts: "Gráficos",
progress: "Progresso do Treinamento",
},
configure: {
title: "Configurar",
description: "Escolha um modelo, dataset e configurações de treinamento.",
startTraining: "Iniciar Treinamento",
starting: "Iniciando...",
loadingModel: "Carregando modelo...",
checkingDataset: "Verificando dataset...",
trainingConfig: "Configuração de Treino",
},
model: {
title: "Modelo",
description: "Selecione o modelo base e o método de treinamento",
fasterTrainingBadge: "Treinamento 2x Mais Rápido",
baseModel: "Modelo base",
localModel: "Modelo Local",
localModelTooltip:
"Caminho para um modelo baixado localmente ou um repositório HF customizado.",
scanningLocalAndCachedModels: "Escaneando modelos locais e em cache...",
scanning: "Escaneando...",
scanningLocalModels: "Escaneando modelos locais...",
noLocalModelsFound: "Nenhum modelo local encontrado",
noLocalModelsFoundManual: "Nenhum modelo local encontrado. Insira o caminho manualmente.",
failedToLoadLocalModels: "Falha ao carregar modelos locais",
hfCache: "Cache do HF",
customFolders: "Pastas Customizadas",
localDir: "Diretório local",
huggingFaceModel: "Modelo do Hugging Face",
huggingFaceModelTooltip:
"Busque modelos no Hugging Face ou escolha da nossa lista recomendada.",
searchModels: "Buscar modelos...",
searching: "Buscando...",
noModelsFound: "Nenhum modelo encontrado",
needsVram: "Precisa de ~{vram}GB de VRAM (GPU: {gpu}GB)",
tightVram: "~{vram}GB de VRAM (limite na {gpu}GB)",
vramEstimate: "~{vram}GB de VRAM",
method: "Método",
methodTooltip:
"O QLoRA usa quantização de 4 bits para menor uso de VRAM. O LoRA usa 16 bits. O Full atualiza todos os pesos. O CPT (Continued Pretraining) treina em texto bruto para adaptar o modelo a um novo domínio sem formatação de chat.",
readMore: "Leia mais",
fullFineTune: "Fine-tune Completo (Full)",
checkingToken: "Verificando token...",
getOrUpdateToken: "Obter ou atualizar token",
huggingFaceTokenOptional: "Token do Hugging Face (Opcional)",
continuedPretraining: "Pré-treinamento Contínuo (CPT)",
localModels: "Modelos locais",
localModelsFound: "{count} modelos locais/em cache encontrados",
loadingLocalModels: "Carregando modelos locais...",
},
dataset: {
title: "Dataset",
description: "Selecione ou envie os dados de treinamento",
source: "Origem do dataset",
chooseDataset: "Escolher dataset",
chooseDatasetTooltip:
"Use as abas do pop-up para alternar entre o Hugging Face e as saídas de receitas locais.",
localTab: "Local",
searchHuggingFaceDatasets: "Buscar datasets no Hugging Face...",
searchLocalDatasets: "Buscar datasets locais...",
searching: "Buscando...",
noDatasetsFound: "Nenhum dataset encontrado",
loadingLocalDatasets: "Carregando datasets locais...",
failedToLoadLocalDatasets: "Falha ao carregar datasets locais.",
noLocalDatasetsYet: "Nenhum dataset local ainda.",
noLocalDatasetsMatchSearch: "Nenhum dataset local corresponde à busca.",
openDataRecipes: "Abrir Receitas de Dados",
browsingSource: "Navegando em {browsing}. A seleção atual permanece {current}.",
localDatasets: "Datasets locais",
localDataset: "Dataset local",
localDatasetRows: " / {count} linhas",
huggingFaceDataset: "Dataset do Hugging Face",
localDatasetMetadata: "Metadados do dataset local",
dataRecipeOutput: "Saída da Receita de Dados.",
rows: "Linhas",
columns: "Colunas",
batches: "Lotes",
updated: "Atualizado",
evalDataset: "Dataset de validação (Eval)",
uploading: "Enviando...",
upload: "Upload",
uploadEvalFile: "Enviar arquivo de validação",
evalDatasetDescription:
"Opcional. Se não for fornecido, uma pequena parte será dividida a partir dos dados de treinamento.",
advanced: "Avançado",
targetFormat: "Formato de Destino",
targetFormatTooltip:
"Formato dos seus dados de treinamento. A detecção automática funciona para a maioria dos datasets.",
auto: "Auto",
rawText: "Texto Bruto",
trainSplitStart: "Início da Divisão de Treino",
trainSplitStartTooltip:
"Treine apenas em um subconjunto da sua divisão de treino especificando um índice de linha inicial (inclusivo, baseado em 0). Deixe em branco para começar da primeira linha.",
trainSplitEnd: "Fim da Divisão de Treino",
trainSplitEndTooltip:
"Último índice de linha a ser incluído da divisão de treino (inclusivo, baseado em 0). Por exemplo, defina o Início como 0 e o Fim como 99 para treinar nas primeiras 100 linhas. Deixe em branco para usar todas as linhas restantes.",
endPlaceholder: "Fim",
clear: "Limpar",
dropFileOrClick: "Solte 1 arquivo aqui ou clique para fazer upload",
viewDataset: "Visualizar dataset",
uploadFailed: "Falha no envio",
unknownError: "Erro desconhecido",
unsupportedFileType: "Tipo de arquivo não suportado",
uploadOneFileType: "Envie um arquivo do tipo {types}.",
datasetUploaded: "Dataset enviado",
evalDatasetUploaded: "Dataset de validação enviado",
uploadOneFileAtATime: "Envie um arquivo por vez",
uploadSingleFileDescription:
"O upload do dataset de treinamento aceita apenas um único arquivo.",
checkingToken: "Verificando token...",
getOrUpdateToken: "Obter ou atualizar token",
preview: "Pré-visualizar dataset",
split: "Divisão (Split)",
subset: "Subconjunto (Subset)",
s3: {
title: "Configuração do S3",
description: "Carregue datasets em .parquet, .json, .jsonl ou .csv do Amazon S3",
bucket: "Nome do Bucket",
bucketPlaceholder: "meu-bucket-de-dados-de-treino",
region: "Região da AWS",
regionPlaceholder: "us-east-1",
prefix: "Prefixo do Caminho",
prefixPlaceholder: "datasets/whisper/",
prefixTooltip: "Caminho opcional dentro do bucket para os arquivos do seu dataset",
accessKeyId: "ID da Chave de Acesso",
accessKeyIdPlaceholder: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "Chave de Acesso Secreta",
secretAccessKeyPlaceholder: "Sua chave de acesso secreta da AWS",
useIamRole: "Usar Função IAM",
useIamRoleTooltip: "Usa credenciais de função IAM em vez de chaves de acesso (recomendado para EC2/SageMaker)",
testConnection: "Testar Conexão",
connectionSuccess: "Conectado com sucesso ao bucket S3",
connectionFailed: "Falha ao conectar ao bucket S3",
comingSoon: "Integração com S3 em breve",
comingSoonDescription: "O carregamento de datasets do S3 requer o boto3. Este recurso está em desenvolvimento.",
},
},
params: {
title: "Parâmetros",
description: "Configure os hiperparâmetros de treinamento",
loraSettings: "Configurações do LoRA",
trainingHyperparameters: "Hiperparâmetros de Treinamento",
maxSteps: "Passos Máximos (Max Steps)",
epochs: "Épocas (Epochs)",
useMaxSteps: "Usar Passos Máximos",
useEpochs: "Usar Épocas",
maxStepsTooltip: "Sobrescreve o total de passos do otimizador.",
epochsTooltip: "Número de passagens completas pelo dataset.",
epochsDescription: "Cada época é uma passagem completa pelo seu dataset.",
maxStepsDescription:
"Limita o treinamento a um número fixo de passos do otimizador.",
contextLength: "Comprimento do Contexto",
contextLengthTooltip: "Número máximo de tokens por amostra de treinamento.",
customContextLength: "Insira um valor personalizado",
contextLengthDescription: "Comprimento máximo de sequência para amostras de treino",
learningRate: "Taxa de Aprendizado (Learning Rate)",
learningRateTooltip:
"Tamanho do passo para atualizações de peso. Valores menores treinam mais lentamente, mas com mais estabilidade.",
learningRateDescription:
"Recomendado: 2e-4 para LoRA, 5e-5 para CPT, 2e-5 para fine-tune completo",
embeddingLearningRate: "Taxa de Aprendizado do Embedding",
embeddingLearningRateTooltip:
"Usado apenas quando o CPT está treinando embed_tokens. Os embeddings são mais fáceis de desestabilizar do que os pesos LoRA, por isso geralmente precisam de um LR menor. Deixe em branco para usar lr/10; a faixa típica de funcionamento é de 2x a 10x menor que o LR principal. Aumente apenas se a adaptação de vocabulário ou de tokens de domínio estiver muito lenta.",
embeddingLearningRateDescription:
"Deixe em branco para usar lr/10 (recomendado). A faixa típica é de 2x a 10x menor que a taxa de aprendizado principal.",
rank: "Rank",
rankTooltip:
"Dimensão das matrizes de baixo rank. Maior = mais capacidade.",
alpha: "Alpha",
alphaTooltip: "Fator de escala para atualizações LoRA. Geralmente o dobro do rank.",
dropout: "Dropout",
dropoutTooltip:
"Probabilidade de dropout para as camadas LoRA para reduzir o overfitting.",
visionLayers: "Camadas de visão",
languageLayers: "Camadas de linguagem",
attentionModules: "Módulos de atenção",
mlpModules: "Módulos MLP",
targetModules: "Módulos de Destino",
enableLora: "Ativar LoRA",
trainWithLora: "Treinar com LoRA",
stableRank: "Stable Rank",
memoryEfficient: "Eficiente em Memória",
optimization: "Otimização",
schedule: "Cronograma",
memory: "Memória",
optimizer: "Otimizador",
optimizerTooltip:
"Algoritmo de otimização. Variantes de 8 bits reduzem o uso de memória. Fused é recomendado para modelos de visão.",
lrScheduler: "Agendador de LR",
lrSchedulerTooltip:
"Como a taxa de aprendizado muda ao longo do treino. Linear decai de forma constante; cosine decai em curva.",
optimizerOptions: {
adamw8bit: "AdamW 8-bit",
pagedAdamw8bit: "Paged AdamW 8-bit",
adamwBnb8bit: "AdamW BNB 8-bit",
pagedAdamw32bit: "Paged AdamW 32-bit",
adamwTorch: "AdamW (PyTorch)",
adamwTorchFused: "AdamW (PyTorch Fused)",
},
lrSchedulerOptions: {
linear: "Linear",
cosine: "Cosine",
},
batchSize: "Tamanho do Lote (Batch Size)",
batchSizeTooltip: "Amostras processadas por passo. Maior consome mais VRAM.",
gradAccum: "Acúmulo de Gradiente",
gradAccumTooltip: "Simula tamanhos de lote maiores sem gastar VRAM extra.",
weightDecay: "Decaimento de Peso",
weightDecayTooltip: "Regularização L2 para evitar overfitting.",
warmupSteps: "Passos de Aquecimento (Warmup)",
warmupStepsTooltip:
"Aumenta gradualmente a LR no início do treino para garantir estabilidade.",
scheduleEpochsTooltip:
"Número de passagens completas pelo dataset. Defina 0 para rodar por passos máximos.",
saveSteps: "Passos para Salvar",
saveStepsTooltip: "Salva um checkpoint a cada N passos. 0 para desativar.",
evalSteps: "Passos de Validação",
evalStepsTooltip:
"Fração dos passos totais de treino entre as validações (0-1). Defina como 0 para desativar. Ex: 0.01 = valida a cada 1% dos passos.",
seed: "Seed",
seedTooltip: "Semente aleatória para reprodutibilidade.",
gradCheckpoint: "Grad Checkpoint",
gradCheckpointTooltip:
"Troca processamento por memória recalculando as ativações.",
none: "Nenhum",
standard: "Padrão",
enablePacking: "Ativar empacotamento (packing)",
assistantCompletionsOnly: "Apenas respostas do assistente",
readMore: "Leia mais",
},
training: {
title: "Treinamento",
description: "Monitore e controle o treinamento",
chartNoDataTitle: "Nenhum dado de treinamento ainda",
chartNoDataDescription: "Inicie o treinamento para ver o progresso da loss",
startTraining: "Iniciar Treinamento",
starting: "Iniciando...",
loadingModel: "Carregando modelo...",
checkingDataset: "Verificando dataset...",
configLabel: "Configuração de Treino",
upload: "Upload",
uploadConfigTooltip: "Carregar uma configuração YAML salva",
save: "Salvar",
saveConfigTooltip: "Baixar configuração atual como YAML",
reset: "Redefinir",
resetConfigTooltip: "Redefinir para os padrões do modelo",
configLoaded: "Configuração carregada",
failedToLoadConfig: "Falha ao carregar a configuração",
invalidYamlFile: "Arquivo YAML inválido",
failedToReadFile: "Falha ao ler o arquivo",
parametersReset: "Parâmetros redefinidos para os padrões do modelo",
audioIncompatible:
"Este modelo não suporta áudio. Mude para um modelo compatível com áudio ou escolha um dataset sem áudio.",
visionIncompatible:
"O modelo de texto não é compatível com um dataset multimodal. Mude para um modelo de visão ou escolha um dataset apenas de texto.",
cancelTitle: "Cancelar Treinamento",
cancelDescription: "Deseja cancelar a execução de treinamento atual?",
continueAction: "Continuar Treinamento",
cancelAction: "Cancelar Treinamento",
stopTitle: "Interromper Treinamento",
stopDescription: "Escolha como você deseja interromper a execução de treinamento atual.",
stopAction: "Interromper",
stopping: "Interrompendo...",
stopAndSave: "Interromper e Salvar",
compareInChat: "Comparar no Chat",
exportModel: "Exportar Modelo",
milestone: "Marco",
halfwayDone: "Metade concluída. O treinamento passou de 50%.",
doneNextStep:
"Treinamento concluído. Próximo passo: comparar as saídas do modelo base vs fine-tuned.",
},
history: {
title: "Histórico",
emptyTitle: "Nenhuma execução de treino ainda",
emptyDescription:
"Nenhuma execução de treino ainda. Inicie sua primeira execução na aba Configurar.",
loadError: "Falha ao carregar as execuções de treino",
deleteError: "Falha ao excluir a execução de treino. Por favor, tente novamente.",
retry: "Tentar novamente",
loadMore: "Carregar mais",
loading: "Carregando...",
loadingRun: "Carregando execução de treino...",
runNotFound: "Execução não encontrada",
deleteTitle: "Excluir execução de treino?",
deleteDescription:
"Isso excluirá permanentemente esta execução de treino e todas as suas métricas. Esta ação não pode ser desfeita.",
runCount: "{count} execuções",
oneRun: "1 execução",
resume: "Retomar",
resumeTraining: "Retomar treinamento",
resuming: "Retomando...",
deleteRun: "Excluir execução",
loss: "Loss",
steps: "Passos",
lossTrendSparkline: "Minigráfico de tendência da loss",
relativeJustNow: "agora mesmo",
relativeMinutesAgo: "há {count}m",
relativeHoursAgo: "há {count}h",
relativeDaysAgo: "há {count}d",
status: {
completed: "Concluído",
stopped: "Interrompido",
error: "Erro",
running: "Em andamento",
continued: "Continuado",
},
message: {
completed: "Treinamento concluído",
stopped: "Treinamento interrompido",
running: "Treinamento em andamento",
errored: "Treinamento com erro",
},
},
charts: {
settings: "Configurações do Gráfico",
settingsDescription:
"Ajuste a apresentação do gráfico enquanto o treinamento continua rodando.",
openSettings: "Abrir configurações do gráfico",
viewWindow: "Janela de visualização",
viewWindowDescription: "Mostra apenas os passos mais recentes ou o histórico completo.",
window: "Janela",
all: "Tudo",
trainingLoss: "Loss de Treinamento",
trainingLossDescription: "Controle as sobreposições e a suavização EMA.",
smoothing: "Suavização",
smoothingDescription: "Mova para a direita para mais suavização. `0` = bruto.",
showRawLoss: "Mostrar loss bruta",
showSmoothedLoss: "Mostrar loss suavizada",
showAverageLine: "Mostrar linha média",
scaleAndCleanup: "Escala e limpeza",
linear: "Linear",
log: "Log",
noClip: "Sem corte",
clipP99: "Cortar p99",
clipP95: "Cortar p95",
lossAxis: "Eixo da loss",
gradientNormAxis: "Eixo da norma do gradiente",
learningRateAxis: "Eixo da taxa de aprendizado",
resetDefaults: "Redefinir padrões",
loss: "Loss",
smoothed: "Suavizado",
evalLoss: "Loss de Validação",
learningRate: "Taxa de Aprendizado",
lr: "LR",
gradNorm: "Norma do Grad.",
gradientNorm: "Norma do Gradiente",
step: "Passo {step}",
averageValue: "média {value}",
waitingForFirstEvaluationStep: "Aguardando o primeiro passo de validação...",
evaluationNotConfigured: "Validação não configurada",
evalChartWillAppear: "O gráfico aparecerá assim que o eval_steps for alcançado",
setEvalDatasetAndSteps:
"Defina o dataset de validação e eval_steps para acompanhar a loss de validação",
},
progress: {
title: "Progresso do Treinamento",
liveMetrics: "Métricas de treino em tempo real",
exportGguf: "Exportar para GGUF",
openConfig: "Abrir configuração de treino",
configLabel: "Configuração de Treino",
hyperparams: "Hiperparâmetros",
epochs: "Épocas",
batchSize: "Tamanho do lote",
learningRate: "Taxa de aprendizado",
optimizer: "Otimizador",
maxSteps: "Passos máximos",
contextLength: "Comprimento do contexto",
warmupSteps: "Passos de warmup",
rank: "Rank",
alpha: "Alpha",
dropout: "Dropout",
variant: "Variante",
epoch: "Época {value}",
percentComplete: "{percent}% completo",
stepProgress: "Passo {current} / {total}",
loss: "Loss",
lr: "LR",
gradNorm: "Norma do Grad.",
model: "Modelo",
method: "Método",
elapsed: "Decorrido: {value}",
eta: "ETA: {value}",
stepsPerSecond: "{value} passos/s",
noStepsPerSecond: "-- passos/s",
tokens: "Tokens: {value}",
gpuMonitor: "Monitor da GPU",
live: "Ao vivo",
utilization: "Utilização",
temperature: "Temperatura",
vram: "VRAM",
power: "Energia",
phase: {
idle: "Ocioso",
downloadingModel: "Baixando modelo",
downloadingDataset: "Baixando dataset",
loadingModel: "Carregando modelo",
loadingDataset: "Carregando dataset",
configuring: "Configurando",
training: "Treinando",
completed: "Concluído",
error: "Erro",
stopped: "Interrompido",
},
},
trainingStart: {
ready: "Pronto",
downloading: "Baixando",
preparing: "Preparando",
left: "restam {eta}",
downloaded: "{size} baixados",
terminalStart: "> treinamento do unsloth iniciado...",
preparingResources: "> Preparando modelo e dataset...",
gettingReady: "> Estamos deixando tudo pronto para a sua execução...",
waitingForFirstStep: "> {message} | aguardando o primeiro passo... ({step})",
resumingTraining: "Retomando treinamento...",
startingTraining: "iniciando treinamento...",
dataset: "Dataset",
datasetStreaming: "Dataset: streaming (sem download completo)",
modelWeights: "Pesos do modelo",
},
tour: {
guidedTour: "Tour Guiado",
},
},
} as const;

View file

@ -4,19 +4,26 @@
import { getLocale } from "./locale-store";
import { en } from "./locales/en";
import { zhCN } from "./locales/zh-CN";
import { ptBR } from "./locales/pt-br";
import { ja } from "./locales/ja";
import type { InterpolationValues, MessageKey } from "./types";
export const LOCALES = {
en: { label: "English", nativeLabel: "English" },
"zh-CN": { label: "Chinese (Simplified)", nativeLabel: "简体中文" },
ja: { label: "Japanese", nativeLabel: "日本語" },
"pt-BR": { label: "Portuguese (Brazil)", nativeLabel: "Português (Brasil)" },
"ja": { label: "Japanese", nativeLabel: "日本語" },
} as const;
export type Locale = keyof typeof LOCALES;
export type TranslationKey = MessageKey<typeof en>;
export const messages = { en, "zh-CN": zhCN, ja } as const;
export const messages = {
en,
"zh-CN": zhCN,
"pt-BR": ptBR,
ja
} as const;
const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
@ -75,4 +82,4 @@ export function isSupportedLocale(value: unknown): value is Locale {
typeof value === "string" &&
Object.prototype.hasOwnProperty.call(LOCALES, value)
);
}
}