Fix model picker lint boundaries

This commit is contained in:
sneakr 2026-07-10 05:47:47 +02:00
commit 647d201008
22 changed files with 487 additions and 326 deletions

View file

@ -27,6 +27,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { InfoHint } from "@/components/ui/info-hint";
import {
InputGroup,
InputGroupAddon,
@ -49,29 +50,27 @@ import {
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { InfoHint } from "@/components/ui/info-hint";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { NumericValueInput, snapToStep } from "@/features/model-picker";
import { RetrievalSettingsSection } from "@/features/rag";
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
import { cn } from "@/lib/utils";
import {
Edit03Icon,
LayoutAlignRightIcon,
} from "@hugeicons/core-free-icons";
import { useIsMobile } from "@/hooks/use-mobile";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { Edit03Icon, LayoutAlignRightIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Braces, ChevronDown, ExternalLink } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Fragment, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "@/lib/toast";
import { OpenAICodeExecSection } from "./components/openai-code-exec-section";
import {
type ExternalProviderConfig,
getExternalProviderApiKey,
parseExternalModelId,
supportsProviderPromptCaching,
supportsProviderPromptCacheTtl,
supportsProviderPromptCaching,
} from "./external-providers";
import {
BUILTIN_PRESETS,
@ -92,11 +91,6 @@ import {
providerSupportsFastMode,
} from "./provider-capabilities";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import {
NumericValueInput,
snapToStep,
} from "@/features/model-picker/components/numeric-value-input";
import { RetrievalSettingsSection } from "@/features/rag/components/retrieval-settings-section";
import type { InferenceParams } from "./types/runtime";
export { defaultInferenceParams, type Preset } from "./presets/preset-policy";
@ -119,7 +113,7 @@ function getPromptVariablesError(raw: string): string | null {
return null;
}
} catch {
return "Use valid JSON, for example { \"env\": \"staging\" }.";
return 'Use valid JSON, for example { "env": "staging" }.';
}
return "Variables must be a JSON object.";
}
@ -266,8 +260,7 @@ function CollapsibleSection({
return (
<div
className={cn(
!first &&
"border-t border-black/[0.13] dark:border-white/[0.09]",
!first && "border-t border-black/[0.13] dark:border-white/[0.09]",
)}
>
{labelHref ? (
@ -381,8 +374,7 @@ export function ChatSettingsPanel({
const showPresencePenalty =
!isExternalModel || Boolean(providerCapabilities?.presencePenalty);
const isMobile = useIsMobile();
const isLoadedGguf =
useChatRuntimeStore((s) => s.activeGgufVariant) != null;
const isLoadedGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null;
const isGguf = isLoadedGguf;
const currentCheckpoint = params.checkpoint;
const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
@ -410,7 +402,9 @@ export function ChatSettingsPanel({
`llama.cpp updated to ${result.tag ?? "the latest build"}.${reloadHint}`,
);
} else {
toast.error(`llama.cpp update failed: ${result.error ?? "unknown error"}`);
toast.error(
`llama.cpp update failed: ${result.error ?? "unknown error"}`,
);
}
}, [applyLlamaUpdate]);
const loadedEffectiveContext = customContextLength ?? ggufContextLength;
@ -465,8 +459,7 @@ export function ChatSettingsPanel({
BUILTIN_PRESETS.find((preset) => preset.name === activePreset) ?? null,
[activePreset],
);
const hasUnsavedPresetChanges = useMemo(
() => {
const hasUnsavedPresetChanges = useMemo(() => {
if (activePresetDefinition == null) {
return false;
}
@ -474,9 +467,7 @@ export function ChatSettingsPanel({
return activePresetSource === "modified";
}
return !isSamePresetConfig(activePresetDefinition.params, params);
},
[activePresetDefinition, activePresetSource, params],
);
}, [activePresetDefinition, activePresetSource, params]);
const presetSaveState = useMemo(
() =>
getPresetSaveState({
@ -505,8 +496,7 @@ export function ChatSettingsPanel({
const externalSelection = currentCheckpoint
? parseExternalModelId(currentCheckpoint)
: null;
const maxTokensMax =
isExternalModel
const maxTokensMax = isExternalModel
? getExternalMaxOutputTokens(
externalProviderType,
externalSelection?.modelId,
@ -596,8 +586,7 @@ export function ChatSettingsPanel({
return;
}
const fallbackPreset =
BUILTIN_PRESETS.find((preset) => preset.name === "Default") ??
null;
BUILTIN_PRESETS.find((preset) => preset.name === "Default") ?? null;
const next = customPresets.filter((preset) => preset.name !== name);
setCustomPresets(next);
if (activePreset === name) {
@ -709,7 +698,7 @@ export function ChatSettingsPanel({
Run settings
</span>
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<TooltipPrimitive.Trigger asChild={true}>
<button
type="button"
onClick={() => onOpenChange?.(false)}
@ -741,7 +730,7 @@ export function ChatSettingsPanel({
>
<div className="px-[18px] pt-3">
{(hasModelContent || modelConfig) && (
<CollapsibleSection label="Model" defaultOpen={true} first>
<CollapsibleSection label="Model" defaultOpen={true} first={true}>
<div className="flex flex-col gap-3 pt-1">
{modelConfig}
{showSpecFallback && (
@ -775,8 +764,8 @@ export function ChatSettingsPanel({
{showContextVramWarning && (
<p className="text-[11px] text-amber-500">
Context length exceeds the estimated VRAM capacity (
{ggufMaxContextLength?.toLocaleString()} tokens). The model may
use system RAM.
{ggufMaxContextLength?.toLocaleString()} tokens). The
model may use system RAM.
</p>
)}
</div>
@ -790,7 +779,7 @@ export function ChatSettingsPanel({
>
<div className="flex flex-col gap-3 pt-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild={true}>
<div
className="w-full min-w-0 cursor-pointer outline-none focus-visible:outline-none"
aria-label="Open preset list"
@ -874,7 +863,9 @@ export function ChatSettingsPanel({
type="button"
onClick={() => savePresetWithName(presetNameInput)}
disabled={!(settingsHydrated && presetSaveState.canSubmit)}
variant={presetSaveState.isSaveReady ? "default" : "outline"}
variant={
presetSaveState.isSaveReady ? "default" : "outline"
}
size="sm"
className={cn(
"h-9 w-full rounded-full text-[13px] font-medium tracking-nav",
@ -915,7 +906,8 @@ export function ChatSettingsPanel({
Prompt caching
</span>
<InfoHint>
Reuse compatible prompt prefixes for lower latency and cost.
Reuse compatible prompt prefixes for lower latency and
cost.
</InfoHint>
</div>
<Switch
@ -938,10 +930,10 @@ export function ChatSettingsPanel({
</span>
<InfoHint>
Anthropic exposes a 5 minute and a 1 hour ephemeral
cache pool. The 1 hour pool costs 2x base input on
write vs 1.25x for 5 minute, but reads stay 0.1x for
both, so a single read landing more than 5 minutes
after the write pays off the premium.
cache pool. The 1 hour pool costs 2x base input on write
vs 1.25x for 5 minute, but reads stay 0.1x for both, so
a single read landing more than 5 minutes after the
write pays off the premium.
</InfoHint>
</div>
<Select
@ -1009,7 +1001,7 @@ export function ChatSettingsPanel({
onLabelClick={openSystemPromptEditor}
headerAction={
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<TooltipPrimitive.Trigger asChild={true}>
<button
type="button"
onClick={openSystemPromptEditor}
@ -1065,7 +1057,6 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
<CollapsibleSection label="Sampling" defaultOpen={true}>
<div className="flex flex-col gap-5 pt-1">
{showTemperature ? (
@ -1136,7 +1127,9 @@ export function ChatSettingsPanel({
max={2}
step={0.1}
onChange={set("presencePenalty")}
displayValue={params.presencePenalty === 0 ? "Off" : undefined}
displayValue={
params.presencePenalty === 0 ? "Off" : undefined
}
info="Penalizes any token that has already appeared at least once, encouraging the model to introduce new topics. 0 = off."
/>
) : null}
@ -1165,7 +1158,7 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
{!isExternalModel ? (
{isExternalModel ? null : (
<CollapsibleSection label="Tools">
<div className="flex flex-col gap-5 pt-1">
<AutoHealToolCallsToggle />
@ -1176,13 +1169,13 @@ export function ChatSettingsPanel({
<ToolCallTimeoutSlider />
</div>
</CollapsibleSection>
) : null}
)}
{!isExternalModel ? (
{isExternalModel ? null : (
<CollapsibleSection label="Retrieval">
<RetrievalSettingsSection />
</CollapsibleSection>
) : null}
)}
</div>
</div>
</div>

View file

@ -3,11 +3,23 @@
export { ChatPage, validateChatSearch, type ChatSearch } from "./chat-page";
export {
addScanFolder,
browseFolders,
deleteCachedModel,
deleteFineTunedModel,
fetchGgufContextLength,
getInferenceStatus,
listGgufVariants,
listLocalModels,
listRecommendedFolders,
listScanFolders,
loadModel,
removeScanFolder,
type BrowseFoldersResponse,
type CachedGgufRepo,
type CachedModelRepo,
type LocalModelInfo,
type ScanFolderInfo,
} from "./api/chat-api";
export type { GgufVariantDetail } from "./types/api";
export {
@ -17,6 +29,10 @@ export {
type Preset,
} from "./chat-settings-sheet";
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
export {
normalizeSpeculativeType,
readPersistedSpeculativeType,
} from "./stores/chat-runtime-store";
export { useChatSearchStore } from "./stores/chat-search-store";
export { usePinnedChatsStore } from "./stores/pinned-chats-store";
export { useChatPreferencesStore } from "./stores/chat-preferences-store";
@ -28,9 +44,11 @@ export {
export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
export {
customProviderDisplayName,
isCustomProviderType,
isExternalModelId,
parseExternalModelId,
} from "./external-providers";
export { ApiProviderLogo } from "./api-provider-logo";
export { useExternalProvidersStore } from "./stores/external-providers-store";
export { ChatSearchDialog } from "./components/chat-search-dialog";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";

View file

@ -2,7 +2,6 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { FolderBrowser } from "@/features/model-picker/components/model-selector/folder-browser";
import { Input } from "@/components/ui/input";
import {
InputGroup,
@ -17,6 +16,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { FolderBrowser } from "@/features/model-picker";
import {
AlertCircleIcon,
ArrowRight01Icon,
@ -28,17 +28,17 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import type { ExportLogEntry } from "../api/export-api";
import {
EXPORT_METHODS,
type ExportMethod,
findMergedFormat,
} from "../constants";
import type { ExportLogEntry } from "../api/export-api";
import { getExportLogLineClass } from "../lib/log-style";
import {
type ExportDestination,
selectExportProgressPercent,
useExportRuntimeStore,
type ExportDestination,
} from "../stores/export-runtime-store";
function useElapsedSeconds(startedAt: number | null, running: boolean): number {
@ -165,7 +165,9 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
const isExporting = run.isExporting;
const isTerminal =
run.phase === "success" || run.phase === "error" || run.phase === "canceled";
run.phase === "success" ||
run.phase === "error" ||
run.phase === "canceled";
const showConfig = run.phase === "idle";
// Gate the log area on the active run's method (from the store) as well as the
// local form selection, so it stays visible after navigating away and back
@ -197,7 +199,9 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
setFollowTail(nearBottom);
};
const methodTitle = EXPORT_METHODS.find((m) => m.value === exportMethod)?.title;
const methodTitle = EXPORT_METHODS.find(
(m) => m.value === exportMethod,
)?.title;
const summary = run.summary;
const summaryBaseModel = summary?.baseModelName ?? baseModelName;
const summaryCheckpoint = summary?.checkpointLabel ?? checkpoint;
@ -290,7 +294,10 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
onClick={() => setFolderBrowserOpen(true)}
aria-label="Browse save folder"
>
<HugeiconsIcon icon={FolderSearchIcon} className="size-4" />
<HugeiconsIcon
icon={FolderSearchIcon}
className="size-4"
/>
</Button>
</TooltipTrigger>
<TooltipContent>Browse</TooltipContent>
@ -301,8 +308,8 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
<>Default: {defaultSaveDirectory}</>
) : (
<>
Paste an absolute path if the folder browser cannot reach the
drive.
Paste an absolute path if the folder browser cannot reach
the drive.
</>
)}
</p>
@ -410,7 +417,10 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
: [];
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">
<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}
@ -431,14 +441,22 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
{run.phase === "canceled" && (
<div className="flex items-start gap-2 rounded-lg bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
<HugeiconsIcon icon={CancelCircleIcon} className="mt-0.5 size-4 shrink-0" />
<span>Export canceled. Training and inference were not affected.</span>
<HugeiconsIcon
icon={CancelCircleIcon}
className="mt-0.5 size-4 shrink-0"
/>
<span>
Export canceled. Training and inference were not affected.
</span>
</div>
)}
{run.phase === "error" && run.error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<HugeiconsIcon icon={AlertCircleIcon} className="size-4 mt-0.5 shrink-0" />
<HugeiconsIcon
icon={AlertCircleIcon}
className="size-4 mt-0.5 shrink-0"
/>
<span>{run.error}</span>
</div>
)}
@ -447,15 +465,21 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
<div className="rounded-xl bg-muted/50 p-3 text-xs text-muted-foreground flex flex-col gap-1">
<div className="flex justify-between">
<span>Base Model</span>
<span className="font-medium text-foreground">{summaryBaseModel}</span>
<span className="font-medium text-foreground">
{summaryBaseModel}
</span>
</div>
<div className="flex justify-between">
<span>{isAdapter ? "Checkpoint" : "Model"}</span>
<span className="font-medium text-foreground">{summaryCheckpoint}</span>
<span className="font-medium text-foreground">
{summaryCheckpoint}
</span>
</div>
<div className="flex justify-between">
<span>Export Method</span>
<span className="font-medium text-foreground">{summaryMethodLabel}</span>
<span className="font-medium text-foreground">
{summaryMethodLabel}
</span>
</div>
{summaryMethod === "merged" && summaryFormats.length > 0 && (
<div className="flex justify-between gap-3">
@ -484,7 +508,12 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
</span>
{summaryMethod === "gguf" && run.quantTotal > 1 && (
<span className="text-[10px] tabular-nums text-muted-foreground">
Quant {Math.min(run.quantIndex + (isExporting ? 1 : 0), run.quantTotal)} of {run.quantTotal}
Quant{" "}
{Math.min(
run.quantIndex + (isExporting ? 1 : 0),
run.quantTotal,
)}{" "}
of {run.quantTotal}
</span>
)}
<span className="rounded-full border border-border/60 px-2.5 py-1 text-[10px] font-medium tabular-nums text-muted-foreground">
@ -506,7 +535,10 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
}
/>
{run.stage && (
<p className="truncate text-[11px] text-muted-foreground/80" title={run.stage}>
<p
className="truncate text-[11px] text-muted-foreground/80"
title={run.stage}
>
{run.stage}
</p>
)}
@ -556,10 +588,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
) : (
<div className="whitespace-pre-wrap break-words">
{run.logLines.map((entry, idx) => (
<div
key={idx}
className={getExportLogLineClass(entry)}
>
<div key={idx} className={getExportLogLineClass(entry)}>
{formatLogLine(entry)}
</div>
))}

View file

@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { ModelDeleteAction } from "@/features/model-picker/components/model-selector/model-delete-action";
import {
Tooltip,
TooltipContent,
@ -9,18 +8,22 @@ import {
} from "@/components/ui/tooltip";
import {
type GgufVariantDetail,
deleteCachedModel,
deleteCachedDataset,
deleteCachedModel,
formatLocalUpdated,
listGgufVariants,
useGgufVariantsCacheVersion,
} from "@/features/hub/inventory";
import { classifyUnslothSupport } from "@/features/hub/hooks/use-hub-model-search";
import { formatBytes, formatRelativeShort } from "@/features/hub/lib/format";
import { ggufVariantDisplayLabel } from "@/features/hub/lib/gguf-variant-sort";
import { modelIdsMatch } from "@/features/hub/lib/model-identity";
} from "@/features/hub";
import {
classifyUnslothSupport,
formatBytes,
formatRelativeShort,
ggufVariantDisplayLabel,
modelIdsMatch,
useHfTokenStore,
} from "@/features/hub";
import { ModelDeleteAction } from "@/features/model-picker";
import { cn, formatCompact } from "@/lib/utils";
import { useHfTokenStore } from "@/features/hub/stores/hf-token-store";
import {
Download01Icon,
FavouriteIcon,
@ -39,6 +42,7 @@ import {
useRef,
useState,
} from "react";
import { paramLabelFromId } from "../lib/view-models";
import type {
CachedInventoryRow,
DiscoverRow,
@ -46,7 +50,6 @@ import type {
} from "../types";
import { OwnerAvatar } from "./owner-avatar";
import { AccessGlyphs } from "./shared";
import { paramLabelFromId } from "../lib/view-models";
const COARSE_POINTER =
typeof window !== "undefined" &&
@ -142,15 +145,15 @@ function CachedSizeChipLive({
);
const rows: Array<{ label: string; size_bytes: number }> | null =
!needsVariantFetch
? [{ label: repoId, size_bytes: totalBytes }]
: currentVariantState.status === "loaded" &&
needsVariantFetch
? currentVariantState.status === "loaded" &&
currentVariantState.variants.length > 0
? currentVariantState.variants.map((variant) => ({
label: ggufVariantDisplayLabel(variant),
size_bytes: variant.size_bytes,
}))
: null;
: null
: [{ label: repoId, size_bytes: totalBytes }];
const variantMessage =
currentVariantState.status === "loading"
? "Loading downloaded variants..."
@ -275,7 +278,9 @@ function CatalogRow({
)}
/>
<CatalogRowInteractiveContext.Provider value={interactive}>
<div className={cn("pointer-events-none relative", card && "z-[1] w-full")}>
<div
className={cn("pointer-events-none relative", card && "z-[1] w-full")}
>
{children}
</div>
</CatalogRowInteractiveContext.Provider>
@ -653,7 +658,9 @@ export const InventoryRow = memo(function InventoryRow({
<div className="hidden shrink-0 items-center gap-1.5 sm:flex">
{/* Format already shows as the status dot, so the pill stays neutral. */}
{formatLabel && <span className="hub-chip">{formatLabel}</span>}
{paramLabel && <span className="hub-chip tabular-nums">{paramLabel}</span>}
{paramLabel && (
<span className="hub-chip tabular-nums">{paramLabel}</span>
)}
{quantLabel && (
<span className="hub-chip font-mono text-[10.5px] uppercase">
{quantLabel}
@ -697,9 +704,7 @@ export const InventoryRow = memo(function InventoryRow({
const compactMarkers =
partialRepoId || unsupported ? (
<span className="flex shrink-0 items-center gap-1">
{partialRepoId && (
<StatusDot tone="warning" label="Partial download" />
)}
{partialRepoId && <StatusDot tone="warning" label="Partial download" />}
{unsupported && (
<StatusDot tone="danger" label="May not be supported yet" />
)}

View file

@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { FolderBrowser } from "@/features/model-picker/components/model-selector/folder-browser";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -22,9 +21,11 @@ import {
addScanFolder,
listScanFolders,
removeScanFolder,
} from "@/features/hub/inventory";
import { openModelsDir } from "@/features/native-intents/api";
} from "@/features/hub";
import { FolderBrowser } from "@/features/model-picker";
import { openModelsDir } from "@/features/native-intents";
import { isTauri } from "@/lib/api-base";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import {
Delete02Icon,
@ -38,7 +39,6 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "@/lib/toast";
function pathTail(path: string): string {
const parts = path.split(/[\\/]/).filter(Boolean);
@ -123,7 +123,9 @@ export function OnDeviceFoldersDialog({
setPath("");
mutationVersionRef.current += 1;
setFolders((current) => {
const withoutDuplicate = current.filter((row) => row.id !== folder.id);
const withoutDuplicate = current.filter(
(row) => row.id !== folder.id,
);
return [...withoutDuplicate, folder];
});
toast.success("Location added", {
@ -184,9 +186,12 @@ export function OnDeviceFoldersDialog({
overlayClassName="bg-black/20 backdrop-blur-none"
>
<DialogHeader className="border-b border-border/60 px-5 py-4">
<DialogTitle className="text-[15px]">On-device locations</DialogTitle>
<DialogTitle className="text-[15px]">
On-device locations
</DialogTitle>
<DialogDescription className="sr-only">
Hugging Face model folders, GGUF files, and adapters are indexed here.
Hugging Face model folders, GGUF files, and adapters are indexed
here.
</DialogDescription>
</DialogHeader>
@ -342,9 +347,7 @@ export function OnDeviceFoldersDialog({
</p>
<Tooltip>
<TooltipTrigger asChild={true}>
<p
className="block w-full truncate font-mono text-[10.5px] text-muted-foreground"
>
<p className="block w-full truncate font-mono text-[10.5px] text-muted-foreground">
{folder.path}
</p>
</TooltipTrigger>
@ -372,7 +375,10 @@ export function OnDeviceFoldersDialog({
/>
</button>
</TooltipTrigger>
<TooltipContent side="left" className="tooltip-compact">
<TooltipContent
side="left"
className="tooltip-compact"
>
Open in file manager
</TooltipContent>
</Tooltip>
@ -397,7 +403,10 @@ export function OnDeviceFoldersDialog({
)}
</button>
</TooltipTrigger>
<TooltipContent side="left" className="tooltip-compact">
<TooltipContent
side="left"
className="tooltip-compact"
>
Remove from list
</TooltipContent>
</Tooltip>

View file

@ -1,35 +1,32 @@
// 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 {
applyModelLoadConfigToRuntime,
currentRuntimePerModelConfig,
resolveInitialConfig,
} from "@/features/model-picker";
import { hfModelFitsDevice } from "@/features/model-picker/components/model-selector/recommended-fit";
import { useHubInventory } from "@/features/hub/inventory";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useGpuInfo } from "@/hooks/use-gpu-info";
import {
type HfModelSearchChannel,
type HfSortDirection,
type HfSortKey,
} from "@/features/hub/hooks/use-hub-model-search";
import { useOnlineStatus } from "@/features/hub/hooks/use-online-status";
import { useHubInfiniteScroll } from "@/features/hub/hooks/use-hub-infinite-scroll";
import { ggufVariantsMatch, modelIdsMatch } from "@/features/hub/lib/model-identity";
import { cn } from "@/lib/utils";
import { usePlatformStore } from "@/config/env";
import {
hfApiToken,
useHfTokenStore,
} from "@/features/hub/stores/hf-token-store";
import {
getInferenceStatus,
isExternalModelId,
useChatModelRuntime,
useChatRuntimeStore,
} from "@/features/chat";
import { useHubInventory } from "@/features/hub";
import type {
HfModelSearchChannel,
HfSortDirection,
HfSortKey,
} from "@/features/hub";
import { useOnlineStatus } from "@/features/hub";
import { useHubInfiniteScroll } from "@/features/hub";
import { ggufVariantsMatch, modelIdsMatch } from "@/features/hub";
import { hfApiToken, useHfTokenStore } from "@/features/hub";
import {
applyModelLoadConfigToRuntime,
currentRuntimePerModelConfig,
hfModelFitsDevice,
resolveInitialConfig,
} from "@/features/model-picker";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useGpuInfo } from "@/hooks/use-gpu-info";
import { cn } from "@/lib/utils";
import { useNavigate, useSearch } from "@tanstack/react-router";
import {
useCallback,
@ -39,17 +36,10 @@ import {
useRef,
useState,
} from "react";
import { ExternalLinkConfirmDialog } from "./catalog/external-link-confirm-dialog";
import { HubDetailView } from "./catalog/hub-detail-view";
import { HubTopBar } from "./catalog/hub-top-bar";
import { HubFeed } from "./catalog/hub-feed";
import { OwnerScopeToggle } from "./catalog/owner-scope-toggle";
import {
type AllModelsView,
HubListHeader,
type InventorySort,
InventorySortControl,
ResultListHeader,
} from "./catalog/models-table";
import { HubTopBar } from "./catalog/hub-top-bar";
import {
ModelsCatalog,
type ModelsCatalogHandlers,
@ -57,9 +47,16 @@ import {
type ModelsCatalogState,
} from "./catalog/models-catalog";
import { ModelsHeader } from "./catalog/models-header";
import {
type AllModelsView,
HubListHeader,
type InventorySort,
InventorySortControl,
ResultListHeader,
} from "./catalog/models-table";
import { ModelsToolbar } from "./catalog/models-toolbar";
import { ExternalLinkConfirmDialog } from "./catalog/external-link-confirm-dialog";
import { OnDeviceFoldersDialog } from "./catalog/on-device-folders-dialog";
import { OwnerScopeToggle } from "./catalog/owner-scope-toggle";
import { useDiscoverSearch } from "./hooks/use-discover-search";
import { useFeedWriteBack } from "./hooks/use-feed-write-back";
import { useHubFeed } from "./hooks/use-hub-feed";
@ -568,15 +565,15 @@ export function ModelsPage() {
const deferredCapabilityFilter = useDeferredValue(capabilityFilter);
const hasQuery = deferredDebouncedQuery.trim() !== "";
const mode: DiscoverMode = !isModelDiscover
? "search"
: hasQuery
const mode: DiscoverMode = isModelDiscover
? hasQuery
? "search"
: urlSection != null
? "channel-list"
: sortBrowseActive
? "search"
: "feed";
: "feed"
: "search";
const isFeedMode = mode === "feed";
const isChannelListMode = mode === "channel-list";
const isSortBrowseMode =
@ -700,7 +697,10 @@ export function ModelsPage() {
return discoverRows.filter(
(row) =>
!isHiddenModelId(row.id) &&
matchesFormat(detectResultFormat(row.result), effectiveDiscoverFormat) &&
matchesFormat(
detectResultFormat(row.result),
effectiveDiscoverFormat,
) &&
matchesCapability(row.capabilities, deferredCapabilityFilter) &&
(!activeChannel?.finetunableOnly || isUnslothFinetunable(row.result)) &&
// Models already on disk stay visible regardless of device fit,
@ -763,7 +763,10 @@ export function ModelsPage() {
}
return merged;
}, [isFeedMode, feedTrendingRows, filteredDiscoverRows]);
const feedResults = useMemo(() => feedRows.map((row) => row.result), [feedRows]);
const feedResults = useMemo(
() => feedRows.map((row) => row.result),
[feedRows],
);
const selectionDiscoverRows = isFeedMode ? feedRows : discoverRows;
const selectionFilteredDiscoverRows = isFeedMode
? feedRows
@ -1343,16 +1346,18 @@ export function ModelsPage() {
</div>
);
}
const ownerToggle = !isDatasetMode ? (
const ownerToggle = isDatasetMode ? undefined : (
<OwnerScopeToggle value={ownerScope} onChange={setOwnerScope} />
) : undefined;
);
// Compact pill so it stays beside the view-mode tabs even in the narrow
// split pane instead of dropping to its own row.
return (
<div className="flex flex-col gap-3 pt-6">
{isChannelListMode ? (
<HubListHeader
title={channelSection ? HUB_SECTION_TITLE[channelSection] : "Models"}
title={
channelSection ? HUB_SECTION_TITLE[channelSection] : "Models"
}
count={listCount}
view={allModelsView}
onViewChange={setAllModelsView}

View file

@ -0,0 +1,60 @@
// 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 {
cancelModelDownload,
downloadManager,
jobKeyOf,
subscribeJobListeners,
useDownloadManagerStore,
type TransportConflictInfo,
} from "./download-manager";
export {
useHubInventory,
type CachedInventoryRow,
type GgufVariantDetail,
type HubInventory,
type HubInventoryKind,
type InventoryRow,
type LocalInventoryRow,
type LocalSource,
type ScanFolderInfo,
addScanFolder,
deleteCachedDataset,
deleteCachedModel,
formatLocalUpdated,
listGgufVariants,
listScanFolders,
removeScanFolder,
useGgufVariantsCacheVersion,
} from "./inventory";
export {
type HfModelResult,
type HfModelSearchChannel,
type HfSortDirection,
type HfSortKey,
useHubModelSearch,
} from "./hooks/use-hub-model-search";
export { useOnlineStatus } from "./hooks/use-online-status";
export { useHubInfiniteScroll } from "./hooks/use-hub-infinite-scroll";
export { useHfTokenStore, hfApiToken } from "./stores/hf-token-store";
export { looksLikeLocalPath } from "./lib/local-path";
export { hubTokenHeader } from "./lib/hub-token-header";
export {
ggufVariantsMatch,
modelIdsMatch,
normalizeGgufVariantIdentity,
normalizeModelIdentity,
} from "./lib/model-identity";
export { formatBytes, formatRelativeShort } from "./lib/format";
export { ggufVariantDisplayLabel } from "./lib/gguf-variant-sort";
export {
DeleteConfirmDialog,
UpdateConfirmDialog,
} from "./catalog/download-card";
export { HubOptionMenu, type HubOption } from "./catalog/hub-option-menu";
export { DotTag } from "./catalog/dot-tag";
export { TransportConflictDialog } from "./catalog/transport-conflict-dialog";
export { TrainIcon } from "./components/train-icon";
export { isHiddenModelId } from "./lib/hidden-models";
export { classifyUnslothSupport } from "./lib/unsloth-support";

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import { hubTokenHeader } from "@/features/hub/lib/hub-token-header";
import { hubTokenHeader } from "@/features/hub";
import { readFastApiError } from "@/lib/format-fastapi-error";
export interface ValidateChatTemplateResult {

View file

@ -12,7 +12,7 @@ import {
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { useEffect, useRef, useState } from "react";
import { useMemo, useState } from "react";
import { validateChatTemplate } from "../api/templates";
import {
MAX_CHAT_TEMPLATE_BYTES,
@ -42,26 +42,22 @@ export function ChatTemplateEditorDialog({
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const [validating, setValidating] = useState(false);
const wasOpen = useRef(false);
const initialDraft = value ?? defaultTemplate ?? "";
const renderedDraft = useMemo(
() =>
open && value == null && defaultTemplate != null && draft.length === 0
? defaultTemplate
: draft,
[open, value, defaultTemplate, draft],
);
useEffect(() => {
const justOpened = open && !wasOpen.current;
wasOpen.current = open;
if (justOpened) {
setDraft(value ?? defaultTemplate ?? "");
setError(null);
} else if (open && value == null && defaultTemplate != null) {
setDraft((current) => (current.length === 0 ? defaultTemplate : current));
}
}, [open, value, defaultTemplate]);
const byteLength = chatTemplateByteLength(draft);
const overLimit = !isChatTemplateWithinLimit(draft);
const byteLength = chatTemplateByteLength(renderedDraft);
const overLimit = !isChatTemplateWithinLimit(renderedDraft);
const matchesDefault =
defaultTemplate != null && draft === defaultTemplate;
defaultTemplate != null && renderedDraft === defaultTemplate;
const handleSave = async () => {
if (draft.trim().length === 0 || matchesDefault) {
if (renderedDraft.trim().length === 0 || matchesDefault) {
onSave(null);
onOpenChange(false);
return;
@ -72,12 +68,12 @@ export function ChatTemplateEditorDialog({
}
setValidating(true);
try {
const result = await validateChatTemplate(draft);
const result = await validateChatTemplate(renderedDraft);
if (!result.valid) {
setError(result.error ?? "Invalid Jinja template.");
return;
}
onSave(draft);
onSave(renderedDraft);
onOpenChange(false);
} catch {
setError("Could not validate the template.");
@ -87,10 +83,21 @@ export function ChatTemplateEditorDialog({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
setDraft(initialDraft);
setError(null);
}
onOpenChange(nextOpen);
}}
>
<DialogContent className="corner-squircle dialog-soft-surface sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{readOnly ? "Chat Template" : "Edit Chat Template"}</DialogTitle>
<DialogTitle>
{readOnly ? "Chat Template" : "Edit Chat Template"}
</DialogTitle>
<DialogDescription>
{readOnly
? "This is the model's chat template. Custom templates apply to GGUF models for now, so it is view only for safetensors models."
@ -98,7 +105,7 @@ export function ChatTemplateEditorDialog({
</DialogDescription>
</DialogHeader>
<Textarea
value={draft}
value={renderedDraft}
onChange={(event) => {
if (readOnly) return;
setDraft(event.target.value);
@ -138,7 +145,9 @@ export function ChatTemplateEditorDialog({
type="button"
variant="ghost"
onClick={() => setDraft(defaultTemplate ?? "")}
disabled={defaultLoading || draft === (defaultTemplate ?? "")}
disabled={
defaultLoading || renderedDraft === (defaultTemplate ?? "")
}
className="text-muted-foreground"
>
{defaultLoading ? (

View file

@ -13,11 +13,12 @@ import {
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { fetchGgufContextLength } from "@/features/chat/api/chat-api";
import {
fetchGgufContextLength,
readPersistedSpeculativeType,
useChatRuntimeStore,
} from "@/features/chat/stores/chat-runtime-store";
} from "@/features/chat";
import { NumericValueInput } from "@/features/model-picker";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { toast } from "@/lib/toast";
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
@ -43,7 +44,6 @@ import {
} from "../model-config/per-model-config";
import { ChatTemplateEditorDialog } from "./chat-template-editor-dialog";
import type { ModelPickTarget } from "./model-selector/types";
import { NumericValueInput } from "./numeric-value-input";
const ROW_CLASS = "flex min-h-8 items-center justify-between gap-3";
const LABEL_CLASS =
@ -315,9 +315,7 @@ export function ModelConfigPage({
}: ModelConfigPageProps) {
const rememberId = useId();
const isActiveModel = loadedConfig != null;
const runtimeMaxSeqLength = useChatRuntimeStore(
(s) => s.params.maxSeqLength,
);
const runtimeMaxSeqLength = useChatRuntimeStore((s) => s.params.maxSeqLength);
const hfToken = useChatRuntimeStore((s) => s.hfToken);
const [initialMaxSeqLength] = useState(
() => normalizeMaxSeqLength(runtimeMaxSeqLength) ?? 4096,
@ -393,12 +391,7 @@ export function ModelConfigPage({
return () => {
cancelled = true;
};
}, [
contextFetchKey,
target.id,
target.ggufVariant,
hfToken,
]);
}, [contextFetchKey, target.id, target.ggufVariant, hfToken]);
const isMtp =
config.speculativeType != null &&
@ -619,7 +612,7 @@ export function ModelConfigPage({
<ChatTemplateSetting
config={config}
onEditTemplate={() => setTemplateOpen(true)}
readOnly
readOnly={true}
/>
</>
)}

View file

@ -3,15 +3,15 @@
"use client";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { TooltipProvider } from "@/components/ui/tooltip";
import { usePlatformStore } from "@/config/env";
import { isCustomProviderType } from "@/features/chat/external-providers";
import { isCustomProviderType } from "@/features/chat";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import {
@ -295,7 +295,8 @@ function saveLastHubSection(section: HubSection): void {
// when they have downloads, else Recommended.
function defaultHubSection(): HubSection {
return (
loadLastHubSection() ?? (hasDownloadedModels() ? "downloaded" : "recommended")
loadLastHubSection() ??
(hasDownloadedModels() ? "downloaded" : "recommended")
);
}
@ -475,11 +476,7 @@ function ModelSelectorContent({
const [configTarget, setConfigTarget] = useState<ModelPickTarget | null>(
null,
);
useEffect(() => {
if (!open) {
setConfigTarget(null);
}
}, [open]);
const visibleConfigTarget = open ? configTarget : null;
const openConfigPage = (id: string, meta: ModelSelectorChangeMeta) => {
const leaf = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id;
setConfigTarget({
@ -510,7 +507,7 @@ function ModelSelectorContent({
onKeyDown={handlePickerEntryKeyDown}
className={cn(
"unsloth-model-selector-menu menu-soft-surface ring-0 max-w-[calc(100vw-1rem)] min-w-0 gap-0",
configTarget
visibleConfigTarget
? "w-[min(468px,calc(100vw-1rem))] px-4 pt-4 pb-4"
: cn(
"pt-4 pb-0 pl-4",
@ -532,32 +529,35 @@ function ModelSelectorContent({
skipDelayDuration={0}
disableHoverableContent={true}
>
{configTarget ? (
{visibleConfigTarget ? (
<ModelConfigPage
key={`${configTarget.id}::${configTarget.ggufVariant ?? ""}`}
target={configTarget}
key={`${visibleConfigTarget.id}::${visibleConfigTarget.ggufVariant ?? ""}`}
target={visibleConfigTarget}
onBack={() => setConfigTarget(null)}
onRun={(config) =>
onSelect(configTarget.id, {
...configTarget.meta,
onSelect(visibleConfigTarget.id, {
...visibleConfigTarget.meta,
config,
})
}
loadedConfig={
value === configTarget.id &&
(activeGgufVariant ?? null) === (configTarget.ggufVariant ?? null)
value === visibleConfigTarget.id &&
(activeGgufVariant ?? null) ===
(visibleConfigTarget.ggufVariant ?? null)
? (activeModelConfig ?? null)
: null
}
loadedContextLength={
value === configTarget.id &&
(activeGgufVariant ?? null) === (configTarget.ggufVariant ?? null)
value === visibleConfigTarget.id &&
(activeGgufVariant ?? null) ===
(visibleConfigTarget.ggufVariant ?? null)
? (activeGgufContextLength ?? null)
: null
}
initialConfig={
value === configTarget.id &&
(selectedGgufVariant ?? null) === (configTarget.ggufVariant ?? null)
value === visibleConfigTarget.id &&
(selectedGgufVariant ?? null) ===
(visibleConfigTarget.ggufVariant ?? null)
? (selectedConfig ?? null)
: null
}

View file

@ -14,10 +14,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import {
type BrowseFoldersResponse,
browseFolders,
} from "@/features/chat/api/chat-api";
import { type BrowseFoldersResponse, browseFolders } from "@/features/chat";
import { cn } from "@/lib/utils";
import { ArrowUp02Icon, Folder02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@ -89,47 +86,43 @@ export function FolderBrowser({
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const navigate = useCallback(
(
target: string | undefined,
hidden: boolean,
opts?: { fallbackOnError?: boolean },
) => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
// Forward the signal so cancelled navigation aborts the backend
// enumeration, not just the response.
browseFolders(target, hidden, ctrl.signal)
.then((res) => {
if (ctrl.signal.aborted) return;
setData(res);
setPath(res.current);
})
.catch((err) => {
if (ctrl.signal.aborted) return;
// Surface the error; if the first request (e.g. a bad initialPath)
// fails, fall back to HOME so the modal stays navigable.
const message = err instanceof Error ? err.message : String(err);
setError(message);
if (opts?.fallbackOnError && target !== undefined) {
// Re-issue without a target -> backend defaults to HOME.
// Don't recurse if HOME itself fails (allowlist always has HOME).
queueMicrotask(() => navigate(undefined, hidden));
}
})
.finally(() => {
if (!ctrl.signal.aborted) setLoading(false);
});
},
[],
);
function navigate(
target: string | undefined,
hidden: boolean,
opts?: { fallbackOnError?: boolean },
) {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
// Forward the signal so cancelled navigation aborts the backend
// enumeration, not just the response.
browseFolders(target, hidden, ctrl.signal)
.then((res) => {
if (ctrl.signal.aborted) return;
setData(res);
setPath(res.current);
})
.catch((err) => {
if (ctrl.signal.aborted) return;
// Surface the error; if the first request (e.g. a bad initialPath)
// fails, fall back to HOME so the modal stays navigable.
const message = err instanceof Error ? err.message : String(err);
setError(message);
if (opts?.fallbackOnError && target !== undefined) {
// Re-issue without a target -> backend defaults to HOME.
// Don't recurse if HOME itself fails (allowlist always has HOME).
queueMicrotask(() => navigate(undefined, hidden));
}
})
.finally(() => {
if (!ctrl.signal.aborted) setLoading(false);
});
}
// Fetch only on closed -> open; later navigation is driven by `navigate()`,
// so `path` is deliberately kept out of the dependency list.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
if (!open) return;
// fallbackOnError: recover into HOME if initialPath is bad, rather than
@ -146,7 +139,7 @@ export function FolderBrowser({
const crumbs = useMemo(
() => (data?.current ? splitBreadcrumb(data.current) : []),
[data?.current],
[data],
);
return (

View file

@ -1,12 +1,12 @@
// 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 { DeleteConfirmDialog } from "@/features/hub/catalog/download-card";
import { DeleteConfirmDialog } from "@/features/hub";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { Delete02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useCallback, useState, type ReactNode } from "react";
import { toast } from "@/lib/toast";
import { type ReactNode, useCallback, useState } from "react";
interface ModelDeleteActionProps {
ariaLabel: string;
@ -63,7 +63,8 @@ export function ModelDeleteAction({
disabled={disabled}
className={cn(
"shrink-0 rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive",
disabled && "cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
disabled &&
"cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
buttonClassName,
)}
>

View file

@ -1,12 +1,14 @@
// 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 { subscribeJobListeners } from "@/features/hub/download-manager";
import { UpdateConfirmDialog } from "@/features/hub/catalog/download-card";
import { ggufVariantsMatch } from "@/features/hub/lib/model-identity";
import {
UpdateConfirmDialog,
ggufVariantsMatch,
subscribeJobListeners,
} from "@/features/hub";
import { cn } from "@/lib/utils";
import { RefreshCw } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
interface ModelUpdateActionProps {
@ -42,20 +44,16 @@ export function ModelUpdateAction({
}: ModelUpdateActionProps) {
const [open, setOpen] = useState(false);
// Refresh the caller when this repo+variant's download finishes so the "update available" cue
// clears. A ref keeps the subscription stable across renders.
const onUpdatedRef = useRef(onUpdated);
onUpdatedRef.current = onUpdated;
useEffect(() => {
return subscribeJobListeners("model", repoId, {
onComplete: (completedVariant) => {
const matches = variant
? ggufVariantsMatch(completedVariant, variant)
: !completedVariant;
if (matches) onUpdatedRef.current?.();
if (matches) onUpdated?.();
},
});
}, [repoId, variant]);
}, [onUpdated, repoId, variant]);
const handleConfirm = useCallback(() => {
// Start the re-download and close the dialog; the Downloads panel owns progress + cancel.
@ -83,7 +81,8 @@ export function ModelUpdateAction({
disabled={disabled}
className={cn(
"shrink-0 rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-amber-500/10 hover:text-amber-700 dark:hover:bg-amber-500/15 dark:hover:text-amber-300",
disabled && "cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
disabled &&
"cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
buttonClassName,
)}
>

View file

@ -40,7 +40,8 @@ export function loadedAt(times: ModelLoadTimes, id: string): number {
export function useModelLoadTimes(currentValue?: string): ModelLoadTimes {
const [times, setTimes] = useState<ModelLoadTimes>(() => readLoadTimes());
useEffect(() => {
if (currentValue) setTimes(recordModelLoaded(currentValue));
if (!currentValue) return;
queueMicrotask(() => setTimes(recordModelLoaded(currentValue)));
}, [currentValue]);
return times;
}

View file

@ -10,7 +10,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { usePlatformStore } from "@/config/env";
import { ApiProviderLogo } from "@/features/chat/api-provider-logo";
import { ApiProviderLogo } from "@/features/chat";
import {
type ScanFolderInfo,
addScanFolder,
@ -20,37 +20,36 @@ import {
listRecommendedFolders,
listScanFolders,
removeScanFolder,
} from "@/features/chat/api/chat-api";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
} from "@/features/chat";
import { useChatRuntimeStore } from "@/features/chat";
import type {
CachedGgufRepo,
CachedModelRepo,
GgufVariantDetail,
LocalModelInfo,
} from "@/features/chat/api/chat-api";
import { useChatPickerInventory } from "../../inventory/use-chat-picker-inventory";
import type { GgufVariantDetail } from "@/features/chat/types/api";
import { DotTag } from "@/features/hub/catalog/dot-tag";
} from "@/features/chat";
import {
DotTag,
type HubOption,
HubOptionMenu,
} from "@/features/hub/catalog/hub-option-menu";
import { TransportConflictDialog } from "@/features/hub/catalog/transport-conflict-dialog";
import { TrainIcon } from "@/features/hub/components/train-icon";
import { useHubInfiniteScroll } from "@/features/hub/hooks/use-hub-infinite-scroll";
TrainIcon,
TransportConflictDialog,
useHubInfiniteScroll,
} from "@/features/hub";
import {
type HfModelResult,
type HfSortKey,
useHubModelSearch,
} from "@/features/hub/hooks/use-hub-model-search";
import { useOnlineStatus } from "@/features/hub/hooks/use-online-status";
import { isHiddenModelId } from "@/features/hub/lib/hidden-models";
import { classifyUnslothSupport } from "@/features/hub/lib/unsloth-support";
import { useHfTokenStore } from "@/features/hub/stores/hf-token-store";
} from "@/features/hub";
import {
classifyUnslothSupport,
downloadManager,
isHiddenModelId,
jobKeyOf,
useDownloadManagerStore,
} from "@/features/hub/download-manager";
useHfTokenStore,
useOnlineStatus,
} from "@/features/hub";
import { useDebouncedValue, useGpuInfo } from "@/hooks";
import { extractParamLabel } from "@/lib/model-size";
import { toast } from "@/lib/toast";
@ -83,6 +82,7 @@ import {
useRef,
useState,
} from "react";
import { useChatPickerInventory } from "../../inventory/use-chat-picker-inventory";
import { FolderBrowser } from "./folder-browser";
import {
type ModelCapabilities,
@ -715,8 +715,11 @@ function GgufVariantExpander({
useEffect(() => {
let canceled = false;
setLoading(true);
setError(null);
queueMicrotask(() => {
if (canceled) return;
setLoading(true);
setError(null);
});
listGgufVariants(repoId, hfToken)
.then((res) => {
@ -744,7 +747,7 @@ function GgufVariantExpander({
}, [repoId, refreshKey, hfToken]);
// Covers Unix absolute (/), Windows drive (C:\, D:/), UNC (\\server), relative (./, ../), tilde (~/)
const isLocalPath = /^(\/|\.{1,2}[\\\/]|~[\\\/]|[A-Za-z]:[\\\/]|\\\\)/.test(
const isLocalPath = /^(\/|\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\\\\)/.test(
repoId,
);
@ -986,7 +989,8 @@ function GgufVariantExpander({
This will update{" "}
<span className="font-medium text-foreground">
{repoId} ({v.quant})
</span>{"."}
</span>
{"."}
</>
)
}
@ -1239,9 +1243,7 @@ function hubRepoUrl(id: string | null | undefined): string | undefined {
/** Whether a local model is an MLX build (name hint). MLX runs on Mac only, so
* callers gate visibility on the host being a Mac. */
function localModelIsMlx(m: LocalModelInfo): boolean {
return (
isMlxId(m.id) || isMlxId(m.display_name) || isMlxId(m.model_id ?? "")
);
return isMlxId(m.id) || isMlxId(m.display_name) || isMlxId(m.model_id ?? "");
}
/** Whether a local model matches the format toggle (GGUF detected by name/path). */
@ -1388,12 +1390,14 @@ export function HubModelPicker({
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>>(
() => new Set(),
);
useEffect(() => {
setCollapsedGguf(new Set());
}, [expandQuantizations]);
const [collapsedGgufState, setCollapsedGgufState] = useState<{
expandQuantizations: boolean;
value: Set<string>;
}>(() => ({ expandQuantizations, value: new Set() }));
const collapsedGguf =
collapsedGgufState.expandQuantizations === expandQuantizations
? collapsedGgufState.value
: new Set<string>();
const isGgufExpanded = useCallback(
(id: string) =>
expandQuantizations ? !collapsedGguf.has(id) : expandedGguf === id,
@ -1404,11 +1408,15 @@ export function HubModelPicker({
const toggleGgufExpanded = useCallback(
(id: string) => {
if (expandQuantizations) {
setCollapsedGguf((prev) => {
const next = new Set(prev);
setCollapsedGgufState((prev) => {
const current =
prev.expandQuantizations === expandQuantizations
? prev.value
: new Set<string>();
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
return { expandQuantizations, value: next };
});
} else {
setExpandedGguf((prev) => (prev === id ? null : id));
@ -1617,22 +1625,25 @@ export function HubModelPicker({
// Updates run as managed downloads (Downloads panel: progress + Cancel), not a blocking
// call. The worker pulls only changed blobs, so the cached copy stays usable until done.
const startManagedUpdate = useCallback((repoId: string, variant: string, expectedBytes: number) => {
return downloadManager
.requestStart({
kind: "model",
repoId,
variant,
expectedBytes,
})
.then((outcome) => {
if (outcome === "conflict") {
setUpdateConflictKey(jobKeyOf("model", repoId, variant));
} else if (outcome === "error") {
throw new Error("Failed to start update");
}
});
}, []);
const startManagedUpdate = useCallback(
(repoId: string, variant: string, expectedBytes: number) => {
return downloadManager
.requestStart({
kind: "model",
repoId,
variant,
expectedBytes,
})
.then((outcome) => {
if (outcome === "conflict") {
setUpdateConflictKey(jobKeyOf("model", repoId, variant));
} else if (outcome === "error") {
throw new Error("Failed to start update");
}
});
},
[],
);
const updateGgufVariant = useCallback(
(repoId: string, quant: string, expectedBytes: number) =>
@ -1682,7 +1693,8 @@ export function HubModelPicker({
// Chat-only keeps runnable formats: GGUF anywhere, plus MLX/safetensors
// on Mac (matches the empty Recommended view so search stays consistent).
.filter(
(id) => !chatOnly || isRecommendableFormat(id, isKnownGgufRepo(id), isMac),
(id) =>
!chatOnly || isRecommendableFormat(id, isKnownGgufRepo(id), isMac),
)
.filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id));
// Sort: GGUFs first, then hub models
@ -2033,7 +2045,8 @@ export function HubModelPicker({
// Chat-only keeps runnable formats: GGUF anywhere, plus MLX/safetensors
// on Mac (matches the empty Recommended view so search stays consistent).
.filter(
(id) => !chatOnly || isRecommendableFormat(id, isKnownGgufRepo(id), isMac),
(id) =>
!chatOnly || isRecommendableFormat(id, isKnownGgufRepo(id), isMac),
)
.filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id))
.filter((id) =>
@ -2912,7 +2925,10 @@ export function HubModelPicker({
title="Browse folders on the server"
className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
>
<HugeiconsIcon icon={Folder02Icon} className="size-3.5" />
<HugeiconsIcon
icon={Folder02Icon}
className="size-3.5"
/>
Custom Folders
</button>
<div className="flex items-center gap-0.5">
@ -3184,7 +3200,9 @@ export function HubModelPicker({
ariaLabel={`Inference settings for ${
m.model_id ?? m.display_name
}`}
onConfigure={() => onConfigure(m.id, localModelMeta())}
onConfigure={() =>
onConfigure(m.id, localModelMeta())
}
/>
)}
</div>
@ -3204,7 +3222,9 @@ export function HubModelPicker({
gpuGb={
gpu.available ? gpu.memoryTotalGb : undefined
}
systemRamGb={gpu.systemRamAvailableGb || undefined}
systemRamGb={
gpu.systemRamAvailableGb || undefined
}
/>
)}
</div>
@ -3289,7 +3309,9 @@ export function HubModelPicker({
ariaLabel={`Inference settings for ${
m.model_id ?? m.display_name
}`}
onConfigure={() => onConfigure(m.id, localModelMeta())}
onConfigure={() =>
onConfigure(m.id, localModelMeta())
}
/>
)}
</div>
@ -3309,7 +3331,9 @@ export function HubModelPicker({
gpuGb={
gpu.available ? gpu.memoryTotalGb : undefined
}
systemRamGb={gpu.systemRamAvailableGb || undefined}
systemRamGb={
gpu.systemRamAvailableGb || undefined
}
/>
)}
</div>
@ -3384,7 +3408,9 @@ export function HubModelPicker({
ariaLabel={`Inference settings for ${
m.model_id ?? m.display_name
}`}
onConfigure={() => onConfigure(m.id, localModelMeta())}
onConfigure={() =>
onConfigure(m.id, localModelMeta())
}
/>
)}
</div>
@ -3404,7 +3430,9 @@ export function HubModelPicker({
gpuGb={
gpu.available ? gpu.memoryTotalGb : undefined
}
systemRamGb={gpu.systemRamAvailableGb || undefined}
systemRamGb={
gpu.systemRamAvailableGb || undefined
}
/>
)}
</div>
@ -3486,7 +3514,9 @@ export function HubModelPicker({
gpuGb={
gpu.available ? gpu.memoryTotalGb : undefined
}
systemRamGb={gpu.systemRamAvailableGb || undefined}
systemRamGb={
gpu.systemRamAvailableGb || undefined
}
variantActions={{
onDelete: async (quant) => {
await deleteCachedModel(id, quant);
@ -3546,10 +3576,14 @@ export function HubModelPicker({
}
}}
vramStatus={
isKnownGgufRepo(id) ? null : (vram?.status ?? null)
isKnownGgufRepo(id)
? null
: (vram?.status ?? null)
}
vramEst={isKnownGgufRepo(id) ? undefined : vram?.est}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
gpuGb={
gpu.available ? gpu.memoryTotalGb : undefined
}
onArrowDownIntoChildren={
expandedGguf === id
? () => {
@ -3576,7 +3610,9 @@ export function HubModelPicker({
gpuGb={
gpu.available ? gpu.memoryTotalGb : undefined
}
systemRamGb={gpu.systemRamAvailableGb || undefined}
systemRamGb={
gpu.systemRamAvailableGb || undefined
}
variantActions={{
onDelete: async (quant) => {
await deleteCachedModel(id, quant);
@ -3668,7 +3704,9 @@ export function HubModelPicker({
gpuGb={
gpu.available ? gpu.memoryTotalGb : undefined
}
systemRamGb={gpu.systemRamAvailableGb || undefined}
systemRamGb={
gpu.systemRamAvailableGb || undefined
}
variantActions={{
onDelete: async (quant) => {
await deleteCachedModel(id, quant);

View file

@ -78,7 +78,8 @@ export function PillTabs({
onValueChange(tabs[next].value);
e.currentTarget.parentElement
?.querySelectorAll<HTMLElement>('button[role="tab"]')
[next]?.focus();
.item(next)
?.focus();
}}
onClick={() => onValueChange(tab.value)}
className={cn(

View file

@ -1,8 +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 { looksLikeLocalPath } from "@/features/hub/lib/local-path";
import { useHfTokenStore } from "@/features/hub/stores/hf-token-store";
import { looksLikeLocalPath, useHfTokenStore } from "@/features/hub";
import { useEffect, useState } from "react";
import { fetchModelMaxPositionEmbeddings } from "../api/model-metadata";
import { fetchDefaultChatTemplate } from "../api/templates";

View file

@ -2,6 +2,14 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { ModelSelector } from "./components/model-selector";
export { FolderBrowser } from "./components/model-selector/folder-browser";
export { ModelDeleteAction } from "./components/model-selector/model-delete-action";
export { ModelUpdateAction } from "./components/model-selector/model-update-action";
export { hfModelFitsDevice } from "./components/model-selector/recommended-fit";
export {
NumericValueInput,
snapToStep,
} from "./components/numeric-value-input";
export { SidebarModelConfig } from "./components/sidebar-model-config";
export type {
DeletedModelRef,

View file

@ -5,13 +5,13 @@ import type {
CachedGgufRepo,
CachedModelRepo,
LocalModelInfo,
} from "@/features/chat/api/chat-api";
} from "@/features/chat";
import {
type CachedInventoryRow,
type LocalInventoryRow,
type LocalSource,
useHubInventory,
} from "@/features/hub/inventory";
} from "@/features/hub";
import { useMemo } from "react";
const PICKER_LOCAL_SOURCES: ReadonlySet<LocalSource> = new Set([

View file

@ -5,11 +5,11 @@ import {
normalizeSpeculativeType,
readPersistedSpeculativeType,
useChatRuntimeStore,
} from "@/features/chat/stores/chat-runtime-store";
} from "@/features/chat";
import {
DEFAULT_PER_MODEL_CONFIG,
normalizeMaxSeqLength,
type PerModelConfig,
normalizeMaxSeqLength,
} from "./per-model-config";
function cleanTemplate(value: string | null | undefined): string | null {

View file

@ -4,12 +4,12 @@
import {
normalizeGgufVariantIdentity,
normalizeModelIdentity,
} from "@/features/hub/lib/model-identity";
} from "@/features/hub";
export {
normalizeGgufVariantIdentity,
normalizeModelIdentity,
} from "@/features/hub/lib/model-identity";
} from "@/features/hub";
const MODEL_STORAGE_KEY_PREFIX = "v2:";