From e9857dab0f545ddcf14f7a45977ca608b4015f9b Mon Sep 17 00:00:00 2001 From: shine1i Date: Mon, 2 Feb 2026 11:08:31 +0100 Subject: [PATCH] feat: replace config summary with model export feature, including export methods, quantization options, and new UI components --- .claude/settings.local.json | 15 ++ studio/frontend/bun.lock | 31 ++- studio/frontend/package.json | 1 + studio/frontend/src/app/router.tsx | 2 + studio/frontend/src/app/routes/export.tsx | 15 ++ studio/frontend/src/components/navbar.tsx | 2 +- .../frontend/src/components/section-card.tsx | 2 +- studio/frontend/src/config/training.ts | 17 +- .../export/components/export-dialog.tsx | 178 ++++++++++++++ .../export/components/method-picker.tsx | 87 +++++++ .../export/components/quant-picker.tsx | 84 +++++++ .../frontend/src/features/export/constants.ts | 56 +++++ .../src/features/export/export-page.tsx | 230 ++++++++++++++++++ studio/frontend/src/features/export/index.ts | 1 + .../sections/config-summary-section.tsx | 88 ------- .../studio/sections/model-section.tsx | 15 +- studio/frontend/src/hooks/index.ts | 5 +- .../frontend/src/hooks/use-debounced-value.ts | 10 + .../src/hooks/use-hf-dataset-search.ts | 72 ++++++ .../frontend/src/hooks/use-hf-model-search.ts | 74 ++++++ studio/frontend/src/types/training.ts | 1 + 21 files changed, 862 insertions(+), 124 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 studio/frontend/src/app/routes/export.tsx create mode 100644 studio/frontend/src/features/export/components/export-dialog.tsx create mode 100644 studio/frontend/src/features/export/components/method-picker.tsx create mode 100644 studio/frontend/src/features/export/components/quant-picker.tsx create mode 100644 studio/frontend/src/features/export/constants.ts create mode 100644 studio/frontend/src/features/export/export-page.tsx create mode 100644 studio/frontend/src/features/export/index.ts delete mode 100644 studio/frontend/src/features/studio/sections/config-summary-section.tsx create mode 100644 studio/frontend/src/hooks/use-debounced-value.ts create mode 100644 studio/frontend/src/hooks/use-hf-dataset-search.ts create mode 100644 studio/frontend/src/hooks/use-hf-model-search.ts 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. + + +
+ + +
+ + + {destination === "hub" && ( + +
+
+
+ + onHfUsernameChange(e.target.value)} /> +
+
+ + onModelNameChange(e.target.value)} /> +
+
+ +
+
+ + + Get token + + +
+ + + + + onHfTokenChange(e.target.value)} /> + +

Leave empty if already logged in via CLI.

+
+ +
+ + +
+
+
+ )} +
+ + {/* 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} +
+
+ + + + + +
+
+ ); +} 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 ( + + ); + })} +
+
+ ); +} 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 ( + + ); + })} +
+ {value.length > 0 && ( +
+ + {value.length} selected + + +
+ )} +
+ ); +} 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 */} +
+
+
+ + +
+ +
+ 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) => ( +
  1. + + {i + 1} + + {step} +
  2. + ))} +
+
+
+ + + + + {exportMethod === "gguf" && ( + + + + )} + + + +
+
+ + Est. size: {estimatedSize} · Free disk space: 120 GB +
+ +
+
+
+ + +
+ ); +} 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)} - -
- ))} -
- ))} - - -
-
- ); -} 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; }