feat: replace config summary with model export feature, including export methods, quantization options, and new UI components

This commit is contained in:
shine1i 2026-02-02 11:08:31 +01:00
commit e9857dab0f
21 changed files with 862 additions and 124 deletions

View file

@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(tree:*)",
"Bash(findstr:*)",
"Bash(bun run typecheck:*)",
"mcp__plugin_serena_serena__list_dir",
"Bash(bun x tsc:*)",
"mcp__plugin_perplexity_perplexity__perplexity_ask",
"WebSearch",
"WebFetch(domain:www.npmjs.com)",
"WebFetch(domain:github.com)"
]
}
}

View file

@ -14,6 +14,7 @@
"@fontsource-variable/space-grotesk": "^5.2.10",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/hub": "^2.8.0",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@streamdown/cjk": "^1.0.1",
@ -276,6 +277,10 @@
"@hugeicons/react": ["@hugeicons/react@1.1.4", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-gsc3eZyd2fGqRUThW9+lfjxxsOkz6KNVmRXRgJjP32GL0OnnLJnl3hytKt47CBbiQj2xE2kCw+rnP3UQCThcKw=="],
"@huggingface/hub": ["@huggingface/hub@2.8.0", "", { "dependencies": { "@huggingface/tasks": "^0.19.80" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-eh7lXCrZeNor2YE+2jn2F75/GEzq+TAh81jLTskiqBNcKuBes0I7TIGP1qiDC+rupP765C5vx3XMNxXQVN3N9w=="],
"@huggingface/tasks": ["@huggingface/tasks@0.19.82", "", {}, "sha512-i8TzJb6Zk7KnYRL8unnYRuh/tW7ku3hJtQw972q/ZXvjkr/YIwWAAVxEfXuaj0vtCUbUljamJji6oWVG4+0PLQ=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@ -786,6 +791,8 @@
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="],
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
@ -962,7 +969,7 @@
"electron-to-chromium": ["electron-to-chromium@1.5.278", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
@ -1700,7 +1707,7 @@
"strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
@ -2086,8 +2093,6 @@
"chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@ -2124,6 +2129,8 @@
"ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@ -2146,12 +2153,10 @@
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
@ -2238,8 +2243,6 @@
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@ -2254,14 +2257,10 @@
"motion/framer-motion/motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}

View file

@ -22,6 +22,7 @@
"@fontsource-variable/space-grotesk": "^5.2.10",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/hub": "^2.8.0",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@streamdown/cjk": "^1.0.1",

View file

@ -4,6 +4,7 @@ import { Route as chatRoute } from "./routes/chat";
import { Route as gridTestRoute } from "./routes/grid-test";
import { Route as homeRoute } from "./routes/home";
import { Route as onboardingRoute } from "./routes/onboarding";
import { Route as exportRoute } from "./routes/export";
import { Route as studioRoute } from "./routes/studio";
const routeTree = rootRoute.addChildren([
@ -12,6 +13,7 @@ const routeTree = rootRoute.addChildren([
gridTestRoute,
studioRoute,
chatRoute,
exportRoute,
]);
export const router = createRouter({ routeTree });

View file

@ -0,0 +1,15 @@
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { Route as rootRoute } from "./__root";
const ExportPage = lazy(() =>
import("@/features/export/export-page").then((m) => ({
default: m.ExportPage,
})),
);
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/export",
component: ExportPage,
});

View file

@ -17,7 +17,7 @@ import { useState } from "react";
const NAV_ITEMS = [
{ label: "Studio", href: "/studio", icon: ZapIcon, enabled: true },
{ label: "Evaluate", href: "/evaluate", enabled: false },
{ label: "Export", href: "/export", enabled: false },
{ label: "Export", href: "/export", enabled: true },
{ label: "Chat", href: "/chat", enabled: true },
];

View file

@ -52,7 +52,7 @@ export function SectionCard({
return (
<div
className={cn(
"bg-card corner-squircle rounded-3xl ring-1 ring-foreground/10 flex flex-col gap-5 p-5 relative overflow-hidden transition-all duration-300 ease-in-out",
"bg-card corner-squircle rounded-3xl ring-1 ring-foreground/10 flex flex-col gap-5 p-5 relative transition-all duration-300 ease-in-out",
featured && styles.border,
className,
)}

View file

@ -74,6 +74,7 @@ export const MODELS: ModelOption[] = [
params: "7B",
vram: "~6GB",
context: "4K",
hfRepo: "unsloth/llava-v1.6-mistral-7b",
recommended: true,
},
{
@ -83,6 +84,7 @@ export const MODELS: ModelOption[] = [
params: "13B",
vram: "~10GB",
context: "4K",
hfRepo: "unsloth/llava-v1.6-vicuna-13b",
recommended: true,
},
{
@ -92,10 +94,11 @@ export const MODELS: ModelOption[] = [
params: "7B",
vram: "~6GB",
context: "8K",
hfRepo: "Qwen/Qwen-VL-Chat",
},
// TTS models
{ id: "bark", name: "Bark", type: "tts", params: "1B", recommended: true },
{ id: "xtts-v2", name: "XTTS v2", type: "tts", params: "500M" },
{ id: "bark", name: "Bark", type: "tts", params: "1B", hfRepo: "suno/bark", recommended: true },
{ id: "xtts-v2", name: "XTTS v2", type: "tts", params: "500M", hfRepo: "coqui/XTTS-v2" },
// Embedding models
{
id: "bge-large",
@ -114,6 +117,7 @@ export const MODELS: ModelOption[] = [
params: "8B",
vram: "~6GB",
context: "128K",
hfRepo: "unsloth/Llama-3.1-8B",
recommended: true,
},
{
@ -131,6 +135,7 @@ export const MODELS: ModelOption[] = [
params: "7B",
vram: "~5GB",
context: "32K",
hfRepo: "unsloth/mistral-7b-v0.3",
},
{
id: "qwen2-7b",
@ -163,6 +168,7 @@ export const MODELS: ModelOption[] = [
params: "27B",
vram: "~18GB",
context: "128K",
hfRepo: "unsloth/gemma-3-27b",
recommended: true,
},
];
@ -250,3 +256,10 @@ export const DEFAULT_HYPERPARAMS = {
finetuneMLPModules: true,
targetModules: TARGET_MODULES,
};
export const MODEL_TYPE_TO_HF_TASK: Record<ModelType, string> = {
text: "text-generation",
vision: "image-text-to-text",
tts: "text-to-speech",
embeddings: "feature-extraction",
};

View file

@ -0,0 +1,178 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Switch } from "@/components/ui/switch";
import { ArrowRight01Icon, Key01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
import { EXPORT_METHODS, type ExportMethod } from "../constants";
type Destination = "local" | "hub";
const anim = {
initial: { height: 0, opacity: 0 },
animate: { height: "auto" as const, opacity: 1 },
exit: { height: 0, opacity: 0 },
transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] as const },
};
interface ExportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
checkpoint: string | null;
exportMethod: ExportMethod | null;
quantLevels: string[];
estimatedSize: string;
baseModelName: string;
isAdapter: boolean;
destination: Destination;
onDestinationChange: (v: Destination) => void;
hfUsername: string;
onHfUsernameChange: (v: string) => void;
modelName: string;
onModelNameChange: (v: string) => void;
hfToken: string;
onHfTokenChange: (v: string) => void;
privateRepo: boolean;
onPrivateRepoChange: (v: boolean) => void;
}
export function ExportDialog({
open,
onOpenChange,
checkpoint,
exportMethod,
quantLevels,
estimatedSize,
baseModelName,
isAdapter,
destination,
onDestinationChange,
hfUsername,
onHfUsernameChange,
modelName,
onModelNameChange,
hfToken,
onHfTokenChange,
privateRepo,
onPrivateRepoChange,
}: ExportDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Export Model</DialogTitle>
<DialogDescription>Choose where to save your exported model.</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Button
variant={destination === "local" ? "dark" : "outline"}
onClick={() => onDestinationChange("local")}
className="flex-1"
>
Save Locally
</Button>
<Button
variant={destination === "hub" ? "dark" : "outline"}
onClick={() => onDestinationChange("hub")}
className="flex-1"
>
Push to Hub
</Button>
</div>
<AnimatePresence>
{destination === "hub" && (
<motion.div {...anim} className="overflow-hidden">
<div className="flex flex-col gap-4 px-0.5">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">Username / Org</label>
<Input placeholder="your-username" value={hfUsername} onChange={(e) => onHfUsernameChange(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">Model Name</label>
<Input placeholder="my-model-gguf" value={modelName} onChange={(e) => onModelNameChange(e.target.value)} />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">HF Write Token</label>
<a
href="https://huggingface.co/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[11px] text-emerald-600 hover:text-emerald-700 transition-colors"
>
Get token
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
</a>
</div>
<InputGroup>
<InputGroupAddon>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
</InputGroupAddon>
<InputGroupInput type="password" placeholder="hf_..." value={hfToken} onChange={(e) => onHfTokenChange(e.target.value)} />
</InputGroup>
<p className="text-[11px] text-muted-foreground/70">Leave empty if already logged in via CLI.</p>
</div>
<div className="flex items-center gap-3">
<Switch id="private-repo" size="sm" checked={privateRepo} onCheckedChange={onPrivateRepoChange} />
<label htmlFor="private-repo" className="text-xs font-medium cursor-pointer">Private Repository</label>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Summary */}
<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">{baseModelName}</span>
</div>
<div className="flex justify-between">
<span>{isAdapter ? "Checkpoint" : "Model"}</span>
<span className="font-medium text-foreground">{checkpoint}</span>
</div>
<div className="flex justify-between">
<span>Export Method</span>
<span className="font-medium text-foreground">
{EXPORT_METHODS.find((m) => m.value === exportMethod)?.title}
</span>
</div>
{exportMethod === "gguf" && quantLevels.length > 0 && (
<div className="flex justify-between">
<span>Quantizations</span>
<span className="font-medium text-foreground">{quantLevels.join(", ")}</span>
</div>
)}
<div className="flex justify-between">
<span>Est. size</span>
<span className="font-medium text-foreground">{estimatedSize}</span>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={() => onOpenChange(false)}>Start Export</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,87 @@
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { CheckmarkCircle01Icon, InformationCircleIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { EXPORT_METHODS, type ExportMethod } from "../constants";
interface MethodPickerProps {
value: ExportMethod | null;
onChange: (v: ExportMethod) => void;
}
export function MethodPicker({ value, onChange }: MethodPickerProps) {
return (
<div className="flex flex-col gap-3">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Export Method
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="text-foreground/70 hover:text-foreground">
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>
How your model is packaged for deployment.{" "}
<a href="https://unsloth.ai/docs/basics/inference-and-deployment" target="_blank" rel="noopener noreferrer" className="text-primary underline">Read more</a>
</TooltipContent>
</Tooltip>
</span>
<div className="grid grid-cols-3 gap-3">
{EXPORT_METHODS.map((m) => {
const selected = value === m.value;
return (
<button
key={m.value}
type="button"
onClick={() => onChange(m.value)}
className={cn(
"flex items-start gap-3 rounded-xl p-4 text-left ring-1 transition-all",
selected
? "ring-2 ring-primary bg-primary/5"
: "ring-border hover:-translate-y-0.5 hover:shadow-sm",
)}
>
<div
className={cn(
"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
selected ? "border-primary bg-primary" : "border-muted-foreground/30",
)}
>
{selected && (
<HugeiconsIcon icon={CheckmarkCircle01Icon} className="size-3 text-primary-foreground" />
)}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{m.title}</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 text-foreground/50 hover:text-foreground cursor-help" onClick={(e) => e.stopPropagation()}>
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{m.tooltip}{" "}
<a href={m.value === "gguf" ? "https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf" : "https://unsloth.ai/docs/basics/inference-and-deployment"} target="_blank" rel="noopener noreferrer" className="text-primary underline">Read more</a>
</TooltipContent>
</Tooltip>
{m.badge && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{m.badge}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">{m.description}</span>
</div>
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,84 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { CheckmarkCircle01Icon, InformationCircleIcon, LayersIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { QUANT_OPTIONS } from "../constants";
interface QuantPickerProps {
value: string[];
onChange: (v: string[]) => void;
}
export function QuantPicker({ value, onChange }: QuantPickerProps) {
const toggle = (qv: string) => {
onChange(
value.includes(qv) ? value.filter((q) => q !== qv) : [...value, qv],
);
};
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<HugeiconsIcon icon={LayersIcon} className="size-4 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">Quantization Levels</span>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="text-foreground/70 hover:text-foreground">
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Lower quantization (Q2, Q3) = smaller files but reduced quality. Q4Q5 is a good balance.{" "}
<a href="https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf" target="_blank" rel="noopener noreferrer" className="text-primary underline">Read more</a>
</TooltipContent>
</Tooltip>
<span className="text-[11px] text-muted-foreground/70"> select one or more</span>
</div>
<div className="flex flex-wrap gap-2 py-1 pl-1">
{QUANT_OPTIONS.map((q) => {
const active = value.includes(q.value);
return (
<button
key={q.value}
type="button"
onClick={() => toggle(q.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ring-1 transition-all",
active
? "ring-primary bg-primary/10 text-primary"
: "ring-border text-muted-foreground hover:text-foreground hover:ring-foreground/20",
)}
>
{active && <HugeiconsIcon icon={CheckmarkCircle01Icon} className="size-3" />}
{q.label}
<span className="text-[10px] opacity-60">{q.size}</span>
{q.recommended && !active && (
<span className="rounded-full bg-emerald-100 px-1.5 py-0 text-[9px] font-semibold text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300">
rec
</span>
)}
</button>
);
})}
</div>
{value.length > 0 && (
<div className="flex items-center gap-3">
<span className="text-[11px] text-muted-foreground">
{value.length} selected
</span>
<button
type="button"
onClick={() => onChange([])}
className="text-[11px] text-muted-foreground/70 hover:text-foreground transition-colors"
>
Clear all
</button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,56 @@
import type { TrainingMethod } from "@/types/training";
export type ExportMethod = "merged" | "lora" | "gguf";
export const EXPORT_METHODS: {
value: ExportMethod;
title: string;
description: string;
tooltip: string;
badge?: string;
}[] = [
{ value: "merged", title: "Merged Model", description: "Full 16-bit model ready for inference.", tooltip: "Merges adapter weights into the base model. Best for direct deployment with vLLM or TGI." },
{ value: "lora", title: "LoRA Only", description: "Lightweight adapter files (~100 MB). Needs base model.", tooltip: "Exports only the trained adapter. Pair with the base model at inference time to save storage." },
{ value: "gguf", title: "GGUF / Llama.cpp", description: "Quantized formats for local AI runners.", tooltip: "Converts to GGUF for llama.cpp, Ollama, and other local runners. Pick a quantization level below." },
];
export const QUANT_OPTIONS = [
{ value: "q2_k", label: "Q2_K", size: "~2.5 GB" },
{ value: "iq3_m", label: "IQ3_M", size: "~3.1 GB" },
{ value: "q3_k_m", label: "Q3_K_M", size: "~3.5 GB" },
{ value: "q4_0", label: "Q4_0", size: "~4.1 GB" },
{ value: "q4_k_m", label: "Q4_K_M", size: "~4.8 GB", recommended: true },
{ value: "q5_0", label: "Q5_0", size: "~5.0 GB" },
{ value: "q5_k_m", label: "Q5_K_M", size: "~5.6 GB" },
{ value: "q6_k", label: "Q6_K", size: "~6.6 GB" },
{ value: "q8_0", label: "Q8_0", size: "~8.2 GB" },
{ value: "f16", label: "F16", size: "~14.2 GB" },
];
export function getEstimatedSize(method: ExportMethod | null, quantLevels: string[]) {
const sizeOf = (v: string) => QUANT_OPTIONS.find((q) => q.value === v)?.size ?? "—";
if (method === "gguf" && quantLevels.length > 0) {
if (quantLevels.length === 1) return sizeOf(quantLevels[0]);
const total = quantLevels
.map((q) => Number.parseFloat(sizeOf(q).replace(/[^0-9.]/g, "")))
.reduce((a, b) => a + b, 0);
return `~${total.toFixed(1)} GB (${quantLevels.length} files)`;
}
if (method === "merged") return "~14.2 GB";
if (method === "lora") return "~100 MB";
return "—";
}
export const METHOD_LABELS: Record<TrainingMethod, string> = {
qlora: "QLoRA",
lora: "LoRA",
full: "Full Fine-tune",
};
export const GUIDE_STEPS = [
"Select a training checkpoint to export from",
"Choose an export method based on your use case",
"Pick quantization levels if using GGUF",
"Click Export and choose your destination",
"Test your model and compare outputs in Chat",
];

View file

@ -0,0 +1,230 @@
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { SectionCard } from "@/components/section-card";
import { MODELS } from "@/config/training";
import { useWizardStore } from "@/stores/training";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { InformationCircleIcon, PackageIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
import { useMemo, useState } from "react";
import { ExportDialog } from "./components/export-dialog";
import { MethodPicker } from "./components/method-picker";
import { QuantPicker } from "./components/quant-picker";
import {
type ExportMethod,
GUIDE_STEPS,
METHOD_LABELS,
getEstimatedSize,
} from "./constants";
const anim = {
initial: { height: 0, opacity: 0 },
animate: { height: "auto" as const, opacity: 1 },
exit: { height: 0, opacity: 0 },
transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] as const },
};
export function ExportPage() {
const store = useWizardStore();
const isAdapter = store.trainingMethod === "lora" || store.trainingMethod === "qlora";
const modelInfo = useMemo(
() => MODELS.find((m) => m.id === store.selectedModel),
[store.selectedModel],
);
const checkpoints = useMemo(() => {
if (isAdapter) {
const interval = store.saveSteps > 0 ? store.saveSteps : 100;
const total = store.trainingMetrics?.totalSteps ?? 500;
const entries: { value: string; label: string; detail: string }[] = [];
for (let step = interval; step <= total; step += interval) {
const loss = (1.5 - (step / total) * 0.7 + Math.random() * 0.05).toFixed(2);
entries.push({
value: `checkpoint-${step}`,
label: `checkpoint-${step}`,
detail: step === total ? `Best Loss: ${loss}` : `Loss: ${loss}`,
});
}
return entries.reverse();
}
return [{ value: "final-model", label: "Final Model", detail: "Full fine-tuned weights" }];
}, [isAdapter, store.saveSteps, store.trainingMetrics?.totalSteps]);
const [checkpoint, setCheckpoint] = useState<string | null>(null);
const [exportMethod, setExportMethod] = useState<ExportMethod | null>(null);
const [quantLevels, setQuantLevels] = useState<string[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [destination, setDestination] = useState<"local" | "hub">("local");
const [hfUsername, setHfUsername] = useState("");
const [modelName, setModelName] = useState("");
const [privateRepo, setPrivateRepo] = useState(false);
const handleMethodChange = (method: ExportMethod) => {
setExportMethod(method);
if (method !== "gguf") setQuantLevels([]);
};
const estimatedSize = getEstimatedSize(exportMethod, quantLevels);
const canExport = checkpoint && exportMethod && (exportMethod !== "gguf" || quantLevels.length > 0);
const baseModelName = modelInfo?.name ?? store.selectedModel ?? "—";
return (
<div className="min-h-screen bg-background">
<main className="mx-auto max-w-7xl px-6 py-8">
<div className="mb-8 flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Export Model</h1>
<p className="text-sm text-muted-foreground">Export your fine-tuned model for deployment</p>
</div>
<SectionCard
icon={<HugeiconsIcon icon={PackageIcon} className="size-5" />}
title="Export Configuration"
description="Select checkpoint, method, and quantization"
accent="emerald"
featured
className="shadow-border ring-1 ring-border"
>
{/* Top row: Checkpoint + metadata | Guide */}
<div className="grid grid-cols-2 gap-8">
<div className="flex flex-col gap-4 ">
<div className="flex flex-col gap-2">
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
{isAdapter ? "Checkpoint" : "Model"}
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="text-foreground/70 hover:text-foreground">
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>
Choose a saved checkpoint to export. Lower loss generally means better quality.{" "}
<a href="https://unsloth.ai/docs/basics/inference-and-deployment" target="_blank" rel="noopener noreferrer" className="text-primary underline">Read more</a>
</TooltipContent>
</Tooltip>
</label>
<Select value={checkpoint ?? ""} onValueChange={setCheckpoint}>
<SelectTrigger className="w-full">
<SelectValue placeholder={isAdapter ? "Select a checkpoint…" : "Select model…"} />
</SelectTrigger>
<SelectContent>
{checkpoints.map((cp) => (
<SelectItem key={cp.value} value={cp.value}>
<span className="flex items-center gap-2">
{cp.label}
<span className="text-muted-foreground text-xs">{cp.detail}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-xl bg-muted/50 p-3 flex flex-col gap-2">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Training Info</span>
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Base Model</span>
<span className="font-medium">{baseModelName}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Method</span>
<span className="font-medium">{METHOD_LABELS[store.trainingMethod] ?? store.trainingMethod}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Checkpoints</span>
<span className="font-medium">{checkpoints.length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Epochs</span>
<span className="font-medium">{store.epochs}</span>
</div>
{isAdapter && (
<div className="flex justify-between">
<span className="text-muted-foreground">LoRA Rank</span>
<span className="font-medium">{store.loraRank}</span>
</div>
)}
{modelInfo?.params && (
<div className="flex justify-between">
<span className="text-muted-foreground">Params</span>
<span className="font-medium">{modelInfo.params}</span>
</div>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-2.5">
<span className="text-xs font-medium text-muted-foreground">Quick Guide</span>
<ol className="flex flex-col gap-3">
{GUIDE_STEPS.map((step, i) => (
<li key={step} className="flex items-start gap-2 text-xs text-muted-foreground">
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
{i + 1}
</span>
{step}
</li>
))}
</ol>
</div>
</div>
<MethodPicker value={exportMethod} onChange={handleMethodChange} />
<AnimatePresence>
{exportMethod === "gguf" && (
<motion.div {...anim} className="overflow-hidden">
<QuantPicker value={quantLevels} onChange={setQuantLevels} />
</motion.div>
)}
</AnimatePresence>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<HugeiconsIcon icon={InformationCircleIcon} className="size-3.5" />
<span>Est. size: {estimatedSize} · Free disk space: 120 GB</span>
</div>
<Button disabled={!canExport} onClick={() => setDialogOpen(true)}>
Export Model
</Button>
</div>
</SectionCard>
</main>
<ExportDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
checkpoint={checkpoint}
exportMethod={exportMethod}
quantLevels={quantLevels}
estimatedSize={estimatedSize}
baseModelName={baseModelName}
isAdapter={isAdapter}
destination={destination}
onDestinationChange={setDestination}
hfUsername={hfUsername}
onHfUsernameChange={setHfUsername}
modelName={modelName}
onModelNameChange={setModelName}
hfToken={store.hfToken}
onHfTokenChange={store.setHfToken}
privateRepo={privateRepo}
onPrivateRepoChange={setPrivateRepo}
/>
</div>
);
}

View file

@ -0,0 +1 @@
export { ExportPage } from "./export-page";

View file

@ -1,88 +0,0 @@
import { SectionCard } from "@/components/section-card";
import { Button } from "@/components/ui/button";
import { useWizardStore } from "@/stores/training";
import { Settings02Icon, StopIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
export function ConfigSummarySection() {
const store = useWizardStore();
const items = [
{
section: "Model",
rows: [
["Model", store.selectedModel ?? "—"],
["Type", store.modelType ?? "—"],
["Method", store.trainingMethod],
],
},
{
section: "Dataset",
rows: [
["Source", store.datasetSource],
["Dataset", store.dataset ?? store.uploadedFile ?? "—"],
["Format", store.datasetFormat],
],
},
{
section: "Hyperparams",
rows: [
["Epochs", store.epochs],
["Batch size", store.batchSize],
["Learning rate", store.learningRate],
["Max steps", store.maxSteps],
["Context length", store.contextLength],
["Warmup steps", store.warmupSteps],
],
},
...(store.trainingMethod !== "full"
? [
{
section: "LoRA",
rows: [
["Rank", store.loraRank],
["Alpha", store.loraAlpha],
["Dropout", store.loraDropout],
["Variant", store.loraVariant],
],
},
]
: []),
];
return (
<SectionCard
icon={<HugeiconsIcon icon={Settings02Icon} className="size-5" />}
title="Config"
description="Training configuration"
accent="indigo"
className="lg:col-span-4"
>
<div className="flex flex-col gap-4">
{items.map((group) => (
<div key={group.section} className="flex flex-col gap-1">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.section}
</p>
{group.rows.map(([label, value]) => (
<div key={String(label)} className="flex justify-between text-xs">
<span className="text-muted-foreground">{String(label)}</span>
<span className="font-medium tabular-nums">
{String(value)}
</span>
</div>
))}
</div>
))}
<Button
variant="destructive"
className="mt-2 w-full cursor-pointer"
onClick={() => store.setIsTraining(false)}
>
<HugeiconsIcon icon={StopIcon} className="size-4" /> Stop Training
</Button>
</div>
</SectionCard>
);
}

View file

@ -29,19 +29,6 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
const HF_REPO_MAP: Record<string, string> = {
"llava-1.6-7b": "unsloth/llava-v1.6-mistral-7b",
"llava-1.6-13b": "unsloth/llava-v1.6-vicuna-13b",
"qwen-vl-7b": "Qwen/Qwen-VL-Chat",
bark: "suno/bark",
"xtts-v2": "coqui/XTTS-v2",
"gemma-3-27b": "unsloth/gemma-3-27b",
"llama-3.1-8b": "unsloth/Llama-3.1-8B",
"mistral-7b": "unsloth/mistral-7b-v0.3",
"phi-4": "unsloth/phi-4",
"qwen-2.5-7b": "Qwen/Qwen2.5-7B",
};
const DOT_COLORS = [
"bg-amber-400",
"bg-blue-400",
@ -180,7 +167,7 @@ export function ModelSection() {
placeholder="unsloth/gemma-3-27b"
value={
selectedModel
? (HF_REPO_MAP[selectedModel] ?? selectedModel)
? (MODELS.find((m) => m.id === selectedModel)?.hfRepo ?? selectedModel)
: ""
}
onChange={(e) => setSelectedModel(e.target.value || null)}

View file

@ -1,2 +1,3 @@
// Shared hooks
export {};
export { useDebouncedValue } from "./use-debounced-value";
export { useHfModelSearch } from "./use-hf-model-search";
export { useHfDatasetSearch } from "./use-hf-dataset-search";

View file

@ -0,0 +1,10 @@
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delayMs = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}

View file

@ -0,0 +1,72 @@
import { listDatasets } from "@huggingface/hub";
import { useEffect, useState } from "react";
export interface HfDatasetResult {
id: string;
downloads: number;
likes: number;
}
interface HfSearchState {
results: HfDatasetResult[];
isLoading: boolean;
error: string | null;
}
export function useHfDatasetSearch(
query: string,
options?: { limit?: number; accessToken?: string },
): HfSearchState {
const { limit = 20, accessToken } = options ?? {};
const [state, setState] = useState<HfSearchState>({
results: [],
isLoading: false,
error: null,
});
useEffect(() => {
if (!query.trim()) {
setState({ results: [], isLoading: false, error: null });
return;
}
let cancelled = false;
setState((prev) => ({ ...prev, isLoading: true, error: null }));
(async () => {
try {
const results: HfDatasetResult[] = [];
const iter = listDatasets({
search: { query },
limit,
...(accessToken ? { credentials: { accessToken } } : {}),
});
for await (const ds of iter) {
if (cancelled) return;
results.push({
id: ds.id,
downloads: ds.downloads,
likes: ds.likes,
});
}
if (!cancelled) {
setState({ results, isLoading: false, error: null });
}
} catch (err) {
if (!cancelled) {
setState({
results: [],
isLoading: false,
error: err instanceof Error ? err.message : "Search failed",
});
}
}
})();
return () => {
cancelled = true;
};
}, [query, limit, accessToken]);
return state;
}

View file

@ -0,0 +1,74 @@
import { listModels } from "@huggingface/hub";
import { useEffect, useState } from "react";
export interface HfModelResult {
id: string;
downloads: number;
likes: number;
task?: string;
}
interface HfSearchState {
results: HfModelResult[];
isLoading: boolean;
error: string | null;
}
export function useHfModelSearch(
query: string,
options?: { task?: string; limit?: number; accessToken?: string },
): HfSearchState {
const { task, limit = 20, accessToken } = options ?? {};
const [state, setState] = useState<HfSearchState>({
results: [],
isLoading: false,
error: null,
});
useEffect(() => {
if (!query.trim()) {
setState({ results: [], isLoading: false, error: null });
return;
}
let cancelled = false;
setState((prev) => ({ ...prev, isLoading: true, error: null }));
(async () => {
try {
const results: HfModelResult[] = [];
const iter = listModels({
search: { query, ...(task ? { task } : {}) },
limit,
...(accessToken ? { credentials: { accessToken } } : {}),
});
for await (const model of iter) {
if (cancelled) return;
results.push({
id: model.id,
downloads: model.downloads,
likes: model.likes,
task: model.task,
});
}
if (!cancelled) {
setState({ results, isLoading: false, error: null });
}
} catch (err) {
if (!cancelled) {
setState({
results: [],
isLoading: false,
error: err instanceof Error ? err.message : "Search failed",
});
}
}
})();
return () => {
cancelled = true;
};
}, [query, task, limit, accessToken]);
return state;
}

View file

@ -128,6 +128,7 @@ export interface ModelOption {
params: string;
vram?: string;
context?: string;
hfRepo?: string;
recommended?: boolean;
}