Merge branch 'main' into pip

This commit is contained in:
Daniel Han 2026-06-12 07:37:43 -07:00
commit b712f9f557
3 changed files with 576 additions and 67 deletions

View file

@ -21,7 +21,9 @@ import {
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo, useState } from "react";
import { type KeyboardEvent, useMemo, useState } from "react";
import { Input } from "../ui/input";
import { HubModelPicker, LoraModelPicker } from "./model-selector/pickers";
import type {
DeletedModelRef,
ExternalModelOption,
@ -29,8 +31,6 @@ import type {
ModelOption,
ModelSelectorChangeMeta,
} from "./model-selector/types";
import { HubModelPicker, LoraModelPicker } from "./model-selector/pickers";
import { Input } from "../ui/input";
const PROVIDER_LOGO_EXT: Record<string, "svg" | "png" | "jpg"> = {
openai: "svg",
@ -241,11 +241,55 @@ function ModelSelectorContent({
return "hub";
}, [externalModels, loraModels, value]);
function focusActiveModelOption(root: HTMLElement): boolean {
const option =
root.querySelector<HTMLElement>(
'[role="tabpanel"]:not([hidden]) [data-model-picker-active-option="true"]',
) ??
root.querySelector<HTMLElement>(
'[data-model-picker-active-option="true"]',
) ??
root.querySelector<HTMLElement>(
'[role="tabpanel"]:not([hidden]) [data-model-picker-option]',
) ??
root.querySelector<HTMLElement>(
"[data-model-picker-option]",
);
if (!option) {
return false;
}
option.focus();
return true;
}
function handlePickerEntryKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key !== "ArrowDown") {
return;
}
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const isPickerSearchInput = target.matches(
"[data-model-picker-search-input]",
);
const isTabTrigger = Boolean(target.closest('[role="tab"]'));
if (!isPickerSearchInput && !isTabTrigger) {
return;
}
if (focusActiveModelOption(event.currentTarget)) {
event.preventDefault();
}
}
return (
<PopoverContent
align="start"
alignOffset={10}
data-tour={dataTour}
onKeyDown={handlePickerEntryKeyDown}
className={cn(
"unsloth-model-selector-menu menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 px-3 pt-3 pb-2",
className,

View file

@ -45,9 +45,11 @@ import { FolderBrowser } from "./folder-browser";
import { ModelDeleteAction } from "./model-delete-action";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import {
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useState,
} from "react";
@ -78,7 +80,161 @@ function sortByDownloadRecency<T extends { repo_id: string; last_modified?: numb
/** Lowercase and strip separators for fuzzy search. */
function normalizeForSearch(s: string): string {
return s.toLowerCase().replace(/[\s\-_\.]/g, "");
return s.toLowerCase().replace(/[\s_.-]/g, "");
}
function makeModelOptionKey(section: string, id: string): string {
return `${section}::${id}`;
}
function makeModelOptionChildrenId(optionKey: string): string {
return `model-picker-children-${optionKey.replace(/[^A-Za-z0-9_-]/g, "-")}`;
}
function focusFirstChildOption(optionKey: string): boolean {
const childList = document.getElementById(makeModelOptionChildrenId(optionKey));
const option = childList?.querySelector<HTMLElement>(
"[data-model-picker-option]",
);
if (!option) {
return false;
}
option.focus();
return true;
}
type ModelRowOptionProps = {
id: string;
tabIndex: number;
onFocus: () => void;
onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void;
"data-model-picker-option": true;
"data-model-picker-active-option"?: "true";
"aria-current"?: "true";
};
function useRovingModelList({
label,
optionKeys,
selectedOptionKey,
onNavigatePastStart,
onNavigatePastEnd,
}: {
label: string;
optionKeys: string[];
selectedOptionKey?: string;
onNavigatePastStart?: () => void;
onNavigatePastEnd?: () => void;
}) {
const rawListboxId = useId();
const listboxId = `model-picker-${rawListboxId.replace(/:/g, "")}`;
const [rovingOptionKey, setRovingOptionKey] = useState<string | null>(null);
const preferredOptionKey =
selectedOptionKey && optionKeys.includes(selectedOptionKey)
? selectedOptionKey
: (optionKeys[0] ?? null);
const activeOptionKey =
rovingOptionKey && optionKeys.includes(rovingOptionKey)
? rovingOptionKey
: preferredOptionKey;
const getOptionDomId = useCallback(
(optionKey: string) => {
const index = optionKeys.indexOf(optionKey);
return index === -1 ? undefined : `${listboxId}-option-${index}`;
},
[listboxId, optionKeys],
);
const focusOption = useCallback(
(optionKey: string) => {
const id = getOptionDomId(optionKey);
if (!id) {
return;
}
document.getElementById(id)?.focus();
},
[getOptionDomId],
);
const moveFocus = useCallback(
(
fromOptionKey: string,
direction: "next" | "previous" | "first" | "last",
) => {
if (optionKeys.length === 0) {
return;
}
const currentIndex = optionKeys.indexOf(fromOptionKey);
let nextIndex = currentIndex === -1 ? 0 : currentIndex;
if (direction === "next") {
if (currentIndex >= optionKeys.length - 1) {
onNavigatePastEnd?.();
return;
}
nextIndex = Math.min(optionKeys.length - 1, nextIndex + 1);
} else if (direction === "previous") {
if (currentIndex <= 0) {
onNavigatePastStart?.();
return;
}
nextIndex = Math.max(0, nextIndex - 1);
} else if (direction === "first") {
nextIndex = 0;
} else {
nextIndex = optionKeys.length - 1;
}
const nextOptionKey = optionKeys[nextIndex];
setRovingOptionKey(nextOptionKey);
focusOption(nextOptionKey);
},
[focusOption, onNavigatePastEnd, onNavigatePastStart, optionKeys],
);
const getOptionProps = useCallback(
(optionKey: string, selected: boolean): ModelRowOptionProps => ({
id: getOptionDomId(optionKey) ?? `${listboxId}-option-missing`,
tabIndex: 0,
onFocus: () => {
setRovingOptionKey(optionKey);
},
onKeyDown: (event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
moveFocus(optionKey, "next");
} else if (event.key === "ArrowUp") {
event.preventDefault();
moveFocus(optionKey, "previous");
} else if (event.key === "Home") {
event.preventDefault();
moveFocus(optionKey, "first");
} else if (event.key === "End") {
event.preventDefault();
moveFocus(optionKey, "last");
}
},
"data-model-picker-option": true,
"data-model-picker-active-option":
optionKey === activeOptionKey ? "true" : undefined,
"aria-current": selected ? "true" : undefined,
}),
[activeOptionKey, getOptionDomId, listboxId, moveFocus],
);
return {
activeOptionKey,
focusOption,
getOptionProps,
moveFocus,
listboxProps: {
id: listboxId,
"data-model-picker-list": true,
"aria-label": label,
},
};
}
function ListLabel({
@ -132,6 +288,8 @@ function ModelRow({
vramEst,
gpuGb,
tooltipText,
optionProps,
onArrowDownIntoChildren,
}: {
label: string;
meta?: string | null;
@ -141,6 +299,8 @@ function ModelRow({
vramEst?: number;
gpuGb?: number;
tooltipText?: ReactNode;
optionProps?: ModelRowOptionProps;
onArrowDownIntoChildren?: () => boolean;
}) {
const exceeds = vramStatus === "exceeds";
const showVramTooltip =
@ -157,9 +317,17 @@ function ModelRow({
const content = (
<button
type="button"
{...optionProps}
onKeyDown={(event) => {
if (event.key === "ArrowDown" && onArrowDownIntoChildren?.()) {
event.preventDefault();
return;
}
optionProps?.onKeyDown(event);
}}
onClick={onClick}
className={cn(
"flex w-full items-center gap-2 rounded-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#3a3d44]",
"flex w-full items-center gap-2 rounded-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45 dark:hover:bg-[#3a3d44] dark:focus-visible:bg-[#3a3d44]",
selected && "bg-[#ececec] dark:bg-[#3a3d44]",
)}
>
@ -223,6 +391,9 @@ function GgufVariantExpander({
onSelect,
gpuGb,
systemRamGb,
parentOptionKey,
onNavigatePastStart,
onNavigatePastEnd,
onDeleteVariant,
sourceOverride,
deleteVariantTitle = "Delete cached model?",
@ -234,6 +405,9 @@ function GgufVariantExpander({
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
gpuGb?: number;
systemRamGb?: number;
parentOptionKey?: string;
onNavigatePastStart?: () => void;
onNavigatePastEnd?: () => void;
onDeleteVariant?: (quant: string) => Promise<void> | void;
sourceOverride?: ModelSelectorChangeMeta["source"];
deleteVariantTitle?: string;
@ -356,6 +530,20 @@ function GgufVariantExpander({
});
}, [variants, effectiveRecommended, getGgufFit]);
const variantOptionKeys = useMemo(
() =>
(sortedVariants ?? []).map((variant) =>
makeModelOptionKey("gguf-variant", `${repoId}:${variant.filename}`),
),
[repoId, sortedVariants],
);
const variantList = useRovingModelList({
label: `${repoId} quantizations`,
optionKeys: variantOptionKeys,
onNavigatePastStart,
onNavigatePastEnd,
});
if (loading) {
return (
<div className="flex items-center gap-2 px-5 py-2">
@ -378,7 +566,15 @@ function GgufVariantExpander({
}
return (
<div className="pl-4 border-l-2 border-accent/50 ml-3 my-1">
<div
{...variantList.listboxProps}
id={
parentOptionKey
? makeModelOptionChildrenId(parentOptionKey)
: variantList.listboxProps.id
}
className="pl-4 border-l-2 border-accent/50 ml-3 my-1"
>
<div className="px-2 py-1 flex items-center gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Quantizations
@ -391,15 +587,18 @@ function GgufVariantExpander({
const fit = getGgufFit(v.size_bytes);
const oom = fit === "oom";
const tight = fit === "tight";
const keyBase = `${repoId}:${v.filename}`;
const variantOptionKey = makeModelOptionKey("gguf-variant", keyBase);
return (
<div key={v.filename} className="flex items-center gap-0.5">
<button
type="button"
{...variantList.getOptionProps(variantOptionKey, false)}
onClick={() =>
handleVariantClick(v.quant, v.downloaded, v.size_bytes)
}
className={cn(
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-full px-3 py-1 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#3a3d44]",
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-full px-3 py-1 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45 dark:hover:bg-[#3a3d44] dark:focus-visible:bg-[#3a3d44]",
)}
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
@ -491,6 +690,13 @@ function sortLmStudio(models: LocalModelInfo[]): LocalModelInfo[] {
});
}
function canDeleteLoraModel(model: LoraModelOption): boolean {
const isTraining = model.source === "training";
const isExported = model.source === "exported";
const isExportedGguf = isExported && model.exportType === "gguf";
return (isTraining || isExported) && !isExportedGguf;
}
// ── Hub Model Picker ──────────────────────────────────────────
export function HubModelPicker({
@ -811,6 +1017,90 @@ export function HubModelPicker({
.filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id));
}, [recommendedSet, downloadedSet, results, showHfSection, chatOnly, isKnownGgufRepo]);
const hubOptionKeys = useMemo(() => {
const keys: string[] = [];
if (
cachedReady &&
!downloadedCollapsed &&
(visibleCachedGguf.length > 0 || visibleCachedModelRows.length > 0)
) {
keys.push(
...visibleCachedGguf.map((model) =>
makeModelOptionKey("downloaded-gguf", model.repo_id),
),
);
keys.push(
...visibleCachedModelRows.map((model) =>
makeModelOptionKey("downloaded-model", model.repo_id),
),
);
}
if (showHfSection) {
keys.push(
...filteredRecommendedIds.map((id) =>
makeModelOptionKey("search-recommended", id),
),
);
keys.push(...hfIds.map((id) => makeModelOptionKey("search-hf", id)));
return keys;
}
if (chatOnly) {
keys.push(
...lmStudioModels.map((model) =>
makeModelOptionKey("lm-studio", model.id),
),
);
}
if (!customFoldersCollapsed) {
keys.push(
...customFolderModels.map((model) =>
makeModelOptionKey("custom-folder", model.id),
),
);
}
if (cachedReady && !recommendedCollapsed) {
keys.push(
...visibleRecommendedIds.map((id) =>
makeModelOptionKey("recommended", id),
),
);
}
return keys;
}, [
cachedReady,
chatOnly,
customFolderModels,
customFoldersCollapsed,
downloadedCollapsed,
filteredRecommendedIds,
hfIds,
lmStudioModels,
recommendedCollapsed,
showHfSection,
visibleCachedGguf,
visibleCachedModelRows,
visibleRecommendedIds,
]);
const selectedHubOptionKey = useMemo(
() =>
value
? hubOptionKeys.find((optionKey) => optionKey.endsWith(`::${value}`))
: undefined,
[hubOptionKeys, value],
);
const hubModelList = useRovingModelList({
label: "Hub models",
optionKeys: hubOptionKeys,
selectedOptionKey: selectedHubOptionKey,
});
const metricsById = useMemo(
() =>
new Map(
@ -930,6 +1220,7 @@ export function HubModelPicker({
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search models"
data-model-picker-search-input={true}
className="h-9 border-[#f2f2f2] dark:border-input pl-8 pr-8"
/>
{isLoading && (
@ -937,7 +1228,11 @@ export function HubModelPicker({
)}
</div>
<div ref={scrollRef} className="max-h-64 overflow-y-auto">
<div
ref={scrollRef}
className="max-h-64 overflow-y-auto"
{...hubModelList.listboxProps}
>
<div className="py-1">
{/* First-load spinner only when nothing cached is shown yet. */}
{!cachedReady &&
@ -960,71 +1255,101 @@ export function HubModelPicker({
collapsed={downloadedCollapsed}
onToggle={() => setDownloadedCollapsed((v) => !v)}
>Downloaded</ListLabel>
{!downloadedCollapsed && visibleCachedGguf.map((c) => (
<div key={c.repo_id}>
<ModelRow
label={c.repo_id}
meta={`GGUF · ${formatBytes(c.size_bytes)}`}
selected={value === c.repo_id}
onClick={() =>
setExpandedGguf((prev) =>
prev === c.repo_id ? null : c.repo_id,
)
}
vramStatus={null}
/>
{expandedGguf === c.repo_id && (
<GgufVariantExpander
repoId={c.repo_id}
onSelect={onSelect}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
systemRamGb={
gpu.available ? gpu.systemRamAvailableGb : undefined
}
onDeleteVariant={async (quant) => {
await deleteCachedModel(c.repo_id, quant);
refreshCachedLists();
}}
/>
)}
</div>
))}
{!downloadedCollapsed &&
visibleCachedModelRows.map((c) => (
<div key={c.repo_id} className="flex items-center gap-0.5">
<div className="min-w-0 flex-1">
visibleCachedGguf.map((c) => {
const optionKey = makeModelOptionKey("downloaded-gguf", c.repo_id);
return (
<div key={c.repo_id}>
<ModelRow
label={c.repo_id}
meta={formatBytes(c.size_bytes)}
meta={`GGUF · ${formatBytes(c.size_bytes)}`}
selected={value === c.repo_id}
optionProps={hubModelList.getOptionProps(
optionKey,
value === c.repo_id,
)}
onClick={() =>
onSelect(c.repo_id, {
source: "hub",
isLora: false,
isDownloaded: true,
})
setExpandedGguf((prev) =>
prev === c.repo_id ? null : c.repo_id,
)
}
onArrowDownIntoChildren={
expandedGguf === c.repo_id
? () => {
const focused = focusFirstChildOption(optionKey);
return focused;
}
: undefined
}
vramStatus={null}
/>
{expandedGguf === c.repo_id && (
<GgufVariantExpander
repoId={c.repo_id}
onSelect={onSelect}
parentOptionKey={optionKey}
onNavigatePastStart={() =>
hubModelList.focusOption(optionKey)
}
onNavigatePastEnd={() =>
hubModelList.moveFocus(optionKey, "next")
}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
systemRamGb={
gpu.available ? gpu.systemRamAvailableGb : undefined
}
onDeleteVariant={async (quant) => {
await deleteCachedModel(c.repo_id, quant);
refreshCachedLists();
}}
/>
)}
</div>
<ModelDeleteAction
ariaLabel={`Delete ${c.repo_id}`}
title="Delete cached model?"
description={
<>
This will remove{" "}
<span className="font-medium text-foreground">
{c.repo_id}
</span>{" "}
from disk. You can re-download it later.
</>
}
successMessage={`Deleted ${c.repo_id}`}
onConfirm={() => deleteCachedModel(c.repo_id)}
onDeleted={refreshCachedLists}
/>
</div>
))}
);
})}
{!downloadedCollapsed &&
visibleCachedModelRows.map((c) => {
const optionKey = makeModelOptionKey("downloaded-model", c.repo_id);
return (
<div key={c.repo_id} className="flex items-center gap-0.5">
<div className="min-w-0 flex-1">
<ModelRow
label={c.repo_id}
meta={formatBytes(c.size_bytes)}
selected={value === c.repo_id}
optionProps={hubModelList.getOptionProps(
optionKey,
value === c.repo_id,
)}
onClick={() =>
onSelect(c.repo_id, {
source: "hub",
isLora: false,
isDownloaded: true,
})
}
vramStatus={null}
/>
</div>
<ModelDeleteAction
ariaLabel={`Delete ${c.repo_id}`}
title="Delete cached model?"
description={
<>
This will remove{" "}
<span className="font-medium text-foreground">
{c.repo_id}
</span>{" "}
from disk. You can re-download it later.
</>
}
successMessage={`Deleted ${c.repo_id}`}
onConfirm={() => deleteCachedModel(c.repo_id)}
onDeleted={refreshCachedLists}
/>
</div>
);
})}
</>
) : null}
@ -1033,6 +1358,7 @@ export function HubModelPicker({
<ListLabel>LM Studio</ListLabel>
{lmStudioModels.map((m) => {
const isGguf = isGgufRepo(m.id) || isGgufRepo(m.display_name);
const optionKey = makeModelOptionKey("lm-studio", m.id);
return (
<div key={m.id}>
<ModelRow
@ -1041,6 +1367,10 @@ export function HubModelPicker({
isGguf || m.path.toLowerCase().endsWith(".gguf") ? "GGUF" : "Local"
}
selected={value === m.id}
optionProps={hubModelList.getOptionProps(
optionKey,
value === m.id,
)}
onClick={() => {
if (isGguf) {
setExpandedGguf((prev) =>
@ -1054,12 +1384,27 @@ export function HubModelPicker({
});
}
}}
onArrowDownIntoChildren={
expandedGguf === m.id
? () => {
const focused = focusFirstChildOption(optionKey);
return focused;
}
: undefined
}
vramStatus={null}
/>
{expandedGguf === m.id && (
<GgufVariantExpander
repoId={m.id}
onSelect={onSelect}
parentOptionKey={optionKey}
onNavigatePastStart={() =>
hubModelList.focusOption(optionKey)
}
onNavigatePastEnd={() =>
hubModelList.moveFocus(optionKey, "next")
}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
systemRamGb={
gpu.available ? gpu.systemRamAvailableGb : undefined
@ -1232,12 +1577,17 @@ export function HubModelPicker({
// Single .gguf files (e.g. Ollama blobs) load directly;
// GGUF repos/directories expand to pick a variant.
const isDirectGguf = isGgufFile;
const optionKey = makeModelOptionKey("custom-folder", m.id);
return (
<div key={m.id}>
<ModelRow
label={m.model_id ?? m.display_name}
meta={isGguf ? "GGUF" : "Local"}
selected={value === m.id}
optionProps={hubModelList.getOptionProps(
optionKey,
value === m.id,
)}
onClick={() => {
if (isDirectGguf) {
onSelect(m.id, {
@ -1257,12 +1607,27 @@ export function HubModelPicker({
});
}
}}
onArrowDownIntoChildren={
expandedGguf === m.id
? () => {
const focused = focusFirstChildOption(optionKey);
return focused;
}
: undefined
}
vramStatus={null}
/>
{expandedGguf === m.id && (
<GgufVariantExpander
repoId={m.id}
onSelect={onSelect}
parentOptionKey={optionKey}
onNavigatePastStart={() =>
hubModelList.focusOption(optionKey)
}
onNavigatePastEnd={() =>
hubModelList.moveFocus(optionKey, "next")
}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
systemRamGb={
gpu.available ? gpu.systemRamAvailableGb : undefined
@ -1289,6 +1654,7 @@ export function HubModelPicker({
) : (
visibleRecommendedIds.map((id) => {
const vram = recommendedVramMap.get(id);
const optionKey = makeModelOptionKey("recommended", id);
return (
<div key={id}>
<ModelRow
@ -1299,6 +1665,10 @@ export function HubModelPicker({
: (vram?.detail ?? extractParamLabel(id))
}
selected={value === id}
optionProps={hubModelList.getOptionProps(
optionKey,
value === id,
)}
onClick={() => {
if (isKnownGgufRepo(id)) {
setExpandedGguf((prev) => (prev === id ? null : id));
@ -1311,11 +1681,26 @@ export function HubModelPicker({
}
vramEst={isKnownGgufRepo(id) ? undefined : vram?.est}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
onArrowDownIntoChildren={
expandedGguf === id
? () => {
const focused = focusFirstChildOption(optionKey);
return focused;
}
: undefined
}
/>
{expandedGguf === id && (
<GgufVariantExpander
repoId={id}
onSelect={onSelect}
parentOptionKey={optionKey}
onNavigatePastStart={() =>
hubModelList.focusOption(optionKey)
}
onNavigatePastEnd={() =>
hubModelList.moveFocus(optionKey, "next")
}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
systemRamGb={
gpu.available ? gpu.systemRamAvailableGb : undefined
@ -1342,6 +1727,7 @@ export function HubModelPicker({
<ListLabel icon={<HugeiconsIcon icon={StarIcon} className="size-3" />}>Recommended</ListLabel>
{filteredRecommendedIds.map((id) => {
const vram = recommendedVramMap.get(id);
const optionKey = makeModelOptionKey("search-recommended", id);
return (
<div key={id}>
<ModelRow
@ -1352,6 +1738,10 @@ export function HubModelPicker({
: (vram?.detail ?? extractParamLabel(id))
}
selected={value === id}
optionProps={hubModelList.getOptionProps(
optionKey,
value === id,
)}
onClick={() => {
if (isKnownGgufRepo(id)) {
setExpandedGguf((prev) => (prev === id ? null : id));
@ -1364,11 +1754,26 @@ export function HubModelPicker({
}
vramEst={isKnownGgufRepo(id) ? undefined : vram?.est}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
onArrowDownIntoChildren={
expandedGguf === id
? () => {
const focused = focusFirstChildOption(optionKey);
return focused;
}
: undefined
}
/>
{expandedGguf === id && (
<GgufVariantExpander
repoId={id}
onSelect={onSelect}
parentOptionKey={optionKey}
onNavigatePastStart={() =>
hubModelList.focusOption(optionKey)
}
onNavigatePastEnd={() =>
hubModelList.moveFocus(optionKey, "next")
}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
systemRamGb={
gpu.available ? gpu.systemRamAvailableGb : undefined
@ -1398,6 +1803,7 @@ export function HubModelPicker({
hfIds.map((id) => {
const vram = vramMap.get(id);
const isSearchGguf = isKnownGgufRepo(id);
const optionKey = makeModelOptionKey("search-hf", id);
return (
<div key={id}>
<ModelRow
@ -1408,6 +1814,10 @@ export function HubModelPicker({
: (metricsById.get(id) ?? extractParamLabel(id))
}
selected={value === id}
optionProps={hubModelList.getOptionProps(
optionKey,
value === id,
)}
onClick={() => {
if (isSearchGguf) {
setExpandedGguf((prev) => (prev === id ? null : id));
@ -1420,11 +1830,26 @@ export function HubModelPicker({
}
vramEst={isSearchGguf ? undefined : vram?.est}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
onArrowDownIntoChildren={
expandedGguf === id
? () => {
const focused = focusFirstChildOption(optionKey);
return focused;
}
: undefined
}
/>
{expandedGguf === id && (
<GgufVariantExpander
repoId={id}
onSelect={onSelect}
parentOptionKey={optionKey}
onNavigatePastStart={() =>
hubModelList.focusOption(optionKey)
}
onNavigatePastEnd={() =>
hubModelList.moveFocus(optionKey, "next")
}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
systemRamGb={
gpu.available ? gpu.systemRamAvailableGb : undefined
@ -1516,6 +1941,26 @@ export function LoraModelPicker({
});
}, [normalized, query]);
const loraOptionKeys = useMemo(
() =>
grouped.flatMap(([, adapters]) =>
adapters.map((adapter) => makeModelOptionKey("lora", adapter.id)),
),
[grouped],
);
const selectedLoraOptionKey = useMemo(
() =>
value
? loraOptionKeys.find((optionKey) => optionKey.endsWith(`::${value}`))
: undefined,
[loraOptionKeys, value],
);
const loraModelList = useRovingModelList({
label: "Fine-tuned models",
optionKeys: loraOptionKeys,
selectedOptionKey: selectedLoraOptionKey,
});
return (
<div className="space-y-2">
<div className="relative">
@ -1527,11 +1972,12 @@ export function LoraModelPicker({
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search trained models"
data-model-picker-search-input={true}
className="h-9 border-[#f2f2f2] dark:border-input pl-8"
/>
</div>
<div className="max-h-64 overflow-y-auto">
<div className="max-h-64 overflow-y-auto" {...loraModelList.listboxProps}>
<div className="py-1">
{grouped.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
@ -1549,11 +1995,12 @@ export function LoraModelPicker({
const isMerged = adapter.exportType === "merged";
const isGguf = adapter.exportType === "gguf";
const isExportedGguf = isExported && isGguf;
const canDelete = (isTraining || isExported) && !isExportedGguf;
const canDelete = canDeleteLoraModel(adapter);
const isTrainingFull = isTraining && isMerged;
const isLocalGgufDir =
isLocal &&
(isGgufRepo(adapter.id) || isGgufRepo(adapter.name));
const optionKey = makeModelOptionKey("lora", adapter.id);
const tag = isLocal
? isLocalGgufDir
? "GGUF"
@ -1584,6 +2031,10 @@ export function LoraModelPicker({
label={adapter.name}
meta={meta}
selected={value === adapter.id}
optionProps={loraModelList.getOptionProps(
optionKey,
value === adapter.id,
)}
onClick={() => {
if (isLocalGgufDir || isExportedGguf) {
setExpandedGguf((prev) =>
@ -1611,6 +2062,14 @@ export function LoraModelPicker({
</span>
</>
}
onArrowDownIntoChildren={
expandedGguf === adapter.id
? () => {
const focused = focusFirstChildOption(optionKey);
return focused;
}
: undefined
}
/>
</div>
{canDelete && (
@ -1645,6 +2104,13 @@ export function LoraModelPicker({
<GgufVariantExpander
repoId={adapter.id}
onSelect={onSelect}
parentOptionKey={optionKey}
onNavigatePastStart={() =>
loraModelList.focusOption(optionKey)
}
onNavigatePastEnd={() =>
loraModelList.moveFocus(optionKey, "next")
}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
systemRamGb={
gpu.available ? gpu.systemRamAvailableGb : undefined

View file

@ -48,4 +48,3 @@ export function classifyGgufFit(
if (required <= combined) return "partial";
return "oom";
}