refactor: simplify model and dataset combobox logic, remove curated items, and streamline search handling across components

This commit is contained in:
shine1i 2026-02-02 13:16:08 +01:00
commit 45df407b78
9 changed files with 139 additions and 462 deletions

View file

@ -4,7 +4,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
delayDuration = 400,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (

View file

@ -32,7 +32,6 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DATASETS } from "@/config/training";
import {
useDebouncedValue,
useHfDatasetSearch,
@ -86,88 +85,24 @@ export function DatasetStep() {
);
const [inputValue, setInputValue] = useState("");
const selectingRef = useRef(false);
const debouncedQuery = useDebouncedValue(inputValue);
const {
results: hfResults,
isLoading,
isLoadingMore,
hasMore,
fetchMore,
} = useHfDatasetSearch(debouncedQuery, {
accessToken: hfToken || undefined,
});
const curatedDatasets = useMemo(
() =>
[...DATASETS].sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
),
[],
);
const datasetMap = useMemo(() => {
const map = new Map<
string,
{
label: string;
description?: string;
size?: string;
totalExamples?: number;
sizeCategory?: string;
downloads?: number;
recommended?: boolean;
}
>();
for (const d of curatedDatasets) {
map.set(d.id, {
label: d.name,
description: d.description,
size: d.size,
recommended: d.recommended,
});
}
for (const r of hfResults) {
if (!map.has(r.id)) {
map.set(r.id, {
label: r.id,
downloads: r.downloads,
totalExamples: r.totalExamples,
sizeCategory: r.sizeCategory,
});
}
}
return map;
}, [curatedDatasets, hfResults]);
const displayIds = useMemo(() => {
if (!debouncedQuery.trim()) {
return curatedDatasets.map((d) => d.id);
}
const q = debouncedQuery.toLowerCase();
const curatedIds = curatedDatasets
.filter(
(d) =>
d.name.toLowerCase().includes(q) || d.id.toLowerCase().includes(q),
)
.map((d) => d.id);
const liveIds = hfResults
.map((r) => r.id)
.filter((id) => !curatedIds.includes(id));
return [...curatedIds, ...liveIds];
}, [debouncedQuery, curatedDatasets, hfResults]);
const allIds = useMemo(
() => [
...new Set([
...curatedDatasets.map((d) => d.id),
...hfResults.map((r) => r.id),
]),
],
[curatedDatasets, hfResults],
);
const resultIds = useMemo(() => hfResults.map((r) => r.id), [hfResults]);
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
const { scrollRef, sentinelRef } = useInfiniteScroll(
fetchMore,
hfResults.length,
);
const handleFileUpload = () => {
setUploadedFile("my_dataset.jsonl");
@ -239,13 +174,13 @@ export function DatasetStep() {
<FieldLabel>Search datasets</FieldLabel>
<div ref={comboboxAnchorRef}>
<Combobox
items={allIds}
filteredItems={displayIds}
items={resultIds}
filteredItems={resultIds}
filter={null}
value={dataset}
onValueChange={(id) => setDataset(id)}
onInputValueChange={(val) => setInputValue(val)}
itemToStringValue={(id) => datasetMap.get(id)?.label ?? id}
onValueChange={(id) => { selectingRef.current = true; setDataset(id); }}
onInputValueChange={(val) => { if (selectingRef.current) { selectingRef.current = false; return; } setInputValue(val); }}
itemToStringValue={(id) => id}
autoHighlight={true}
>
<ComboboxInput
@ -259,7 +194,7 @@ export function DatasetStep() {
<ComboboxContent anchor={comboboxAnchorRef}>
{isLoading ? (
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
<Spinner className="size-4" /> Searching
<Spinner className="size-4" /> Searching...
</div>
) : (
<ComboboxEmpty>No datasets found</ComboboxEmpty>
@ -270,13 +205,10 @@ export function DatasetStep() {
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const meta = datasetMap.get(id);
const label = meta?.label ?? id;
const rowLabel =
meta?.size ??
(meta?.totalExamples
? `${formatCompact(meta.totalExamples)} rows`
: null);
const r = hfResults.find((r) => r.id === id);
const detail = r?.totalExamples
? `${formatCompact(r.totalExamples)} rows`
: (r?.sizeCategory ?? null);
return (
<ComboboxItem
key={id}
@ -284,41 +216,32 @@ export function DatasetStep() {
className="justify-between"
>
<Tooltip>
<TooltipTrigger asChild={true}>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="truncate">{label}</span>
{meta?.description && (
<span className="text-xs text-muted-foreground truncate">
{meta.description}
</span>
)}
</div>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 truncate">
{id}
</span>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-xs break-all"
>
{label}
{id}
</TooltipContent>
</Tooltip>
{rowLabel ? (
<Badge variant="outline" className="shrink-0">
{rowLabel}
</Badge>
) : meta?.sizeCategory ? (
{detail ? (
<span className="text-[10px] text-muted-foreground shrink-0">
{meta.sizeCategory}
{detail}
</span>
) : meta?.downloads != null ? (
) : r?.downloads != null ? (
<span className="text-[10px] text-muted-foreground shrink-0">
{formatCompact(meta.downloads)}
{formatCompact(r.downloads)}
</span>
) : null}
</ComboboxItem>
);
}}
</ComboboxList>
{hasMore && <div ref={sentinelRef} className="h-px" />}
<div ref={sentinelRef} className="h-px" />
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />

View file

@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import {
Combobox,
ComboboxContent,
@ -31,7 +30,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MODELS, MODEL_TYPE_TO_HF_TASK } from "@/config/training";
import { MODEL_TYPE_TO_HF_TASK } from "@/config/training";
import {
useDebouncedValue,
useHfModelSearch,
@ -71,92 +70,26 @@ export function ModelSelectionStep() {
);
const [inputValue, setInputValue] = useState("");
const selectingRef = useRef(false);
const debouncedQuery = useDebouncedValue(inputValue);
const task = modelType ? MODEL_TYPE_TO_HF_TASK[modelType] : undefined;
const {
results: hfResults,
isLoading,
isLoadingMore,
hasMore,
fetchMore,
} = useHfModelSearch(debouncedQuery, {
task,
accessToken: hfToken || undefined,
});
const curatedModels = useMemo(() => {
if (!modelType) {
return [];
}
return MODELS.filter((m) => m.type === modelType).sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
);
}, [modelType]);
const resultIds = useMemo(() => hfResults.map((r) => r.id), [hfResults]);
const modelMap = useMemo(() => {
const map = new Map<
string,
{
label: string;
params?: string;
totalParams?: number;
downloads?: number;
recommended?: boolean;
}
>();
for (const m of curatedModels) {
map.set(m.hfRepo ?? m.id, {
label: m.name,
params: m.params,
recommended: m.recommended,
});
}
for (const r of hfResults) {
if (!map.has(r.id)) {
map.set(r.id, {
label: r.id,
downloads: r.downloads,
totalParams: r.totalParams,
});
}
}
return map;
}, [curatedModels, hfResults]);
const displayIds = useMemo(() => {
if (!debouncedQuery.trim()) {
return curatedModels.map((m) => m.hfRepo ?? m.id);
}
const q = debouncedQuery.toLowerCase();
const curatedIds = curatedModels
.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.id.toLowerCase().includes(q) ||
m.hfRepo?.toLowerCase().includes(q),
)
.map((m) => m.hfRepo ?? m.id);
const liveIds = hfResults
.map((r) => r.id)
.filter((id) => !curatedIds.includes(id));
return [...curatedIds, ...liveIds];
}, [debouncedQuery, curatedModels, hfResults]);
const allIds = useMemo(
() => [
...new Set([
...curatedModels.map((m) => m.hfRepo ?? m.id),
...hfResults.map((r) => r.id),
]),
],
[curatedModels, hfResults],
);
const selectedModelData = MODELS.find(
(m) => m.id === selectedModel || m.hfRepo === selectedModel,
);
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
const { scrollRef, sentinelRef } = useInfiniteScroll(
fetchMore,
hfResults.length,
);
return (
<FieldGroup>
@ -219,13 +152,13 @@ export function ModelSelectionStep() {
</FieldLabel>
<div ref={comboboxAnchorRef}>
<Combobox
items={allIds}
filteredItems={displayIds}
items={resultIds}
filteredItems={resultIds}
filter={null}
value={selectedModel}
onValueChange={(id) => setSelectedModel(id)}
onInputValueChange={(val) => setInputValue(val)}
itemToStringValue={(id) => modelMap.get(id)?.label ?? id}
onValueChange={(id) => { selectingRef.current = true; setSelectedModel(id); }}
onInputValueChange={(val) => { if (selectingRef.current) { selectingRef.current = false; return; } setInputValue(val); }}
itemToStringValue={(id) => id}
autoHighlight={true}
>
<ComboboxInput placeholder="Search models..." className="w-full">
@ -247,13 +180,10 @@ export function ModelSelectionStep() {
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const meta = modelMap.get(id);
const label = meta?.label ?? id;
const sizeLabel =
meta?.params ??
(meta?.totalParams
? formatCompact(meta.totalParams)
: null);
const r = hfResults.find((r) => r.id === id);
const sizeLabel = r?.totalParams
? formatCompact(r.totalParams)
: null;
return (
<ComboboxItem
key={id}
@ -263,38 +193,30 @@ export function ModelSelectionStep() {
<Tooltip>
<TooltipTrigger asChild={true}>
<span className="min-w-0 flex-1 truncate">
{label}
{id}
</span>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-xs break-all"
>
{label}
{id}
</TooltipContent>
</Tooltip>
<span className="flex items-center gap-1.5 shrink-0">
{meta?.recommended && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 text-emerald-600 border-emerald-200 dark:border-emerald-800 dark:text-emerald-400"
>
Recommended
</Badge>
)}
{sizeLabel ? (
<Badge variant="outline">{sizeLabel}</Badge>
) : meta?.downloads != null ? (
<span className="text-[10px] text-muted-foreground">
{formatCompact(meta.downloads)}
</span>
) : null}
</span>
{sizeLabel ? (
<span className="text-xs text-muted-foreground shrink-0">
{sizeLabel}
</span>
) : r?.downloads != null ? (
<span className="text-[10px] text-muted-foreground shrink-0">
{formatCompact(r.downloads)}
</span>
) : null}
</ComboboxItem>
);
}}
</ComboboxList>
{hasMore && <div ref={sentinelRef} className="h-px" />}
<div ref={sentinelRef} className="h-px" />
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />
@ -306,7 +228,7 @@ export function ModelSelectionStep() {
</div>
</Field>
{(selectedModelData || selectedModel) && (
{selectedModel && (
<Field>
<div className="flex items-center justify-between">
<div>
@ -340,8 +262,7 @@ export function ModelSelectionStep() {
</Tooltip>
</FieldLabel>
<FieldDescription>
Choose how to fine-tune{" "}
{selectedModelData?.name ?? selectedModel}
Choose how to fine-tune {selectedModel}
</FieldDescription>
</div>
<Select

View file

@ -1,10 +1,5 @@
import { SectionCard } from "@/components/section-card";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Combobox,
ComboboxContent,
@ -27,7 +22,6 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DATASETS } from "@/config/training";
import {
useDebouncedValue,
useHfDatasetSearch,
@ -36,7 +30,6 @@ import {
import { formatCompact } from "@/lib/utils";
import { useWizardStore } from "@/stores/training";
import {
ArrowDown01Icon,
CloudUploadIcon,
Database02Icon,
FileAttachmentIcon,
@ -59,89 +52,26 @@ export function DatasetSection() {
hfToken: s.hfToken,
})),
);
const [recOpen, setRecOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const selectingRef = useRef(false);
const debouncedQuery = useDebouncedValue(inputValue);
const {
results: hfResults,
isLoading,
isLoadingMore,
hasMore,
fetchMore,
} = useHfDatasetSearch(debouncedQuery, {
accessToken: hfToken || undefined,
});
const curatedDatasets = useMemo(
() =>
[...DATASETS].sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
),
[],
);
const datasetMap = useMemo(() => {
const map = new Map<
string,
{
label: string;
description?: string;
size?: string;
totalExamples?: number;
sizeCategory?: string;
downloads?: number;
}
>();
for (const d of curatedDatasets) {
map.set(d.id, {
label: d.name,
description: d.description,
size: d.size,
});
}
for (const r of hfResults) {
if (!map.has(r.id)) {
map.set(r.id, {
label: r.id,
downloads: r.downloads,
totalExamples: r.totalExamples,
sizeCategory: r.sizeCategory,
});
}
}
return map;
}, [curatedDatasets, hfResults]);
const displayIds = useMemo(() => {
if (!debouncedQuery.trim()) {
return curatedDatasets.map((d) => d.id);
}
const q = debouncedQuery.toLowerCase();
const curatedIds = curatedDatasets
.filter(
(d) =>
d.name.toLowerCase().includes(q) || d.id.toLowerCase().includes(q),
)
.map((d) => d.id);
const liveIds = hfResults
.map((r) => r.id)
.filter((id) => !curatedIds.includes(id));
return [...curatedIds, ...liveIds];
}, [debouncedQuery, curatedDatasets, hfResults]);
const allIds = useMemo(
() => [
...new Set([
...curatedDatasets.map((d) => d.id),
...hfResults.map((r) => r.id),
]),
],
[curatedDatasets, hfResults],
);
const resultIds = useMemo(() => hfResults.map((r) => r.id), [hfResults]);
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
const { scrollRef, sentinelRef } = useInfiniteScroll(
fetchMore,
hfResults.length,
);
return (
<SectionCard
@ -184,13 +114,13 @@ export function DatasetSection() {
</span>
<div ref={comboboxAnchorRef}>
<Combobox
items={allIds}
filteredItems={displayIds}
items={resultIds}
filteredItems={resultIds}
filter={null}
value={dataset}
onValueChange={(id) => setDataset(id)}
onInputValueChange={(val) => setInputValue(val)}
itemToStringValue={(id) => datasetMap.get(id)?.label ?? id}
onValueChange={(id) => { selectingRef.current = true; setDataset(id); }}
onInputValueChange={(val) => { if (selectingRef.current) { selectingRef.current = false; return; } setInputValue(val); }}
itemToStringValue={(id) => id}
autoHighlight={true}
>
<ComboboxInput
@ -204,7 +134,7 @@ export function DatasetSection() {
<ComboboxContent anchor={comboboxAnchorRef}>
{isLoading ? (
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
<Spinner className="size-4" /> Searching
<Spinner className="size-4" /> Searching...
</div>
) : (
<ComboboxEmpty>No datasets found</ComboboxEmpty>
@ -215,13 +145,10 @@ export function DatasetSection() {
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const meta = datasetMap.get(id);
const label = meta?.label ?? id;
const rowLabel =
meta?.size ??
(meta?.totalExamples
? `${formatCompact(meta.totalExamples)} rows`
: null);
const r = hfResults.find((r) => r.id === id);
const detail = r?.totalExamples
? `${formatCompact(r.totalExamples)} rows`
: (r?.sizeCategory ?? null);
return (
<ComboboxItem
key={id}
@ -231,34 +158,30 @@ export function DatasetSection() {
<Tooltip>
<TooltipTrigger asChild={true}>
<span className="min-w-0 flex-1 truncate">
{label}
{id}
</span>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-xs break-all"
>
{label}
{id}
</TooltipContent>
</Tooltip>
{rowLabel ? (
<span className="text-xs text-muted-foreground shrink-0">
{rowLabel}
</span>
) : meta?.sizeCategory ? (
{detail ? (
<span className="text-[10px] text-muted-foreground shrink-0">
{meta.sizeCategory}
{detail}
</span>
) : meta?.downloads != null ? (
) : r?.downloads != null ? (
<span className="text-[10px] text-muted-foreground shrink-0">
{formatCompact(meta.downloads)}
{formatCompact(r.downloads)}
</span>
) : null}
</ComboboxItem>
);
}}
</ComboboxList>
{hasMore && <div ref={sentinelRef} className="h-px" />}
<div ref={sentinelRef} className="h-px" />
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />
@ -346,41 +269,6 @@ export function DatasetSection() {
</div>
)}
{/* Recommended */}
<Collapsible open={recOpen} onOpenChange={setRecOpen}>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<HugeiconsIcon
icon={ArrowDown01Icon}
className={`size-3.5 transition-transform ${recOpen ? "rotate-180" : ""}`}
/>
Common Datasets
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 flex flex-col gap-1.5">
{DATASETS.filter((d) => d.recommended).map((d) => (
<button
type="button"
key={d.id}
onClick={() => setDataset(d.id)}
className="flex w-full corner-squircle cursor-pointer items-center gap-2.5 rounded-2xl border bg-muted/30 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/60"
>
<HugeiconsIcon
icon={Database02Icon}
className="size-4 shrink-0 text-muted-foreground"
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium">{d.name}</p>
<p className="text-[10px] text-muted-foreground">
{d.description}
</p>
</div>
<span className="text-[10px] font-mono text-muted-foreground mt-0.5">
{d.size}
</span>
</button>
))}
</CollapsibleContent>
</Collapsible>
{/* Action buttons */}
<div className="grid grid-cols-2 gap-2">
<Button

View file

@ -25,7 +25,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MODELS, MODEL_TYPE_TO_HF_TASK } from "@/config/training";
import { MODEL_TYPE_TO_HF_TASK } from "@/config/training";
import {
useDebouncedValue,
useHfModelSearch,
@ -78,89 +78,26 @@ export function ModelSection() {
);
const [inputValue, setInputValue] = useState("");
const selectingRef = useRef(false);
const debouncedQuery = useDebouncedValue(inputValue);
const task = modelType ? MODEL_TYPE_TO_HF_TASK[modelType] : undefined;
const {
results: hfResults,
isLoading,
isLoadingMore,
hasMore,
fetchMore,
} = useHfModelSearch(debouncedQuery, {
task,
accessToken: hfToken || undefined,
});
const curatedModels = useMemo(() => {
if (!modelType) {
return MODELS;
}
return MODELS.filter((m) => m.type === modelType).sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
);
}, [modelType]);
const modelMap = useMemo(() => {
const map = new Map<
string,
{
label: string;
params?: string;
totalParams?: number;
downloads?: number;
recommended?: boolean;
}
>();
for (const m of curatedModels) {
map.set(m.hfRepo ?? m.id, {
label: m.name,
params: m.params,
recommended: m.recommended,
});
}
for (const r of hfResults) {
if (!map.has(r.id)) {
map.set(r.id, {
label: r.id,
downloads: r.downloads,
totalParams: r.totalParams,
});
}
}
return map;
}, [curatedModels, hfResults]);
const displayIds = useMemo(() => {
if (!debouncedQuery.trim()) {
return curatedModels.map((m) => m.hfRepo ?? m.id);
}
const q = debouncedQuery.toLowerCase();
const curatedIds = curatedModels
.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.id.toLowerCase().includes(q) ||
m.hfRepo?.toLowerCase().includes(q),
)
.map((m) => m.hfRepo ?? m.id);
const liveIds = hfResults
.map((r) => r.id)
.filter((id) => !curatedIds.includes(id));
return [...curatedIds, ...liveIds];
}, [debouncedQuery, curatedModels, hfResults]);
const allIds = useMemo(
() => [
...new Set([
...curatedModels.map((m) => m.hfRepo ?? m.id),
...hfResults.map((r) => r.id),
]),
],
[curatedModels, hfResults],
);
const resultIds = useMemo(() => hfResults.map((r) => r.id), [hfResults]);
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
const { scrollRef, sentinelRef } = useInfiniteScroll(
fetchMore,
hfResults.length,
);
return (
<SectionCard
@ -200,14 +137,7 @@ export function ModelSection() {
</InputGroupAddon>
<InputGroupInput
placeholder="./models/my-model"
value={
selectedModel
? (MODELS.find(
(m) =>
m.id === selectedModel || m.hfRepo === selectedModel,
)?.hfRepo ?? selectedModel)
: ""
}
value={selectedModel ?? ""}
onChange={(e) => setSelectedModel(e.target.value || null)}
/>
</InputGroup>
@ -244,13 +174,13 @@ export function ModelSection() {
</span>
<div ref={comboboxAnchorRef}>
<Combobox
items={allIds}
filteredItems={displayIds}
items={resultIds}
filteredItems={resultIds}
filter={null}
value={selectedModel}
onValueChange={(id) => setSelectedModel(id)}
onInputValueChange={(val) => setInputValue(val)}
itemToStringValue={(id) => modelMap.get(id)?.label ?? id}
onValueChange={(id) => { selectingRef.current = true; setSelectedModel(id); }}
onInputValueChange={(val) => { if (selectingRef.current) { selectingRef.current = false; return; } setInputValue(val); }}
itemToStringValue={(id) => id}
autoHighlight={true}
>
<ComboboxInput placeholder="Search models..." className="w-full">
@ -272,13 +202,10 @@ export function ModelSection() {
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const meta = modelMap.get(id);
const label = meta?.label ?? id;
const sizeLabel =
meta?.params ??
(meta?.totalParams
? formatCompact(meta.totalParams)
: null);
const r = hfResults.find((r) => r.id === id);
const sizeLabel = r?.totalParams
? formatCompact(r.totalParams)
: null;
return (
<ComboboxItem
key={id}
@ -288,30 +215,30 @@ export function ModelSection() {
<Tooltip>
<TooltipTrigger asChild={true}>
<span className="min-w-0 flex-1 truncate">
{label}
{id}
</span>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-xs break-all"
>
{label}
{id}
</TooltipContent>
</Tooltip>
{sizeLabel ? (
<span className="text-xs text-muted-foreground shrink-0">
{sizeLabel}
</span>
) : meta?.downloads != null ? (
) : r?.downloads != null ? (
<span className="text-[10px] text-muted-foreground shrink-0">
{formatCompact(meta.downloads)}
{formatCompact(r.downloads)}
</span>
) : null}
</ComboboxItem>
);
}}
</ComboboxList>
{hasMore && <div ref={sentinelRef} className="h-px" />}
<div ref={sentinelRef} className="h-px" />
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />

View file

@ -76,12 +76,12 @@ export function useHfDatasetSearch(
const createIter = useCallback(
() =>
listDatasets({
search: { query },
search: query.trim() ? { query } : {},
additionalFields: ["cardData"],
...(accessToken ? { credentials: { accessToken } } : {}),
}) as AsyncGenerator<unknown>,
[query, accessToken],
);
return useHfPaginatedSearch(query, createIter, mapDataset);
return useHfPaginatedSearch(createIter, mapDataset);
}

View file

@ -10,13 +10,30 @@ export interface HfModelResult {
totalParams?: number;
}
function mapModel(raw: unknown): HfModelResult {
const EXCLUDED_TAGS = new Set([
"gguf",
"gptq",
"awq",
"exl2",
"mlx",
"onnx",
"openvino",
"coreml",
"tflite",
"ctranslate2",
]);
function mapModel(raw: unknown): HfModelResult | null {
const m = raw as {
name: string;
downloads: number;
likes: number;
safetensors?: { total: number };
tags?: string[];
};
if (m.tags?.some((t) => EXCLUDED_TAGS.has(t))) {
return null;
}
return {
id: m.name,
downloads: m.downloads,
@ -34,12 +51,16 @@ export function useHfModelSearch(
const createIter = useCallback(
() =>
listModels({
search: { query, ...(task ? { task } : {}) },
additionalFields: ["safetensors"],
search: {
...(query.trim() ? { query } : {}),
tags: ["transformers"],
...(task ? { task } : {}),
},
additionalFields: ["safetensors", "tags"],
...(accessToken ? { credentials: { accessToken } } : {}),
}) as AsyncGenerator<unknown>,
[query, task, accessToken],
);
return useHfPaginatedSearch(query, createIter, mapModel);
return useHfPaginatedSearch(createIter, mapModel);
}

View file

@ -19,24 +19,26 @@ const BATCH = 20;
async function pullBatch<T>(
iter: AsyncGenerator<unknown>,
mapItem: (raw: unknown) => T,
mapItem: (raw: unknown) => T | null,
size: number,
) {
const items: T[] = [];
for (let i = 0; i < size; i++) {
while (items.length < size) {
const result = await iter.next();
if (result.done) {
return { items, done: true };
}
items.push(mapItem(result.value));
const mapped = mapItem(result.value);
if (mapped !== null) {
items.push(mapped);
}
}
return { items, done: false };
}
export function useHfPaginatedSearch<T>(
query: string,
createIter: () => AsyncGenerator<unknown>,
mapItem: (raw: unknown) => T,
mapItem: (raw: unknown) => T | null,
): HfPaginatedState<T> & { fetchMore: () => void } {
const [state, setState] = useState<HfPaginatedState<T>>(
INITIAL as HfPaginatedState<T>,
@ -51,11 +53,6 @@ export function useHfPaginatedSearch<T>(
const v = ++versionRef.current;
iterRef.current = null;
if (!query.trim()) {
setState(INITIAL as HfPaginatedState<T>);
return;
}
setState((prev) => ({
...prev,
results: [],
@ -92,7 +89,7 @@ export function useHfPaginatedSearch<T>(
error: err instanceof Error ? err.message : "Search failed",
});
});
}, [query, createIter, mapItem]);
}, [createIter, mapItem]);
const fetchMore = useCallback(() => {
const iter = iterRef.current;

View file

@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
export function useInfiniteScroll(fetchMore: () => void) {
export function useInfiniteScroll(fetchMore: () => void, itemCount: number) {
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
@ -19,7 +19,7 @@ export function useInfiniteScroll(fetchMore: () => void) {
);
obs.observe(el);
return () => obs.disconnect();
}, [fetchMore]);
}, [fetchMore, itemCount]);
return { scrollRef, sentinelRef };
}