Fix model picker lint boundaries
This commit is contained in:
parent
f82327c8c1
commit
647d201008
22 changed files with 487 additions and 326 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
60
studio/frontend/src/features/hub/index.ts
Normal file
60
studio/frontend/src/features/hub/index.ts
Normal 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";
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:";
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue