Merge branch 'diffusion-train-tab-2' into diffusion-krea2
This commit is contained in:
commit
ea9f7ae9f9
131 changed files with 19488 additions and 3092 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 1→2→3
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
160
studio/frontend/src/components/floating-monitor.tsx
Normal file
160
studio/frontend/src/components/floating-monitor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
|
|
|
|||
82
studio/frontend/src/features/settings/api/embedding-model.ts
Normal file
82
studio/frontend/src/features/settings/api/embedding-model.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
)
|
||||
);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
>
|
||||
|
|
|
|||
477
studio/frontend/src/features/settings/tabs/resources-tab.tsx
Normal file
477
studio/frontend/src/features/settings/tabs/resources-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
130
studio/frontend/src/hooks/use-system.ts
Normal file
130
studio/frontend/src/hooks/use-system.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.");
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
934
studio/frontend/src/i18n/locales/pt-br.ts
Normal file
934
studio/frontend/src/i18n/locales/pt-br.ts
Normal 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;
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue