feat: replace config summary with model export feature, including export methods, quantization options, and new UI components
This commit is contained in:
parent
1179735255
commit
e9857dab0f
21 changed files with 862 additions and 124 deletions
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal 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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
15
studio/frontend/src/app/routes/export.tsx
Normal file
15
studio/frontend/src/app/routes/export.tsx
Normal 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,
|
||||
});
|
||||
|
|
@ -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 },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
178
studio/frontend/src/features/export/components/export-dialog.tsx
Normal file
178
studio/frontend/src/features/export/components/export-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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. Q4–Q5 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>
|
||||
);
|
||||
}
|
||||
56
studio/frontend/src/features/export/constants.ts
Normal file
56
studio/frontend/src/features/export/constants.ts
Normal 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",
|
||||
];
|
||||
230
studio/frontend/src/features/export/export-page.tsx
Normal file
230
studio/frontend/src/features/export/export-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
studio/frontend/src/features/export/index.ts
Normal file
1
studio/frontend/src/features/export/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ExportPage } from "./export-page";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
10
studio/frontend/src/hooks/use-debounced-value.ts
Normal file
10
studio/frontend/src/hooks/use-debounced-value.ts
Normal 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;
|
||||
}
|
||||
72
studio/frontend/src/hooks/use-hf-dataset-search.ts
Normal file
72
studio/frontend/src/hooks/use-hf-dataset-search.ts
Normal 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;
|
||||
}
|
||||
74
studio/frontend/src/hooks/use-hf-model-search.ts
Normal file
74
studio/frontend/src/hooks/use-hf-model-search.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -128,6 +128,7 @@ export interface ModelOption {
|
|||
params: string;
|
||||
vram?: string;
|
||||
context?: string;
|
||||
hfRepo?: string;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue