diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000000..7802dc0461
--- /dev/null
+++ b/.claude/settings.local.json
@@ -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)"
+ ]
+ }
+}
diff --git a/studio/frontend/bun.lock b/studio/frontend/bun.lock
index e33edd872d..1417bea8db 100644
--- a/studio/frontend/bun.lock
+++ b/studio/frontend/bun.lock
@@ -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=="],
}
}
diff --git a/studio/frontend/package.json b/studio/frontend/package.json
index 5a2f5a3063..e09c04695b 100644
--- a/studio/frontend/package.json
+++ b/studio/frontend/package.json
@@ -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",
diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx
index 95012e1a83..e2771919da 100644
--- a/studio/frontend/src/app/router.tsx
+++ b/studio/frontend/src/app/router.tsx
@@ -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 });
diff --git a/studio/frontend/src/app/routes/export.tsx b/studio/frontend/src/app/routes/export.tsx
new file mode 100644
index 0000000000..93bb83cd98
--- /dev/null
+++ b/studio/frontend/src/app/routes/export.tsx
@@ -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,
+});
diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx
index 3ccb8ef579..6153a74cc3 100644
--- a/studio/frontend/src/components/navbar.tsx
+++ b/studio/frontend/src/components/navbar.tsx
@@ -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 },
];
diff --git a/studio/frontend/src/components/section-card.tsx b/studio/frontend/src/components/section-card.tsx
index aee6fdfa05..1548fd57db 100644
--- a/studio/frontend/src/components/section-card.tsx
+++ b/studio/frontend/src/components/section-card.tsx
@@ -52,7 +52,7 @@ export function SectionCard({
return (
= {
+ text: "text-generation",
+ vision: "image-text-to-text",
+ tts: "text-to-speech",
+ embeddings: "feature-extraction",
+};
diff --git a/studio/frontend/src/features/export/components/export-dialog.tsx b/studio/frontend/src/features/export/components/export-dialog.tsx
new file mode 100644
index 0000000000..1e6ff9962b
--- /dev/null
+++ b/studio/frontend/src/features/export/components/export-dialog.tsx
@@ -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 (
+
+
+
+ Export Model
+ Choose where to save your exported model.
+
+
+
+ onDestinationChange("local")}
+ className="flex-1"
+ >
+ Save Locally
+
+ onDestinationChange("hub")}
+ className="flex-1"
+ >
+ Push to Hub
+
+
+
+
+ {destination === "hub" && (
+
+
+
+
+
+
+
+
+
+
+ onHfTokenChange(e.target.value)} />
+
+
Leave empty if already logged in via CLI.
+
+
+
+
+ Private Repository
+
+
+
+ )}
+
+
+ {/* Summary */}
+
+
+ Base Model
+ {baseModelName}
+
+
+ {isAdapter ? "Checkpoint" : "Model"}
+ {checkpoint}
+
+
+ Export Method
+
+ {EXPORT_METHODS.find((m) => m.value === exportMethod)?.title}
+
+
+ {exportMethod === "gguf" && quantLevels.length > 0 && (
+
+ Quantizations
+ {quantLevels.join(", ")}
+
+ )}
+
+ Est. size
+ {estimatedSize}
+
+
+
+
+ onOpenChange(false)}>Cancel
+ onOpenChange(false)}>Start Export
+
+
+
+ );
+}
diff --git a/studio/frontend/src/features/export/components/method-picker.tsx b/studio/frontend/src/features/export/components/method-picker.tsx
new file mode 100644
index 0000000000..b0052f3069
--- /dev/null
+++ b/studio/frontend/src/features/export/components/method-picker.tsx
@@ -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 (
+
+
+ Export Method
+
+
+
+
+
+
+
+ How your model is packaged for deployment.{" "}
+ Read more
+
+
+
+
+ {EXPORT_METHODS.map((m) => {
+ const selected = value === m.value;
+ return (
+
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",
+ )}
+ >
+
+ {selected && (
+
+ )}
+
+
+
+
{m.title}
+
+
+ e.stopPropagation()}>
+
+
+
+
+ {m.tooltip}{" "}
+ Read more
+
+
+ {m.badge && (
+
+ {m.badge}
+
+ )}
+
+
{m.description}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/studio/frontend/src/features/export/components/quant-picker.tsx b/studio/frontend/src/features/export/components/quant-picker.tsx
new file mode 100644
index 0000000000..e16dbd5494
--- /dev/null
+++ b/studio/frontend/src/features/export/components/quant-picker.tsx
@@ -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 (
+
+
+
+
Quantization Levels
+
+
+
+
+
+
+
+ Lower quantization (Q2, Q3) = smaller files but reduced quality. Q4–Q5 is a good balance.{" "}
+ Read more
+
+
+
— select one or more
+
+
+ {QUANT_OPTIONS.map((q) => {
+ const active = value.includes(q.value);
+ return (
+ 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 && }
+ {q.label}
+ {q.size}
+ {q.recommended && !active && (
+
+ rec
+
+ )}
+
+ );
+ })}
+
+ {value.length > 0 && (
+
+
+ {value.length} selected
+
+ onChange([])}
+ className="text-[11px] text-muted-foreground/70 hover:text-foreground transition-colors"
+ >
+ Clear all
+
+
+ )}
+
+ );
+}
diff --git a/studio/frontend/src/features/export/constants.ts b/studio/frontend/src/features/export/constants.ts
new file mode 100644
index 0000000000..b289372f05
--- /dev/null
+++ b/studio/frontend/src/features/export/constants.ts
@@ -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
= {
+ 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",
+];
diff --git a/studio/frontend/src/features/export/export-page.tsx b/studio/frontend/src/features/export/export-page.tsx
new file mode 100644
index 0000000000..0ba0c817a8
--- /dev/null
+++ b/studio/frontend/src/features/export/export-page.tsx
@@ -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(null);
+ const [exportMethod, setExportMethod] = useState(null);
+ const [quantLevels, setQuantLevels] = useState([]);
+ 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 (
+
+
+
+
Export Model
+
Export your fine-tuned model for deployment
+
+
+ }
+ title="Export Configuration"
+ description="Select checkpoint, method, and quantization"
+ accent="emerald"
+ featured
+ className="shadow-border ring-1 ring-border"
+ >
+ {/* Top row: Checkpoint + metadata | Guide */}
+
+
+
+
+ {isAdapter ? "Checkpoint" : "Model"}
+
+
+
+
+
+
+
+ Choose a saved checkpoint to export. Lower loss generally means better quality.{" "}
+ Read more
+
+
+
+
+
+
+
+
+ {checkpoints.map((cp) => (
+
+
+ {cp.label}
+ {cp.detail}
+
+
+ ))}
+
+
+
+
+
+
Training Info
+
+
+ Base Model
+ {baseModelName}
+
+
+ Method
+ {METHOD_LABELS[store.trainingMethod] ?? store.trainingMethod}
+
+
+ Checkpoints
+ {checkpoints.length}
+
+
+ Epochs
+ {store.epochs}
+
+ {isAdapter && (
+
+ LoRA Rank
+ {store.loraRank}
+
+ )}
+ {modelInfo?.params && (
+
+ Params
+ {modelInfo.params}
+
+ )}
+
+
+
+
+
+
Quick Guide
+
+ {GUIDE_STEPS.map((step, i) => (
+
+
+ {i + 1}
+
+ {step}
+
+ ))}
+
+
+
+
+
+
+
+ {exportMethod === "gguf" && (
+
+
+
+ )}
+
+
+
+
+
+
+ Est. size: {estimatedSize} · Free disk space: 120 GB
+
+
setDialogOpen(true)}>
+ Export Model
+
+
+
+
+
+
+
+ );
+}
diff --git a/studio/frontend/src/features/export/index.ts b/studio/frontend/src/features/export/index.ts
new file mode 100644
index 0000000000..320f8b6d2e
--- /dev/null
+++ b/studio/frontend/src/features/export/index.ts
@@ -0,0 +1 @@
+export { ExportPage } from "./export-page";
diff --git a/studio/frontend/src/features/studio/sections/config-summary-section.tsx b/studio/frontend/src/features/studio/sections/config-summary-section.tsx
deleted file mode 100644
index db85fdc11d..0000000000
--- a/studio/frontend/src/features/studio/sections/config-summary-section.tsx
+++ /dev/null
@@ -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 (
- }
- title="Config"
- description="Training configuration"
- accent="indigo"
- className="lg:col-span-4"
- >
-
- {items.map((group) => (
-
-
- {group.section}
-
- {group.rows.map(([label, value]) => (
-
- {String(label)}
-
- {String(value)}
-
-
- ))}
-
- ))}
-
-
store.setIsTraining(false)}
- >
- Stop Training
-
-
-
- );
-}
diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx
index 0fff945c8f..f9e5018779 100644
--- a/studio/frontend/src/features/studio/sections/model-section.tsx
+++ b/studio/frontend/src/features/studio/sections/model-section.tsx
@@ -29,19 +29,6 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
-const HF_REPO_MAP: Record = {
- "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)}
diff --git a/studio/frontend/src/hooks/index.ts b/studio/frontend/src/hooks/index.ts
index f3adbbdac9..0e981fab4f 100644
--- a/studio/frontend/src/hooks/index.ts
+++ b/studio/frontend/src/hooks/index.ts
@@ -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";
diff --git a/studio/frontend/src/hooks/use-debounced-value.ts b/studio/frontend/src/hooks/use-debounced-value.ts
new file mode 100644
index 0000000000..860d5181a4
--- /dev/null
+++ b/studio/frontend/src/hooks/use-debounced-value.ts
@@ -0,0 +1,10 @@
+import { useEffect, useState } from "react";
+
+export function useDebouncedValue(value: T, delayMs = 300): T {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const id = setTimeout(() => setDebounced(value), delayMs);
+ return () => clearTimeout(id);
+ }, [value, delayMs]);
+ return debounced;
+}
diff --git a/studio/frontend/src/hooks/use-hf-dataset-search.ts b/studio/frontend/src/hooks/use-hf-dataset-search.ts
new file mode 100644
index 0000000000..727b6c1442
--- /dev/null
+++ b/studio/frontend/src/hooks/use-hf-dataset-search.ts
@@ -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({
+ 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;
+}
diff --git a/studio/frontend/src/hooks/use-hf-model-search.ts b/studio/frontend/src/hooks/use-hf-model-search.ts
new file mode 100644
index 0000000000..fe0cc358e7
--- /dev/null
+++ b/studio/frontend/src/hooks/use-hf-model-search.ts
@@ -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({
+ 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;
+}
diff --git a/studio/frontend/src/types/training.ts b/studio/frontend/src/types/training.ts
index dfee530f96..9688ec1ed4 100644
--- a/studio/frontend/src/types/training.ts
+++ b/studio/frontend/src/types/training.ts
@@ -128,6 +128,7 @@ export interface ModelOption {
params: string;
vram?: string;
context?: string;
+ hfRepo?: string;
recommended?: boolean;
}