Merge pull request #8 from unslothai/feature/frontendui-export-page-client-rebased

feat: Add export page, HF model/dataset search, PDF/DOCX extraction logic, modern AUI API, and code cleanup
This commit is contained in:
Roland Tannous 2026-02-02 19:55:54 +04:00 committed by GitHub
commit 396b8fb9a4
45 changed files with 2091 additions and 893 deletions

1
.gitignore vendored
View file

@ -25,6 +25,7 @@ models/
# IDE / Editors
.vscode/
.idea/
.claude/
*.swp
*.swo

66
CLAUDE.md Normal file
View file

@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
LLM fine-tuning platform UI (Unsloth-branded). React/TypeScript frontend with a skeletal Python backend. The frontend is the active development focus.
## Commands
All commands run from `frontend/`:
```bash
bun install # install dependencies
bun run dev # start Vite dev server
bun run build # typecheck + production build
bun run typecheck # TypeScript type checking only
bun run lint # ESLint
bun run biome:check # Biome linter + formatter check
bun run biome:fix # Biome auto-fix
```
Package manager is **Bun** (not npm/yarn).
## Architecture
### Frontend (`frontend/src/`)
**Feature-based module architecture** with enforced boundaries:
- `features/` — self-contained feature modules (chat, onboarding, studio)
- `components/ui/` — shadcn/ui primitives (linting/formatting disabled for these)
- `components/assistant-ui/` — AI chat thread components
- `components/layout/` — layout shells (dashboard)
- `stores/` — Zustand stores (training wizard state)
- `config/` — constants (model lists, hyperparameters, env)
- `types/` — shared TypeScript types
- `app/` — router and root layout (TanStack React Router)
### Import Rules (ESLint-enforced)
Cross-feature imports are **prohibited**. Import from feature barrel (`@/features/[name]`), never from internal paths (`@/features/chat/some-component`).
### Key Technology Choices
| Concern | Choice |
|---------|--------|
| Routing | TanStack React Router |
| State | Zustand |
| Styling | Tailwind CSS + shadcn/ui (radix-maia style, HugeIcons) |
| Animation | Framer Motion |
| Chat UI | @assistant-ui/react with streaming |
| Local DB | Dexie (IndexedDB) for chat threads/messages |
| Charts | Recharts |
### Backend (`backend/`)
Placeholder Python structure. Frontend expects an inference server at the URL in `frontend/.env` (`VITE_INFERENCE_URL`) serving POST `/api/chat/generate` with streaming responses.
## Code Style
- Biome handles formatting (2-space indent) and import organization
- `src/components/ui/**` is excluded from Biome linting/formatting (generated shadcn code)
- Path alias: `@` maps to `frontend/src/`
- Prefer KISS and DRY

View file

@ -14,6 +14,7 @@
"@fontsource-variable/space-grotesk": "^5.2.10",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/hub": "^2.8.0",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@streamdown/cjk": "^1.0.1",
@ -34,6 +35,7 @@
"framer-motion": "^11.15.0",
"katex": "^0.16.22",
"lucide-react": "^0.563.0",
"mammoth": "^1.11.0",
"motion": "^12.29.2",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@ -50,6 +52,7 @@
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"tw-shimmer": "^0.4.4",
"unpdf": "^1.4.0",
"zustand": "^5.0.10",
},
"devDependencies": {
@ -276,6 +279,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=="],
@ -708,6 +715,8 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@ -726,7 +735,7 @@
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
@ -740,8 +749,12 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ=="],
"bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
@ -786,6 +799,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=="],
@ -822,6 +837,8 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
@ -948,12 +965,16 @@
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eciesjs": ["eciesjs@0.4.16", "", { "dependencies": { "@ecies/ciphers": "^0.2.4", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-dS5cbA9rA2VR4Ybuvhg6jvdmp46ubLn3E+px8cG/35aEDNclrqoCjg6mt0HYZ/M+OoESS3jSkCrqk1kWAEhWAw=="],
@ -962,7 +983,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=="],
@ -1162,6 +1183,8 @@
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@ -1216,6 +1239,8 @@
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isbot": ["isbot@5.1.33", "", {}, "sha512-P4Hgb5NqswjkI0J1CM6XKXon/sxKY1SuowE7Qx2hrBhIwICFyXy54mfgB5eMHXsbe/eStzzpbIGNOvGmz+dlKg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@ -1244,6 +1269,8 @@
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"katex": ["katex@0.16.28", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@ -1258,6 +1285,8 @@
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
@ -1298,12 +1327,16 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
@ -1478,6 +1511,8 @@
"open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
@ -1490,6 +1525,8 @@
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
@ -1508,6 +1545,8 @@
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
@ -1536,6 +1575,8 @@
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
@ -1582,6 +1623,8 @@
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
@ -1646,6 +1689,8 @@
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@ -1662,6 +1707,8 @@
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shadcn": ["shadcn@3.7.0", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.17.2", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-zOXNAIFclguSYmmoibyXyKiYA6qjEJtXDSvloAMziSREW9Q0R/dLqBUYdb81lOejmZkDYuZApGabbMLH7G8qvQ=="],
@ -1692,6 +1739,8 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
@ -1700,7 +1749,9 @@
"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=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"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=="],
@ -1780,6 +1831,8 @@
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"underscore": ["underscore@1.13.7", "", {}, "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
@ -1802,6 +1855,8 @@
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unpdf": ["unpdf@1.4.0", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-TahIk0xdH/4jh/MxfclzU79g40OyxtP00VnEUZdEkJoYtXAHWLiir6t3FC6z3vDqQTzc2ZHcla6uEiVTNjejuA=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
@ -1868,6 +1923,8 @@
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
"xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
@ -2086,8 +2143,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=="],
@ -2108,6 +2163,8 @@
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
@ -2124,6 +2181,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 +2205,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 +2295,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 +2309,10 @@
"motion/framer-motion/motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}

View file

@ -22,6 +22,7 @@
"@fontsource-variable/space-grotesk": "^5.2.10",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/hub": "^2.8.0",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@streamdown/cjk": "^1.0.1",
@ -42,6 +43,7 @@
"framer-motion": "^11.15.0",
"katex": "^0.16.22",
"lucide-react": "^0.563.0",
"mammoth": "^1.11.0",
"motion": "^12.29.2",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@ -58,6 +60,7 @@
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"tw-shimmer": "^0.4.4",
"unpdf": "^1.4.0",
"zustand": "^5.0.10"
},
"devDependencies": {

View file

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

View file

@ -4,6 +4,7 @@ import {
createRootRoute,
useRouterState,
} from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { Suspense } from "react";
import { AppProvider } from "../provider";
@ -20,9 +21,19 @@ function RootLayout() {
return (
<AppProvider>
{!hideNavbar && <Navbar />}
<Suspense fallback={null}>
<Outlet />
</Suspense>
<AnimatePresence initial={false}>
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.15 }}
className="flex-1"
>
<Suspense fallback={null}>
<Outlet />
</Suspense>
</motion.div>
</AnimatePresence>
</AppProvider>
);
}

View file

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

View file

@ -19,9 +19,7 @@ import {
MessagePrimitive,
SuggestionPrimitive,
ThreadPrimitive,
useComposerRuntime,
useMessageRuntime,
useThreadRuntime,
useAui,
} from "@assistant-ui/react";
import { motion } from "framer-motion";
import {
@ -359,9 +357,7 @@ const UserActionBar: FC = () => {
};
const EditComposer: FC = () => {
const threadRuntime = useThreadRuntime();
const composerRuntime = useComposerRuntime();
const messageRuntime = useMessageRuntime();
const aui = useAui();
return (
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">
@ -379,18 +375,18 @@ const EditComposer: FC = () => {
<Button
size="sm"
onClick={() => {
const newText = composerRuntime.getState().text;
const originalText = messageRuntime.unstable_getCopyText();
const newText = aui.composer().getState().text;
const originalText = aui.message().getCopyText();
if (newText === originalText) {
composerRuntime.cancel();
aui.composer().cancel();
return;
}
if (threadRuntime.getState().isRunning) {
threadRuntime.cancelRun();
if (aui.thread().getState().isRunning) {
aui.thread().cancelRun();
}
composerRuntime.send();
aui.composer().send();
}}
>
Update

View file

@ -5,8 +5,11 @@ import {
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import {
AiChat02Icon,
Analytics01Icon,
ArrowRight01Icon,
Book03Icon,
PackageIcon,
ZapIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@ -16,9 +19,9 @@ 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: "Chat", href: "/chat", enabled: true },
{ label: "Evaluate", href: "/evaluate", icon: Analytics01Icon, enabled: false },
{ label: "Export", href: "/export", icon: PackageIcon, enabled: true },
{ label: "Chat", href: "/chat", icon: AiChat02Icon, enabled: true },
];
export function Navbar() {
@ -37,11 +40,11 @@ export function Navbar() {
<motion.img
src="https://unsloth.ai/cgi/image/unsloth_sticker_no_shadow_ldN4V4iydw00qSIIWDCUv.png?width=96&quality=80&format=auto"
alt="unsloth"
className="size-8"
className="size-10"
animate={{ rotate: logoHovered ? 360 : 0 }}
transition={{ duration: 0.5, ease: [0.165, 0.84, 0.44, 1] }}
/>
<span className="text-xl font-bold tracking-tight font-heading">
<span className="text-2xl font-bold tracking-wide font-heading">
unsloth
</span>
<AnimatePresence>
@ -67,7 +70,7 @@ export function Navbar() {
return (
<span
key={item.href}
className="rounded-full px-4 py-1.5 text-sm font-medium text-muted-foreground/40 cursor-not-allowed"
className="relative rounded-full px-4 py-1.5 text-sm font-medium text-muted-foreground/40 cursor-not-allowed"
>
{item.label}
</span>
@ -78,19 +81,44 @@ export function Navbar() {
key={item.href}
to={item.href}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
"relative rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
active
? "bg-foreground text-background"
? "text-background"
: "text-muted-foreground hover:text-foreground",
)}
>
{"icon" in item && item.icon && (
<HugeiconsIcon
icon={item.icon}
className="size-3.5 inline-block mr-1 -mt-px fill-current"
{active && (
<motion.span
layoutId="nav-pill"
className="absolute inset-0 rounded-full bg-foreground"
transition={{
type: "spring",
stiffness: 500,
damping: 35,
mass: 0.5,
}}
/>
)}
{item.label}
<span className="relative z-10 flex items-center gap-1.5">
<AnimatePresence mode="popLayout">
{active && item.icon && (
<motion.span
key={item.href}
initial={{ width: 0, opacity: 0 }}
animate={{ width: "auto", opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: [0.165, 0.84, 0.44, 1] }}
className="overflow-hidden"
>
<HugeiconsIcon
icon={item.icon}
className="size-3.5 -mt-px fill-current"
/>
</motion.span>
)}
</AnimatePresence>
{item.label}
</span>
</Link>
);
})}

View file

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

View file

@ -0,0 +1,11 @@
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { Loading03Icon } from "@hugeicons/core-free-icons"
function Spinner({ className }: { className?: string }) {
return (
<HugeiconsIcon icon={Loading03Icon} strokeWidth={2} role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} />
)
}
export { Spinner }

View file

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

View file

@ -1,9 +1,8 @@
import type {
DatasetOption,
ModelOption,
ModelType,
StepConfig,
} from "@/types/training";
import type { PipelineType } from "@huggingface/hub";
export const STEPS: StepConfig[] = [
{
@ -65,149 +64,6 @@ export const MODEL_TYPES: ReadonlyArray<{
},
];
export const MODELS: ModelOption[] = [
// Vision models
{
id: "llava-1.6-7b",
name: "LLaVA 1.6 7B",
type: "vision",
params: "7B",
vram: "~6GB",
context: "4K",
recommended: true,
},
{
id: "llava-1.6-13b",
name: "LLaVA 1.6 13B",
type: "vision",
params: "13B",
vram: "~10GB",
context: "4K",
recommended: true,
},
{
id: "qwen-vl-7b",
name: "Qwen-VL 7B",
type: "vision",
params: "7B",
vram: "~6GB",
context: "8K",
},
// TTS models
{ id: "bark", name: "Bark", type: "tts", params: "1B", recommended: true },
{ id: "xtts-v2", name: "XTTS v2", type: "tts", params: "500M" },
// Embedding models
{
id: "bge-large",
name: "BGE Large",
type: "embeddings",
params: "335M",
recommended: true,
},
{ id: "e5-large", name: "E5 Large", type: "embeddings", params: "335M" },
{ id: "gte-large", name: "GTE Large", type: "embeddings", params: "335M" },
// Text models
{
id: "llama-3.1-8b",
name: "Llama 3.1 8B",
type: "text",
params: "8B",
vram: "~6GB",
context: "128K",
recommended: true,
},
{
id: "llama-3.1-70b",
name: "Llama 3.1 70B",
type: "text",
params: "70B",
vram: "~40GB",
context: "128K",
},
{
id: "mistral-7b",
name: "Mistral 7B",
type: "text",
params: "7B",
vram: "~5GB",
context: "32K",
},
{
id: "qwen2-7b",
name: "Qwen2 7B",
type: "text",
params: "7B",
vram: "~5GB",
context: "32K",
},
{
id: "phi-3-mini",
name: "Phi-3 Mini",
type: "text",
params: "3.8B",
vram: "~3GB",
context: "128K",
},
{
id: "gemma-2-9b",
name: "Gemma 2 9B",
type: "text",
params: "9B",
vram: "~7GB",
context: "8K",
},
{
id: "gemma-3-27b",
name: "Gemma 3 27B",
type: "text",
params: "27B",
vram: "~18GB",
context: "128K",
recommended: true,
},
];
export const DATASETS: DatasetOption[] = [
{
id: "alpaca",
name: "Alpaca",
description: "Instruction following dataset",
size: "52K",
recommended: true,
},
{
id: "dolly-15k",
name: "Dolly 15K",
description: "Databricks instruction dataset",
size: "15K",
recommended: true,
},
{
id: "openorca",
name: "OpenOrca",
description: "Large-scale instruct dataset",
size: "4M",
},
{
id: "wizard-lm",
name: "WizardLM",
description: "Complex instruction dataset",
size: "196K",
},
{
id: "lima",
name: "LIMA",
description: "Curated high-quality dataset",
size: "1K",
},
{
id: "sharegpt",
name: "ShareGPT",
description: "Conversation dataset",
size: "90K",
},
];
export const CONTEXT_LENGTHS = [512, 1024, 2048, 4096, 8192, 16384, 32768];
export const TARGET_MODULES = [
@ -250,3 +106,10 @@ export const DEFAULT_HYPERPARAMS = {
finetuneMLPModules: true,
targetModules: TARGET_MODULES,
};
export const MODEL_TYPE_TO_HF_TASK: Record<ModelType, PipelineType> = {
text: "text-generation",
vision: "image-text-to-text",
tts: "text-to-speech",
embeddings: "feature-extraction",
};

View file

@ -6,13 +6,21 @@ type RunMessages = Parameters<ChatModelAdapter["run"]>[0]["messages"];
function makeBody(messages: RunMessages): string {
return JSON.stringify({
messages: messages.map((m) => ({
role: m.role,
content: m.content
messages: messages.map((m) => {
const textParts = m.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join(""),
})),
.map((c) => c.text);
if ("attachments" in m && m.attachments?.length) {
for (const att of m.attachments) {
for (const part of att.content ?? []) {
if (part.type === "text") textParts.push(part.text);
}
}
}
return { role: m.role, content: textParts.join("\n") };
}),
});
}

View file

@ -3,13 +3,23 @@ import {
ModelSelector,
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import { Button } from "@/components/ui/button";
import {
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { Settings04Icon } from "@hugeicons/core-free-icons";
import {
ColumnInsertIcon,
PencilEdit02Icon,
Settings04Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type CSSProperties,
@ -18,7 +28,6 @@ import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@ -38,7 +47,6 @@ import {
import { ThreadSidebar } from "./thread-sidebar";
import type { ChatView } from "./types";
// TODO: fetch from API at runtime
const LORA_MODELS: ModelOption[] = [
{
id: "outputs/llama-3.1-8b-instruct-lora",
@ -75,17 +83,9 @@ const GGUF_MODELS: ModelOption[] = [
},
];
type SingleContentProps = {
threadId?: string;
};
type CompareContentProps = {
pairId: string;
};
const SingleContent = memo(function SingleContent({
threadId,
}: SingleContentProps): ReactElement {
}: { threadId?: string }): ReactElement {
return (
<ChatRuntimeProvider modelType="base" initialThreadId={threadId}>
<div className="min-h-0 flex-1">
@ -97,7 +97,7 @@ const SingleContent = memo(function SingleContent({
const CompareContent = memo(function CompareContent({
pairId,
}: CompareContentProps): ReactElement {
}: { pairId: string }): ReactElement {
const handlesRef = useRef<Record<string, CompareHandle>>({});
const [baseThreadId, setBaseThreadId] = useState<string>();
const [loraThreadId, setLoraThreadId] = useState<string>();
@ -123,9 +123,9 @@ const CompareContent = memo(function CompareContent({
return (
<CompareHandlesProvider handlesRef={handlesRef}>
<div className="flex min-h-0 flex-1 flex-col">
<div className="grid min-h-0 flex-1 grid-cols-2 px-0">
<div className="grid min-h-0 flex-1 grid-cols-2 px-0">
<div className="flex min-h-0 flex-col">
<div className=" px-3 py-1.5">
<div className="px-3 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Base Model
</span>
@ -142,8 +142,8 @@ const CompareContent = memo(function CompareContent({
</div>
</div>
<div className="flex min-h-0 flex-col">
<div className=" text-end px-3 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-primary">
<div className="text-end px-3 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-primary">
Fine-tuned (LoRA)
</span>
</div>
@ -186,7 +186,7 @@ function InlineSidebar({
<aside
data-sidebar="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground h-full overflow-hidden transition-[width] duration-200 ease-linear",
"bg-sidebar text-sidebar-foreground h-full overflow-hidden rounded-2xl corner-squircle transition-[width] duration-200 ease-linear",
!collapsed && (side === "left" ? "border-r border-0 border-sidebar-border" : "border-l border-0 border-sidebar-border"),
collapsed ? "w-0" : "w-(--sidebar-width)",
)}
@ -199,6 +199,34 @@ function InlineSidebar({
);
}
function TopBarActions({
onNewThread,
onNewCompare,
}: { onNewThread: () => void; onNewCompare: () => void }) {
const { state } = useSidebar();
if (state !== "collapsed") return null;
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={onNewThread}>
<HugeiconsIcon icon={PencilEdit02Icon} strokeWidth={2} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">New Chat</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={onNewCompare}>
<HugeiconsIcon icon={ColumnInsertIcon} strokeWidth={2} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Compare</TooltipContent>
</Tooltip>
</>
);
}
export function ChatPage(): ReactElement {
const [view, setView] = useState<ChatView>({ mode: "single" });
const [settingsOpen, setSettingsOpen] = useState(false);
@ -214,18 +242,23 @@ export function ChatPage(): ReactElement {
() => setInferenceParams((p) => ({ ...p, checkpoint: "" })),
[],
);
const models = useMemo(
() =>
inferenceParams.inferenceEngine === "llama-cpp"
? GGUF_MODELS
: LORA_MODELS,
[inferenceParams.inferenceEngine],
const handleNewThread = useCallback(
() => setView({ mode: "single" }),
[],
);
const handleNewCompare = useCallback(
() => setView({ mode: "compare", pairId: crypto.randomUUID() }),
[],
);
const models =
inferenceParams.inferenceEngine === "llama-cpp"
? GGUF_MODELS
: LORA_MODELS;
return (
<SidebarProvider
defaultOpen={false}
defaultOpen={true}
className="!min-h-0 h-[calc(100vh-4rem)] max-w-7xl mx-auto px-4"
style={{ "--sidebar-width": "14rem", "--sidebar-width-icon": "3rem" } as CSSProperties}
>
@ -233,19 +266,19 @@ export function ChatPage(): ReactElement {
<ThreadSidebar
view={view}
onSelect={setView}
onNewThread={() => setView({ mode: "single" })}
onNewCompare={() =>
setView({ mode: "compare", pairId: crypto.randomUUID() })
}
onNewThread={handleNewThread}
onNewCompare={handleNewCompare}
/>
</InlineSidebar>
{/* main chat area */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{/* top bar */}
<div className="flex h-11 shrink-0 items-center px-2">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1">
<SidebarTrigger />
<TopBarActions
onNewThread={handleNewThread}
onNewCompare={handleNewCompare}
/>
<ModelSelector
models={models}
value={inferenceParams.checkpoint}
@ -275,7 +308,6 @@ export function ChatPage(): ReactElement {
)}
</div>
{/* inline settings panel on right */}
<ChatSettingsPanel
open={settingsOpen}
params={inferenceParams}

View file

@ -1,5 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import {
Select,
SelectContent,
@ -23,8 +21,6 @@ import { AnimatePresence, motion } from "motion/react";
import type { ReactNode } from "react";
import { useState } from "react";
// --- Types & defaults ---
export interface InferenceParams {
temperature: number;
topP: number;
@ -33,7 +29,7 @@ export interface InferenceParams {
maxTokens: number;
systemPrompt: string;
inferenceEngine: string;
checkpoint: string; // "" = no checkpoint (base model only)
checkpoint: string;
}
export const defaultInferenceParams: InferenceParams = {
@ -81,8 +77,6 @@ const ENGINE_OPTIONS = [
{ value: "llama-cpp", label: "llama.cpp (GGUF)" },
];
// --- Subcomponents ---
function ParamSlider({
label,
value,
@ -166,8 +160,6 @@ function CollapsibleSection({
);
}
// --- Main panel ---
interface ChatSettingsPanelProps {
open: boolean;
params: InferenceParams;
@ -182,8 +174,9 @@ export function ChatSettingsPanel({
const [presets, setPresets] = useState<Preset[]>(BUILTIN_PRESETS);
const [activePreset, setActivePreset] = useState("Default");
const set = (key: keyof InferenceParams) => (v: number | string) =>
onParamsChange({ ...params, [key]: v });
function set<K extends keyof InferenceParams>(key: K) {
return (v: InferenceParams[K]) => onParamsChange({ ...params, [key]: v });
}
function applyPreset(name: string) {
const p = presets.find((pr) => pr.name === name);
@ -218,10 +211,9 @@ export function ChatSettingsPanel({
return (
<aside
className={`shrink-0 h-full overflow-hidden bg-sidebar transition-[width] duration-200 ease-linear ${open ? "w-[17rem] border-sidebar-border" : "w-0"}`}
className={`shrink-0 h-full overflow-hidden bg-sidebar rounded-2xl corner-squircle transition-[width] duration-200 ease-linear ${open ? "w-[17rem] border-sidebar-border" : "w-0"}`}
>
<div className="flex h-full w-[17rem] flex-col">
{/* header */}
<div className="flex items-center gap-2 px-3 py-2">
<HugeiconsIcon
icon={PencilEdit01Icon}
@ -233,7 +225,6 @@ export function ChatSettingsPanel({
</div>
<div className="flex-1 overflow-y-auto px-1.5">
{/* presets */}
<div className="px-2 pb-3">
<div className="flex items-center gap-2">
<Select value={activePreset} onValueChange={applyPreset}>
@ -279,7 +270,6 @@ export function ChatSettingsPanel({
</div>
</div>
{/* system prompt — always visible */}
<div className="px-2 pb-4">
<label
htmlFor="system-prompt"
@ -297,7 +287,6 @@ export function ChatSettingsPanel({
/>
</div>
{/* inference engine */}
<CollapsibleSection
icon={EngineIcon}
label="Inference Engine"
@ -309,7 +298,7 @@ export function ChatSettingsPanel({
</span>
<Select
value={params.inferenceEngine}
onValueChange={(v) => set("inferenceEngine")(v)}
onValueChange={set("inferenceEngine")}
>
<SelectTrigger className="h-8 w-full text-xs corner-squircle">
<SelectValue />
@ -325,20 +314,19 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
{/* sampling */}
<CollapsibleSection
icon={SlidersHorizontalIcon}
label="Sampling"
defaultOpen={true}
>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-5">
<ParamSlider
label="Temperature"
value={params.temperature}
min={0}
max={2}
step={0.1}
onChange={set("temperature") as (v: number) => void}
onChange={set("temperature")}
/>
<ParamSlider
label="Top P"
@ -346,7 +334,7 @@ export function ChatSettingsPanel({
min={0}
max={1}
step={0.05}
onChange={set("topP") as (v: number) => void}
onChange={set("topP")}
/>
<ParamSlider
label="Top K"
@ -354,7 +342,7 @@ export function ChatSettingsPanel({
min={0}
max={100}
step={1}
onChange={set("topK") as (v: number) => void}
onChange={set("topK")}
/>
<ParamSlider
label="Repetition Penalty"
@ -362,7 +350,7 @@ export function ChatSettingsPanel({
min={1}
max={2}
step={0.05}
onChange={set("repetitionPenalty") as (v: number) => void}
onChange={set("repetitionPenalty")}
/>
<ParamSlider
label="Max Tokens"
@ -370,12 +358,11 @@ export function ChatSettingsPanel({
min={64}
max={4096}
step={64}
onChange={set("maxTokens") as (v: number) => void}
onChange={set("maxTokens")}
/>
</div>
</CollapsibleSection>
{/* settings — empty for now */}
<CollapsibleSection icon={Settings02Icon} label="Settings">
<p className="text-xs text-muted-foreground">
No additional settings yet.

View file

@ -1,151 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import {
Logout01Icon,
Settings04Icon,
SidebarLeft01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
export interface ModelOption {
value: string;
label: string;
}
// TODO: fetch from GET /api/loras at runtime
export const MOCK_CHECKPOINTS: ModelOption[] = [
{
value: "outputs/llama-3.1-8b-instruct-lora",
label: "meta-llama/Llama-3.1-8B-Instruct — LoRA v1",
},
{
value: "outputs/qwen2.5-7b-lora",
label: "Qwen/Qwen2.5-7B-Instruct — LoRA v2",
},
{
value: "outputs/mistral-7b-v0.3-lora",
label: "mistralai/Mistral-7B-Instruct-v0.3 — LoRA v1",
},
];
export const MOCK_GGUFS: ModelOption[] = [
{
value: "models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
label: "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
},
{
value: "models/Qwen2.5-7B-Instruct-Q5_K_M.gguf",
label: "Qwen2.5-7B-Instruct-Q5_K_M.gguf",
},
{
value: "models/Mistral-7B-Instruct-v0.3-Q4_K_M.gguf",
label: "Mistral-7B-Instruct-v0.3-Q4_K_M.gguf",
},
];
export const DEFAULT_CHECKPOINT = MOCK_CHECKPOINTS[0].value;
interface ChatTopBarProps {
checkpoint: string;
inferenceEngine: string;
onCheckpointChange: (value: string) => void;
onEject: () => void;
sidebarOpen: boolean;
onSidebarToggle: () => void;
settingsOpen: boolean;
onSettingsToggle: () => void;
}
export function ChatTopBar({
checkpoint,
inferenceEngine,
onCheckpointChange,
onEject,
sidebarOpen,
onSidebarToggle,
settingsOpen,
onSettingsToggle,
}: ChatTopBarProps) {
const isLoaded = checkpoint !== "";
const items = inferenceEngine === "llama-cpp" ? MOCK_GGUFS : MOCK_CHECKPOINTS;
return (
<div className="flex h-11 shrink-0 items-center gap-2 px-3">
{/* sidebar toggle */}
<button
type="button"
onClick={onSidebarToggle}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent",
sidebarOpen && "bg-accent text-foreground",
)}
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
>
<HugeiconsIcon icon={SidebarLeft01Icon} className="size-4" />
</button>
{/* center group: settings + model selector + eject */}
<div className="flex flex-1 items-center justify-center gap-1.5">
<button
type="button"
onClick={onSettingsToggle}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent",
settingsOpen && "bg-accent text-foreground",
)}
title="Inference settings"
>
<HugeiconsIcon icon={Settings04Icon} className="size-4" />
</button>
<Select value={checkpoint} onValueChange={onCheckpointChange}>
<SelectTrigger className="h-8 min-w-[320px] text-xs">
{isLoaded && (
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
)}
<SelectValue placeholder="Select a model…" />
</SelectTrigger>
<SelectContent position="popper" className="min-w-[340px]">
<SelectGroup>
<SelectLabel>
{inferenceEngine === "llama-cpp"
? "GGUF Models"
: "LoRA Checkpoints"}
</SelectLabel>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<button
type="button"
onClick={onEject}
disabled={!isLoaded}
className={cn(
"flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs transition-colors",
isLoaded
? "text-muted-foreground hover:bg-accent hover:text-foreground"
: "cursor-default border-transparent text-muted-foreground/30",
)}
>
<HugeiconsIcon icon={Logout01Icon} className="size-3.5" />
Eject
</button>
</div>
</div>
);
}

View file

@ -1,8 +1,11 @@
import {
type AttachmentAdapter,
AssistantRuntimeProvider,
type CompleteAttachment,
CompositeAttachmentAdapter,
ExportedMessageRepository,
type ExportedMessageRepositoryItem,
type PendingAttachment,
RuntimeAdapterProvider,
SimpleImageAttachmentAdapter,
SimpleTextAttachmentAdapter,
@ -12,22 +15,82 @@ import {
type ThreadUserMessagePart,
WebSpeechDictationAdapter,
type unstable_RemoteThreadListAdapter,
useAssistantRuntime,
useAui,
useAuiState,
useLocalRuntime,
unstable_useRemoteThreadListRuntime as useRemoteThreadListRuntime,
} from "@assistant-ui/react";
import { createAssistantStream } from "assistant-stream";
import {
type MutableRefObject,
type ReactElement,
type ReactNode,
useEffect,
useMemo,
} from "react";
import { type ReactElement, type ReactNode, useEffect, useMemo } from "react";
import mammoth from "mammoth";
import { extractText, getDocumentProxy } from "unpdf";
import { createStreamAdapter } from "./adapter";
import { db } from "./db";
import type { MessageRecord, ModelType, RuntimeBridge } from "./types";
import type { MessageRecord, ModelType } from "./types";
class PDFAttachmentAdapter implements AttachmentAdapter {
accept = "application/pdf";
async add({ file }: { file: File }): Promise<PendingAttachment> {
return {
id: crypto.randomUUID(),
type: "document",
name: file.name,
contentType: file.type,
file,
status: { type: "requires-action", reason: "composer-send" },
};
}
async send(attachment: PendingAttachment): Promise<CompleteAttachment> {
const buffer = new Uint8Array(await attachment.file.arrayBuffer());
const pdf = await getDocumentProxy(buffer);
const { text } = await extractText(pdf, { mergePages: true });
return {
id: attachment.id,
type: "document",
name: attachment.name,
contentType: attachment.contentType,
content: [{ type: "text", text: `[PDF: ${attachment.name}]\n${text}` }],
status: { type: "complete" },
};
}
async remove(): Promise<void> {}
}
class DocxAttachmentAdapter implements AttachmentAdapter {
accept =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
async add({ file }: { file: File }): Promise<PendingAttachment> {
return {
id: crypto.randomUUID(),
type: "document",
name: file.name,
contentType: file.type,
file,
status: { type: "requires-action", reason: "composer-send" },
};
}
async send(attachment: PendingAttachment): Promise<CompleteAttachment> {
const arrayBuffer = await attachment.file.arrayBuffer();
const { value } = await mammoth.extractRawText({ arrayBuffer });
return {
id: attachment.id,
type: "document",
name: attachment.name,
contentType: attachment.contentType,
content: [
{ type: "text", text: `[DOCX: ${attachment.name}]\n${value}` },
],
status: { type: "complete" },
};
}
async remove(): Promise<void> {}
}
function toThreadMessage(m: MessageRecord): ThreadMessage {
const base = {
@ -61,10 +124,6 @@ function toThreadMessage(m: MessageRecord): ThreadMessage {
};
}
function threadStatus(archived: boolean): "archived" | "regular" {
return archived ? "archived" : "regular";
}
function createDexieAdapter(
modelType: ModelType,
pairId?: string,
@ -77,7 +136,7 @@ function createDexieAdapter(
}
return {
remoteId: thread.id,
status: threadStatus(thread.archived),
status: thread.archived ? "archived" : "regular",
title: thread.title,
};
},
@ -90,7 +149,9 @@ function createDexieAdapter(
.sortBy("createdAt");
return {
threads: threads.map((t) => ({
status: threadStatus(t.archived),
status: (t.archived ? "archived" : "regular") as
| "archived"
| "regular",
remoteId: t.id,
title: t.title,
})),
@ -174,8 +235,7 @@ function ThreadHistoryProvider({
.equals(remoteId)
.sortBy("createdAt");
const converted = msgs.map((m) => toThreadMessage(m));
return ExportedMessageRepository.fromArray(converted);
return ExportedMessageRepository.fromArray(msgs.map(toThreadMessage));
},
async append({ message }: ExportedMessageRepositoryItem) {
@ -209,6 +269,8 @@ function ThreadHistoryProvider({
new CompositeAttachmentAdapter([
new SimpleImageAttachmentAdapter(),
new SimpleTextAttachmentAdapter(),
new PDFAttachmentAdapter(),
new DocxAttachmentAdapter(),
]),
[],
);
@ -228,49 +290,19 @@ const chatAdapter = createStreamAdapter();
const useRuntimeHook = (): ReturnType<typeof useLocalRuntime> =>
useLocalRuntime(chatAdapter);
function RuntimeBridgeCapture({
bridgeRef,
}: {
bridgeRef: MutableRefObject<RuntimeBridge | null>;
}): ReactElement | null {
const runtime = useAssistantRuntime();
useEffect(() => {
bridgeRef.current = {
switchToThread: (id) => runtime.threadList.switchToThread(id),
switchToNewThread: () => runtime.threadList.switchToNewThread(),
};
return () => {
bridgeRef.current = null;
};
}, [runtime, bridgeRef]);
return null;
}
function ThreadAutoSwitch({
threadId,
}: { threadId: string }): ReactElement | null {
const runtime = useAssistantRuntime();
const aui = useAui();
const isLoading = useAuiState(({ threads }) => threads.isLoading);
const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId);
useEffect(() => {
function trySwitch() {
const s = runtime.threadList.getState();
if (s.isLoading || s.mainThreadId === threadId) {
return false;
}
runtime.threadList.switchToThread(threadId);
return true;
if (!isLoading && mainThreadId !== threadId) {
aui.threads().switchToThread(threadId);
}
}, [aui, isLoading, mainThreadId, threadId]);
if (trySwitch()) {
return;
}
const unsub = runtime.threadList.subscribe(() => {
if (trySwitch()) {
unsub();
}
});
return unsub;
}, [runtime, threadId]);
return null;
}
@ -278,13 +310,11 @@ export function ChatRuntimeProvider({
children,
modelType = "base",
pairId,
bridgeRef,
initialThreadId,
}: {
children: ReactNode;
modelType?: ModelType;
pairId?: string;
bridgeRef?: MutableRefObject<RuntimeBridge | null>;
initialThreadId?: string;
}): ReactElement {
const runtime = useRemoteThreadListRuntime({
@ -298,7 +328,7 @@ export function ChatRuntimeProvider({
const aui = useAui({
suggestions: Suggestions([
"Draw a simple flowchart of a login system using Mermaid",
"Solve the integral of x²·sin(x) step by step",
"Solve the integral of x\u00B2\u00B7sin(x) step by step",
"Write a Python function that finds the longest palindrome in a string",
"Format a comparison of 3 databases as a markdown table with pros and cons",
]),
@ -306,7 +336,6 @@ export function ChatRuntimeProvider({
return (
<AssistantRuntimeProvider runtime={runtime} aui={aui}>
{bridgeRef && <RuntimeBridgeCapture bridgeRef={bridgeRef} />}
{initialThreadId && <ThreadAutoSwitch threadId={initialThreadId} />}
{children}
</AssistantRuntimeProvider>

View file

@ -1,6 +1,6 @@
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { useAssistantRuntime } from "@assistant-ui/react";
import { useAui } from "@assistant-ui/react";
import { ArrowUpIcon, SquareIcon } from "lucide-react";
import {
type KeyboardEvent,
@ -44,7 +44,7 @@ export function RegisterCompareHandle({
name: string;
}): ReactElement | null {
const handlesRef = useContext(CompareHandlesContext);
const runtime = useAssistantRuntime();
const aui = useAui();
useEffect(() => {
if (!handlesRef) {
@ -52,14 +52,14 @@ export function RegisterCompareHandle({
}
const currentHandles = handlesRef.current;
currentHandles[name] = {
append: (content) => runtime.thread.append({ role: "user", content }),
cancel: () => runtime.thread.cancelRun(),
isRunning: () => runtime.thread.getState().isRunning,
append: (content) => aui.thread().append({ role: "user", content }),
cancel: () => aui.thread().cancelRun(),
isRunning: () => aui.thread().getState().isRunning,
};
return () => {
delete currentHandles[name];
};
}, [handlesRef, name, runtime]);
}, [handlesRef, name, aui]);
return null;
}

View file

@ -124,7 +124,6 @@ export function ThreadSidebar({
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/*<SidebarSeparator />*/}
<SidebarGroup className="flex-1">
<SidebarGroupLabel>Your Chats</SidebarGroupLabel>
<SidebarGroupContent>

View file

@ -22,7 +22,3 @@ export interface MessageRecord {
createdAt: number;
}
export interface RuntimeBridge {
switchToThread: (threadId: string) => void;
switchToNewThread: () => void;
}

View file

@ -0,0 +1,6 @@
export const collapseAnim = {
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 },
};

View file

@ -0,0 +1,212 @@
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 { collapseAnim } from "../anim";
import { EXPORT_METHODS, type ExportMethod } from "../constants";
type Destination = "local" | "hub";
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 {...collapseAnim} className="overflow-hidden">
<div className="flex flex-col gap-4 px-0.5">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Username / Org
</label>
<Input
placeholder="your-username"
value={hfUsername}
onChange={(e) => onHfUsernameChange(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Model Name
</label>
<Input
placeholder="my-model-gguf"
value={modelName}
onChange={(e) => onModelNameChange(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
HF Write Token
</label>
<a
href="https://huggingface.co/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[11px] text-emerald-600 hover:text-emerald-700 transition-colors"
>
Get token
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3"
/>
</a>
</div>
<InputGroup>
<InputGroupAddon>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
</InputGroupAddon>
<InputGroupInput
type="password"
placeholder="hf_..."
value={hfToken}
onChange={(e) => onHfTokenChange(e.target.value)}
/>
</InputGroup>
<p className="text-[11px] text-muted-foreground/70">
Leave empty if already logged in via CLI.
</p>
</div>
<div className="flex items-center gap-3">
<Switch
id="private-repo"
size="sm"
checked={privateRepo}
onCheckedChange={onPrivateRepoChange}
/>
<label
htmlFor="private-repo"
className="text-xs font-medium cursor-pointer"
>
Private Repository
</label>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Summary */}
<div className="rounded-xl bg-muted/50 p-3 text-xs text-muted-foreground flex flex-col gap-1">
<div className="flex justify-between">
<span>Base Model</span>
<span className="font-medium text-foreground">{baseModelName}</span>
</div>
<div className="flex justify-between">
<span>{isAdapter ? "Checkpoint" : "Model"}</span>
<span className="font-medium text-foreground">{checkpoint}</span>
</div>
<div className="flex justify-between">
<span>Export Method</span>
<span className="font-medium text-foreground">
{EXPORT_METHODS.find((m) => m.value === exportMethod)?.title}
</span>
</div>
{exportMethod === "gguf" && quantLevels.length > 0 && (
<div className="flex justify-between">
<span>Quantizations</span>
<span className="font-medium text-foreground">
{quantLevels.join(", ")}
</span>
</div>
)}
<div className="flex justify-between">
<span>Est. size</span>
<span className="font-medium text-foreground">{estimatedSize}</span>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => onOpenChange(false)}>Start Export</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,127 @@
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={true}>
<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={true}>
<span
className="shrink-0 text-foreground/50 hover:text-foreground cursor-help"
onClick={(e) => e.stopPropagation()}
>
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3"
/>
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{m.tooltip}{" "}
<a
href={
m.value === "gguf"
? "https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf"
: "https://unsloth.ai/docs/basics/inference-and-deployment"
}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Read more
</a>
</TooltipContent>
</Tooltip>
{m.badge && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0"
>
{m.badge}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{m.description}
</span>
</div>
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,111 @@
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={true}>
<button
type="button"
className="text-foreground/70 hover:text-foreground"
>
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Lower quantization (Q2, Q3) = smaller files but reduced quality.
Q4Q5 is a good balance.{" "}
<a
href="https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Read more
</a>
</TooltipContent>
</Tooltip>
<span className="text-[11px] text-muted-foreground/70">
select one or more
</span>
</div>
<div className="flex flex-wrap gap-2 py-1 pl-1">
{QUANT_OPTIONS.map((q) => {
const active = value.includes(q.value);
return (
<button
key={q.value}
type="button"
onClick={() => toggle(q.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ring-1 transition-all",
active
? "ring-primary bg-primary/10 text-primary"
: "ring-border text-muted-foreground hover:text-foreground hover:ring-foreground/20",
)}
>
{active && (
<HugeiconsIcon
icon={CheckmarkCircle01Icon}
className="size-3"
/>
)}
{q.label}
<span className="text-[10px] opacity-60">{q.size}</span>
{q.recommended && !active && (
<span className="rounded-full bg-emerald-100 px-1.5 py-0 text-[9px] font-semibold text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300">
rec
</span>
)}
</button>
);
})}
</div>
{value.length > 0 && (
<div className="flex items-center gap-3">
<span className="text-[11px] text-muted-foreground">
{value.length} selected
</span>
<button
type="button"
onClick={() => onChange([])}
className="text-[11px] text-muted-foreground/70 hover:text-foreground transition-colors"
>
Clear all
</button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,82 @@
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: "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: "q8_0", label: "Q8_0", size: "~8.2 GB" },
{ value: "f16", label: "F16", size: "~14.2 GB" },
{ value: "f32", label: "F32", size: "~28.4 GB" },
];
export function getEstimatedSize(
method: ExportMethod | null,
quantLevels: string[],
) {
const sizeOf = (v: string) =>
QUANT_OPTIONS.find((q) => q.value === v)?.size ?? "—";
if (method === "gguf" && quantLevels.length > 0) {
if (quantLevels.length === 1) {
return sizeOf(quantLevels[0]);
}
const total = quantLevels
.map((q) => Number.parseFloat(sizeOf(q).replace(/[^0-9.]/g, "")))
.reduce((a, b) => a + b, 0);
return `~${total.toFixed(1)} GB (${quantLevels.length} files)`;
}
if (method === "merged") {
return "~14.2 GB";
}
if (method === "lora") {
return "~100 MB";
}
return "—";
}
export const METHOD_LABELS: Record<TrainingMethod, string> = {
qlora: "QLoRA",
lora: "LoRA",
full: "Full Fine-tune",
};
export const GUIDE_STEPS = [
"Select a training checkpoint to export from",
"Choose an export method based on your use case",
"Pick quantization levels if using GGUF",
"Click Export and choose your destination",
"Test your model and compare outputs in Chat",
];

View file

@ -0,0 +1,286 @@
import { SectionCard } from "@/components/section-card";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useWizardStore } from "@/stores/training";
import { isAdapterMethod } from "@/types/training";
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 { useShallow } from "zustand/react/shallow";
import { collapseAnim } from "./anim";
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";
export function ExportPage() {
const {
trainingMethod,
selectedModel,
saveSteps,
trainingMetrics,
epochs,
loraRank,
hfToken,
setHfToken,
} = useWizardStore(
useShallow((s) => ({
trainingMethod: s.trainingMethod,
selectedModel: s.selectedModel,
saveSteps: s.saveSteps,
trainingMetrics: s.trainingMetrics,
epochs: s.epochs,
loraRank: s.loraRank,
hfToken: s.hfToken,
setHfToken: s.setHfToken,
})),
);
const isAdapter = isAdapterMethod(trainingMethod);
const checkpoints = useMemo(() => {
if (isAdapter) {
const interval = saveSteps > 0 ? saveSteps : 100;
const total = 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, saveSteps, 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 = 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={true}
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={true}>
<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[trainingMethod] ?? 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">{epochs}</span>
</div>
{isAdapter && (
<div className="flex justify-between">
<span className="text-muted-foreground">LoRA Rank</span>
<span className="font-medium">{loraRank}</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 {...collapseAnim} 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={hfToken}
onHfTokenChange={setHfToken}
privateRepo={privateRepo}
onPrivateRepoChange={setPrivateRepo}
/>
</div>
);
}

View file

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

View file

@ -26,13 +26,18 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DATASETS } from "@/config/training";
import { cn } from "@/lib/utils";
import {
useDebouncedValue,
useHfDatasetSearch,
useInfiniteScroll,
} from "@/hooks";
import { cn, formatCompact } from "@/lib/utils";
import { useWizardStore } from "@/stores/training";
import type { DatasetFormat } from "@/types/training";
import {
@ -42,7 +47,7 @@ import {
Upload04Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo, useRef } from "react";
import { useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
const FORMAT_OPTIONS: { value: DatasetFormat; label: string }[] = [
@ -79,26 +84,32 @@ export function DatasetStep() {
})),
);
const sortedDatasets = useMemo(
() =>
// Sort recommended first
[...DATASETS].sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
),
[],
const [inputValue, setInputValue] = useState("");
const selectingRef = useRef(false);
const debouncedQuery = useDebouncedValue(inputValue);
const {
results: hfResults,
isLoading,
isLoadingMore,
fetchMore,
} = useHfDatasetSearch(debouncedQuery, {
accessToken: hfToken || undefined,
});
const resultIds = useMemo(() => hfResults.map((r) => r.id), [hfResults]);
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
const { scrollRef, sentinelRef } = useInfiniteScroll(
fetchMore,
hfResults.length,
);
const selectedDatasetData = DATASETS.find((d) => d.id === dataset);
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
const handleFileUpload = () => {
// Mock file upload
setUploadedFile("my_dataset.jsonl");
};
return (
<FieldGroup>
{/* Source Toggle */}
<Field>
<FieldLabel>Source</FieldLabel>
<div className="flex gap-2">
@ -163,18 +174,17 @@ export function DatasetStep() {
<FieldLabel>Search datasets</FieldLabel>
<div ref={comboboxAnchorRef}>
<Combobox
items={sortedDatasets.map((d) => d.name)}
value={selectedDatasetData?.name ?? null}
onValueChange={(name) => {
const ds = sortedDatasets.find((d) => d.name === name);
if (ds) {
setDataset(ds.id);
}
}}
items={resultIds}
filteredItems={resultIds}
filter={null}
value={dataset}
onValueChange={(id) => { selectingRef.current = true; setDataset(id); }}
onInputValueChange={(val) => { if (selectingRef.current) { selectingRef.current = false; return; } setInputValue(val); }}
itemToStringValue={(id) => id}
autoHighlight={true}
>
<ComboboxInput
placeholder="Search by name..."
placeholder="Search datasets..."
className="w-full"
>
<InputGroupAddon>
@ -182,29 +192,62 @@ export function DatasetStep() {
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent anchor={comboboxAnchorRef}>
<ComboboxEmpty>No datasets found</ComboboxEmpty>
<ComboboxList className="p-1">
{(name: string) => {
const ds = sortedDatasets.find((d) => d.name === name);
return (
<ComboboxItem key={name} value={name}>
<div className="flex flex-col gap-0.5 flex-1">
<span>{name}</span>
{ds && (
<span className="text-xs text-muted-foreground">
{ds.description}
{isLoading ? (
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
<Spinner className="size-4" /> Searching...
</div>
) : (
<ComboboxEmpty>No datasets found</ComboboxEmpty>
)}
<div
ref={scrollRef}
className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]"
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const r = hfResults.find((r) => r.id === id);
const detail = r?.totalExamples
? `${formatCompact(r.totalExamples)} rows`
: (r?.sizeCategory ?? null);
return (
<ComboboxItem
key={id}
value={id}
className="justify-between"
>
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 truncate">
{id}
</span>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-xs break-all"
>
{id}
</TooltipContent>
</Tooltip>
{detail ? (
<span className="text-[10px] text-muted-foreground shrink-0">
{detail}
</span>
)}
</div>
{ds && (
<Badge variant="outline" className="ml-auto">
{ds.size}
</Badge>
)}
</ComboboxItem>
);
}}
</ComboboxList>
) : r?.downloads != null ? (
<span className="text-[10px] text-muted-foreground shrink-0">
{formatCompact(r.downloads)}
</span>
) : null}
</ComboboxItem>
);
}}
</ComboboxList>
<div ref={sentinelRef} className="h-px" />
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
)}
</div>
</ComboboxContent>
</Combobox>
</div>
@ -250,7 +293,6 @@ export function DatasetStep() {
</>
)}
{/* Format Selection */}
<Field>
<div className="flex items-center justify-between">
<FieldLabel className="flex items-center gap-1.5">

View file

@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import {
Combobox,
ComboboxContent,
@ -25,12 +24,19 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MODELS } from "@/config/training";
import { MODEL_TYPE_TO_HF_TASK } from "@/config/training";
import {
useDebouncedValue,
useHfModelSearch,
useInfiniteScroll,
} from "@/hooks";
import { formatCompact } from "@/lib/utils";
import { useWizardStore } from "@/stores/training";
import type { TrainingMethod } from "@/types/training";
import {
@ -39,7 +45,7 @@ import {
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo, useRef } from "react";
import { useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
export function ModelSelectionStep() {
@ -63,18 +69,27 @@ export function ModelSelectionStep() {
})),
);
const filteredModels = useMemo(() => {
if (!modelType) {
return [];
}
// Sort recommended first
return MODELS.filter((m) => m.type === modelType).sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
);
}, [modelType]);
const [inputValue, setInputValue] = useState("");
const selectingRef = useRef(false);
const debouncedQuery = useDebouncedValue(inputValue);
const task = modelType ? MODEL_TYPE_TO_HF_TASK[modelType] : undefined;
const {
results: hfResults,
isLoading,
isLoadingMore,
fetchMore,
} = useHfModelSearch(debouncedQuery, {
task,
accessToken: hfToken || undefined,
});
const resultIds = useMemo(() => hfResults.map((r) => r.id), [hfResults]);
const selectedModelData = MODELS.find((m) => m.id === selectedModel);
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
const { scrollRef, sentinelRef } = useInfiniteScroll(
fetchMore,
hfResults.length,
);
return (
<FieldGroup>
@ -123,7 +138,7 @@ export function ModelSelectionStep() {
</button>
</TooltipTrigger>
<TooltipContent>
Search from our curated list of optimized models.{" "}
Search Hugging Face models or pick from our recommended list.{" "}
<a
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/what-model-should-i-use"
target="_blank"
@ -137,44 +152,83 @@ export function ModelSelectionStep() {
</FieldLabel>
<div ref={comboboxAnchorRef}>
<Combobox
items={filteredModels.map((m) => m.name)}
value={selectedModelData?.name ?? null}
onValueChange={(name) => {
const model = filteredModels.find((m) => m.name === name);
if (model) {
setSelectedModel(model.id);
}
}}
items={resultIds}
filteredItems={resultIds}
filter={null}
value={selectedModel}
onValueChange={(id) => { selectingRef.current = true; setSelectedModel(id); }}
onInputValueChange={(val) => { if (selectingRef.current) { selectingRef.current = false; return; } setInputValue(val); }}
itemToStringValue={(id) => id}
autoHighlight={true}
>
<ComboboxInput placeholder="Search by name..." className="w-full">
<ComboboxInput placeholder="Search models..." className="w-full">
<InputGroupAddon>
<HugeiconsIcon icon={Search01Icon} className="size-4" />
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent anchor={comboboxAnchorRef}>
<ComboboxEmpty>No models found</ComboboxEmpty>
<ComboboxList className="p-1">
{(name: string) => {
const model = filteredModels.find((m) => m.name === name);
return (
<ComboboxItem key={name} value={name}>
<span className="flex-1">{name}</span>
{model && (
<Badge variant="outline" className="ml-auto">
{model.params}
</Badge>
)}
</ComboboxItem>
);
}}
</ComboboxList>
{isLoading ? (
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
<Spinner className="size-4" /> Searching
</div>
) : (
<ComboboxEmpty>No models found</ComboboxEmpty>
)}
<div
ref={scrollRef}
className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]"
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const r = hfResults.find((r) => r.id === id);
const sizeLabel = r?.totalParams
? formatCompact(r.totalParams)
: null;
return (
<ComboboxItem
key={id}
value={id}
className="justify-between"
>
<Tooltip>
<TooltipTrigger asChild={true}>
<span className="min-w-0 flex-1 truncate">
{id}
</span>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-xs break-all"
>
{id}
</TooltipContent>
</Tooltip>
{sizeLabel ? (
<span className="text-xs text-muted-foreground shrink-0">
{sizeLabel}
</span>
) : r?.downloads != null ? (
<span className="text-[10px] text-muted-foreground shrink-0">
{formatCompact(r.downloads)}
</span>
) : null}
</ComboboxItem>
);
}}
</ComboboxList>
<div ref={sentinelRef} className="h-px" />
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
)}
</div>
</ComboboxContent>
</Combobox>
</div>
</Field>
{selectedModelData && (
{selectedModel && (
<Field>
<div className="flex items-center justify-between">
<div>
@ -208,7 +262,7 @@ export function ModelSelectionStep() {
</Tooltip>
</FieldLabel>
<FieldDescription>
Choose how to fine-tune {selectedModelData.name}
Choose how to fine-tune {selectedModel}
</FieldDescription>
</div>
<Select

View file

@ -1,13 +1,12 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { DATASETS, MODELS } from "@/config/training";
import { useWizardStore } from "@/stores/training";
import { isAdapterMethod } from "@/types/training";
import { GpuIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useShallow } from "zustand/react/shallow";
// Mock system info - like checking via code
const SYSTEM_INFO = {
gpu: "NVIDIA RTX 4090",
vram: "24 GB",
@ -32,35 +31,24 @@ export function SummaryStep() {
loraAlpha,
loraDropout,
} = useWizardStore(
useShallow((s) => ({
modelType: s.modelType,
selectedModel: s.selectedModel,
trainingMethod: s.trainingMethod,
datasetSource: s.datasetSource,
datasetFormat: s.datasetFormat,
dataset: s.dataset,
uploadedFile: s.uploadedFile,
epochs: s.epochs,
contextLength: s.contextLength,
learningRate: s.learningRate,
loraRank: s.loraRank,
loraAlpha: s.loraAlpha,
loraDropout: s.loraDropout,
useShallow(({
modelType, selectedModel, trainingMethod, datasetSource, datasetFormat,
dataset, uploadedFile, epochs, contextLength, learningRate,
loraRank, loraAlpha, loraDropout,
}) => ({
modelType, selectedModel, trainingMethod, datasetSource, datasetFormat,
dataset, uploadedFile, epochs, contextLength, learningRate,
loraRank, loraAlpha, loraDropout,
})),
);
const modelData = MODELS.find((m) => m.id === selectedModel);
const datasetData = DATASETS.find((d) => d.id === dataset);
const showLoraParams =
trainingMethod === "lora" || trainingMethod === "qlora";
const showLoraParams = isAdapterMethod(trainingMethod);
const datasetName =
datasetSource === "upload" ? uploadedFile : datasetData?.name;
const datasetDesc =
datasetSource === "upload" ? "Uploaded file" : datasetData?.description;
datasetSource === "upload" ? uploadedFile : dataset;
return (
<div className="grid grid-cols-2 gap-4">
<Card size="sm" className="">
<Card size="sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">
System
@ -103,10 +91,9 @@ export function SummaryStep() {
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{modelData?.name ?? "—"}
<span className="text-sm font-medium truncate">
{selectedModel ?? "—"}
</span>
<Badge>{modelData?.params}</Badge>
</div>
<Separator />
<div className="flex items-center justify-between text-sm">
@ -129,14 +116,8 @@ export function SummaryStep() {
<CardContent className="space-y-2">
<div className="flex items-center gap-3">
<div className="flex flex-col flex-1">
<span className="text-sm font-medium">{datasetName ?? "—"}</span>
<span className="text-xs text-muted-foreground">
{datasetDesc}
</span>
<span className="text-sm font-medium truncate">{datasetName ?? "—"}</span>
</div>
{datasetData?.size && (
<Badge variant="secondary">{datasetData.size}</Badge>
)}
</div>
<Separator />
<div className="flex items-center justify-between text-sm">

View file

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

View file

@ -1,15 +1,14 @@
import { SectionCard } from "@/components/section-card";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { InputGroupAddon } from "@/components/ui/input-group";
import {
Select,
SelectContent,
@ -17,15 +16,20 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DATASETS } from "@/config/training";
import {
useDebouncedValue,
useHfDatasetSearch,
useInfiniteScroll,
} from "@/hooks";
import { formatCompact } from "@/lib/utils";
import { useWizardStore } from "@/stores/training";
import {
ArrowDown01Icon,
CloudUploadIcon,
Database02Icon,
FileAttachmentIcon,
@ -34,20 +38,55 @@ import {
ViewIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
export function DatasetSection() {
const { dataset, setDataset, datasetFormat, setDatasetFormat } =
const { dataset, setDataset, datasetFormat, setDatasetFormat, hfToken } =
useWizardStore(
useShallow((s) => ({
dataset: s.dataset,
setDataset: s.setDataset,
datasetFormat: s.datasetFormat,
setDatasetFormat: s.setDatasetFormat,
useShallow(({ dataset, setDataset, datasetFormat, setDatasetFormat, hfToken }) => ({
dataset, setDataset, datasetFormat, setDatasetFormat, hfToken,
})),
);
const [recOpen, setRecOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const selectingRef = useRef(false);
const debouncedQuery = useDebouncedValue(inputValue);
function handleDatasetSelect(id: string | null) {
selectingRef.current = true;
setDataset(id);
}
function handleInputChange(val: string) {
if (selectingRef.current) {
selectingRef.current = false;
return;
}
setInputValue(val);
}
const {
results: hfResults,
isLoading,
isLoadingMore,
fetchMore,
} = useHfDatasetSearch(debouncedQuery, {
accessToken: hfToken || undefined,
});
const resultIds = useMemo(() => {
const ids = hfResults.map((r) => r.id);
if (dataset && !ids.includes(dataset)) {
ids.unshift(dataset);
}
return ids;
}, [hfResults, dataset]);
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
const { scrollRef, sentinelRef } = useInfiniteScroll(
fetchMore,
hfResults.length,
);
return (
<SectionCard
@ -58,7 +97,6 @@ export function DatasetSection() {
className="lg:col-span-4 min-h-[450px]"
>
<div className="flex flex-col gap-4">
{/* Load from Hub */}
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Load from Hub
@ -75,7 +113,8 @@ export function DatasetSection() {
</button>
</TooltipTrigger>
<TooltipContent>
Enter a Hugging Face dataset path like 'username/dataset-name'.{" "}
Search Hugging Face datasets or enter a path like
'username/dataset-name'.{" "}
<a
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/datasets-guide"
target="_blank"
@ -87,20 +126,87 @@ export function DatasetSection() {
</TooltipContent>
</Tooltip>
</span>
<InputGroup>
<InputGroupAddon>
<HugeiconsIcon icon={Search01Icon} className="size-4" />
</InputGroupAddon>
<InputGroupInput
placeholder="yahma/alpaca-cleaned"
value={dataset ?? ""}
className=""
onChange={(e) => setDataset(e.target.value || null)}
/>
</InputGroup>
<div ref={comboboxAnchorRef}>
<Combobox
items={resultIds}
filteredItems={resultIds}
filter={null}
value={dataset}
onValueChange={handleDatasetSelect}
onInputValueChange={handleInputChange}
itemToStringValue={(id) => id}
autoHighlight={true}
>
<ComboboxInput
placeholder="Search datasets..."
className="w-full"
>
<InputGroupAddon>
<HugeiconsIcon icon={Search01Icon} className="size-4" />
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent anchor={comboboxAnchorRef}>
{isLoading ? (
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
<Spinner className="size-4" /> Searching...
</div>
) : (
<ComboboxEmpty>No datasets found</ComboboxEmpty>
)}
<div
ref={scrollRef}
className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]"
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const r = hfResults.find((ds) => ds.id === id);
const detail = r?.totalExamples
? `${formatCompact(r.totalExamples)} rows`
: r?.sizeCategory
? r.sizeCategory
: r?.downloads != null
? `${formatCompact(r.downloads)}`
: null;
return (
<ComboboxItem
key={id}
value={id}
className="justify-between"
>
<Tooltip>
<TooltipTrigger asChild={true}>
<span className="min-w-0 flex-1 truncate">
{id}
</span>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-xs break-all"
>
{id}
</TooltipContent>
</Tooltip>
{detail && (
<span className="text-[10px] text-muted-foreground shrink-0">
{detail}
</span>
)}
</ComboboxItem>
);
}}
</ComboboxList>
<div ref={sentinelRef} className="h-px" />
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
)}
</div>
</ComboboxContent>
</Combobox>
</div>
</div>
{/* Format */}
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Dataset Format
@ -146,7 +252,6 @@ export function DatasetSection() {
</Select>
</div>
{/* Active dataset display */}
{dataset ? (
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 px-3.5 py-3">
<div className="rounded-md bg-indigo-500/10 p-1.5">
@ -176,42 +281,6 @@ export function DatasetSection() {
</div>
)}
{/* Recommended */}
<Collapsible open={recOpen} onOpenChange={setRecOpen}>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<HugeiconsIcon
icon={ArrowDown01Icon}
className={`size-3.5 transition-transform ${recOpen ? "rotate-180" : ""}`}
/>
Common Datasets
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 flex flex-col gap-1.5">
{DATASETS.filter((d) => d.recommended).map((d) => (
<button
type="button"
key={d.id}
onClick={() => setDataset(d.id)}
className="flex w-full corner-squircle cursor-pointer items-center gap-2.5 rounded-2xl border bg-muted/30 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/60"
>
<HugeiconsIcon
icon={Database02Icon}
className="size-4 shrink-0 text-muted-foreground"
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium">{d.name}</p>
<p className="text-[10px] text-muted-foreground">
{d.description}
</p>
</div>
<span className="text-[10px] font-mono text-muted-foreground mt-0.5">
{d.size}
</span>
</button>
))}
</CollapsibleContent>
</Collapsible>
{/* Action buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"

View file

@ -1,4 +1,12 @@
import { SectionCard } from "@/components/section-card";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
InputGroup,
InputGroupAddon,
@ -11,56 +19,32 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MODELS } from "@/config/training";
import { MODEL_TYPE_TO_HF_TASK } from "@/config/training";
import {
useDebouncedValue,
useHfModelSearch,
useInfiniteScroll,
} from "@/hooks";
import { formatCompact } from "@/lib/utils";
import { useWizardStore } from "@/stores/training";
import type { TrainingMethod } from "@/types/training";
import {
ChipIcon,
FolderSearchIcon,
InformationCircleIcon,
Key01Icon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo } from "react";
import { useMemo, useRef, useState } 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",
"bg-emerald-400",
"bg-rose-400",
"bg-violet-400",
"bg-cyan-400",
"bg-orange-400",
"bg-pink-400",
"bg-teal-400",
"bg-indigo-400",
"bg-lime-400",
"bg-fuchsia-400",
"bg-sky-400",
"bg-red-400",
"bg-yellow-400",
"bg-purple-400",
];
const METHOD_DOTS: Record<string, string> = {
qlora: "bg-emerald-400",
lora: "bg-blue-400",
@ -82,25 +66,55 @@ export function ModelSection() {
hfToken,
setHfToken,
} = useWizardStore(
useShallow((s) => ({
modelType: s.modelType,
selectedModel: s.selectedModel,
setSelectedModel: s.setSelectedModel,
trainingMethod: s.trainingMethod,
setTrainingMethod: s.setTrainingMethod,
hfToken: s.hfToken,
setHfToken: s.setHfToken,
useShallow(({
modelType, selectedModel, setSelectedModel,
trainingMethod, setTrainingMethod, hfToken, setHfToken,
}) => ({
modelType, selectedModel, setSelectedModel,
trainingMethod, setTrainingMethod, hfToken, setHfToken,
})),
);
const filteredModels = useMemo(() => {
if (!modelType) {
return MODELS;
const [inputValue, setInputValue] = useState("");
const selectingRef = useRef(false);
const debouncedQuery = useDebouncedValue(inputValue);
function handleModelSelect(id: string | null) {
selectingRef.current = true;
setSelectedModel(id);
}
function handleInputChange(val: string) {
if (selectingRef.current) {
selectingRef.current = false;
return;
}
return MODELS.filter((m) => m.type === modelType).sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
);
}, [modelType]);
setInputValue(val);
}
const task = modelType ? MODEL_TYPE_TO_HF_TASK[modelType] : undefined;
const {
results: hfResults,
isLoading,
isLoadingMore,
fetchMore,
} = useHfModelSearch(debouncedQuery, {
task,
accessToken: hfToken || undefined,
});
const resultIds = useMemo(() => {
const ids = hfResults.map((r) => r.id);
if (selectedModel && !ids.includes(selectedModel)) {
ids.unshift(selectedModel);
}
return ids;
}, [hfResults, selectedModel]);
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
const { scrollRef, sentinelRef } = useInfiniteScroll(
fetchMore,
hfResults.length,
);
return (
<SectionCard
@ -113,7 +127,36 @@ export function ModelSection() {
className="col-span-12 shadow-border ring-1 ring-border"
>
<div className="grid gap-4 lg:grid-cols-4">
{/* Base Model */}
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Local Model
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
className="text-foreground/70 hover:text-foreground"
>
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3"
/>
</button>
</TooltipTrigger>
<TooltipContent>
Path to a locally downloaded model or a custom HF repo.
</TooltipContent>
</Tooltip>
</span>
<InputGroup className="bg-foreground text-background [&_input]:text-background [&_input]:placeholder:text-background/40 [&_svg]:text-background/50 hover:bg-foreground/90">
<InputGroupAddon>
<HugeiconsIcon icon={FolderSearchIcon} className="size-4" />
</InputGroupAddon>
<InputGroupInput
placeholder="./models/my-model"
/>
</InputGroup>
</div>
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Base Model
@ -130,7 +173,7 @@ export function ModelSection() {
</button>
</TooltipTrigger>
<TooltipContent>
Search from curated optimized models.{" "}
Search Hugging Face models or pick from our recommended list.{" "}
<a
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/what-model-should-i-use"
target="_blank"
@ -142,53 +185,82 @@ export function ModelSection() {
</TooltipContent>
</Tooltip>
</span>
<Select value={selectedModel ?? ""} onValueChange={setSelectedModel}>
<SelectTrigger className={DARK_TRIGGER}>
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent
position="popper"
className={`${DARK_CONTENT} max-h-64 overflow-y-auto w-[var(--radix-select-trigger-width)]`}
<div ref={comboboxAnchorRef}>
<Combobox
items={resultIds}
filteredItems={resultIds}
filter={null}
value={selectedModel}
onValueChange={handleModelSelect}
onInputValueChange={handleInputChange}
itemToStringValue={(id) => id}
autoHighlight={true}
>
{filteredModels.map((m, i) => (
<SelectItem key={m.id} value={m.id}>
<span className="flex items-center gap-2">
<span
className={`size-2 shrink-0 rounded-full ${DOT_COLORS[i % DOT_COLORS.length]}`}
/>
{m.name}
<span className="text-background/40 ml-auto text-xs">
{m.params}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<ComboboxInput placeholder="Search models..." className="w-full">
<InputGroupAddon>
<HugeiconsIcon icon={Search01Icon} className="size-4" />
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent anchor={comboboxAnchorRef}>
{isLoading ? (
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
<Spinner className="size-4" /> Searching
</div>
) : (
<ComboboxEmpty>No models found</ComboboxEmpty>
)}
<div
ref={scrollRef}
className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]"
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const r = hfResults.find((m) => m.id === id);
const detail = r?.totalParams
? formatCompact(r.totalParams)
: r?.downloads != null
? `${formatCompact(r.downloads)}`
: null;
return (
<ComboboxItem
key={id}
value={id}
className="justify-between"
>
<Tooltip>
<TooltipTrigger asChild={true}>
<span className="min-w-0 flex-1 truncate">
{id}
</span>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-xs break-all"
>
{id}
</TooltipContent>
</Tooltip>
{detail && (
<span className="text-[10px] text-muted-foreground shrink-0">
{detail}
</span>
)}
</ComboboxItem>
);
}}
</ComboboxList>
<div ref={sentinelRef} className="h-px" />
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
)}
</div>
</ComboboxContent>
</Combobox>
</div>
</div>
{/* HF Repo */}
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
Hugging Face Repo
</span>
<InputGroup>
<InputGroupAddon>
<HugeiconsIcon icon={Search01Icon} className="size-4" />
</InputGroupAddon>
<InputGroupInput
placeholder="unsloth/gemma-3-27b"
value={
selectedModel
? (HF_REPO_MAP[selectedModel] ?? selectedModel)
: ""
}
onChange={(e) => setSelectedModel(e.target.value || null)}
/>
</InputGroup>
</div>
{/* Training Method */}
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Method
@ -257,7 +329,6 @@ export function ModelSection() {
</Select>
</div>
{/* HF Token */}
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
Hugging Face Token (Optional)

View file

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

View file

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

View file

@ -0,0 +1,83 @@
import { listDatasets } from "@huggingface/hub";
import { useCallback } from "react";
import { useHfPaginatedSearch } from "./use-hf-paginated-search";
interface DatasetInfoSplit {
name: string;
num_bytes: number;
num_examples: number;
}
interface CardDataWithInfo {
size_categories?: string[];
pretty_name?: string;
dataset_info?:
| {
splits?: DatasetInfoSplit[];
download_size?: number;
dataset_size?: number;
}
| Array<{ splits?: DatasetInfoSplit[] }>;
}
function extractTotalExamples(
cardData: CardDataWithInfo | undefined,
): number | undefined {
if (!cardData?.dataset_info) return undefined;
const infos = Array.isArray(cardData.dataset_info)
? cardData.dataset_info
: [cardData.dataset_info];
const examples = infos
.flatMap((info) => info.splits ?? [])
.filter((s) => typeof s.num_examples === "number")
.map((s) => s.num_examples);
return examples.length > 0
? examples.reduce((a, b) => a + b, 0)
: undefined;
}
export interface HfDatasetResult {
id: string;
downloads: number;
likes: number;
totalExamples?: number;
sizeCategory?: string;
}
function mapDataset(raw: unknown): HfDatasetResult {
const ds = raw as {
name: string;
downloads: number;
likes: number;
cardData?: unknown;
};
const card = ds.cardData as CardDataWithInfo | undefined;
return {
id: ds.name,
downloads: ds.downloads,
likes: ds.likes,
totalExamples: extractTotalExamples(card),
sizeCategory: card?.size_categories?.[0],
};
}
export function useHfDatasetSearch(
query: string,
options?: { accessToken?: string },
) {
const { accessToken } = options ?? {};
const createIter = useCallback(
() =>
listDatasets({
search: query.trim() ? { query } : {},
additionalFields: ["cardData"],
...(accessToken ? { credentials: { accessToken } } : {}),
}) as AsyncGenerator<unknown>,
[query, accessToken],
);
return useHfPaginatedSearch(createIter, mapDataset);
}

View file

@ -0,0 +1,66 @@
import type { PipelineType } from "@huggingface/hub";
import { listModels } from "@huggingface/hub";
import { useCallback } from "react";
import { useHfPaginatedSearch } from "./use-hf-paginated-search";
export interface HfModelResult {
id: string;
downloads: number;
likes: number;
totalParams?: number;
}
const EXCLUDED_TAGS = new Set([
"gguf",
"gptq",
"awq",
"exl2",
"mlx",
"onnx",
"openvino",
"coreml",
"tflite",
"ctranslate2",
]);
function mapModel(raw: unknown): HfModelResult | null {
const m = raw as {
name: string;
downloads: number;
likes: number;
safetensors?: { total: number };
tags?: string[];
};
if (m.tags?.some((t) => EXCLUDED_TAGS.has(t))) {
return null;
}
return {
id: m.name,
downloads: m.downloads,
likes: m.likes,
totalParams: m.safetensors?.total,
};
}
export function useHfModelSearch(
query: string,
options?: { task?: PipelineType; accessToken?: string },
) {
const { task, accessToken } = options ?? {};
const createIter = useCallback(
() =>
listModels({
search: {
...(query.trim() ? { query } : { owner: "unsloth" }),
tags: ["transformers"],
...(task ? { task } : {}),
},
additionalFields: ["safetensors", "tags"],
...(accessToken ? { credentials: { accessToken } } : {}),
}) as AsyncGenerator<unknown>,
[query, task, accessToken],
);
return useHfPaginatedSearch(createIter, mapModel);
}

View file

@ -0,0 +1,122 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface HfPaginatedState<T> {
results: T[];
isLoading: boolean;
isLoadingMore: boolean;
hasMore: boolean;
error: string | null;
}
const INITIAL: HfPaginatedState<never> = {
results: [],
isLoading: false,
isLoadingMore: false,
hasMore: false,
error: null,
};
const BATCH = 20;
async function pullBatch<T>(
iter: AsyncGenerator<unknown>,
mapItem: (raw: unknown) => T | null,
size: number,
) {
const items: T[] = [];
while (items.length < size) {
const result = await iter.next();
if (result.done) {
return { items, done: true };
}
const mapped = mapItem(result.value);
if (mapped !== null) {
items.push(mapped);
}
}
return { items, done: false };
}
export function useHfPaginatedSearch<T>(
createIter: () => AsyncGenerator<unknown>,
mapItem: (raw: unknown) => T | null,
): HfPaginatedState<T> & { fetchMore: () => void } {
const [state, setState] = useState<HfPaginatedState<T>>(
INITIAL as HfPaginatedState<T>,
);
const stateRef = useRef(state);
useEffect(() => { stateRef.current = state; });
const iterRef = useRef<AsyncGenerator<unknown> | null>(null);
const versionRef = useRef(0);
useEffect(() => {
const v = ++versionRef.current;
iterRef.current = null;
setState({
...INITIAL as HfPaginatedState<T>,
isLoading: true,
});
const iter = createIter();
iterRef.current = iter;
pullBatch(iter, mapItem, BATCH)
.then(({ items, done }) => {
if (versionRef.current !== v) {
return;
}
setState({
results: items,
isLoading: false,
isLoadingMore: false,
hasMore: !done,
error: null,
});
})
.catch((err) => {
if (versionRef.current !== v) {
return;
}
setState({
results: [],
isLoading: false,
isLoadingMore: false,
hasMore: false,
error: err instanceof Error ? err.message : "Search failed",
});
});
}, [createIter, mapItem]);
const fetchMore = useCallback(() => {
const iter = iterRef.current;
const { isLoading, isLoadingMore, hasMore } = stateRef.current;
if (!iter || isLoading || isLoadingMore || !hasMore) {
return;
}
const v = versionRef.current;
setState((prev) => ({ ...prev, isLoadingMore: true }));
pullBatch(iter, mapItem, BATCH)
.then(({ items, done }) => {
if (versionRef.current !== v) {
return;
}
setState((prev) => ({
...prev,
results: [...prev.results, ...items],
isLoadingMore: false,
hasMore: !done,
}));
})
.catch(() => {
if (versionRef.current !== v) {
return;
}
setState((prev) => ({ ...prev, isLoadingMore: false, hasMore: false }));
});
}, [mapItem]);
return { ...state, fetchMore };
}

View file

@ -0,0 +1,25 @@
import { useEffect, useRef } from "react";
export function useInfiniteScroll(fetchMore: () => void, itemCount: number) {
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el) {
return;
}
const obs = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) {
fetchMore();
}
},
{ threshold: 0, root: scrollRef.current },
);
obs.observe(el);
return () => obs.disconnect();
}, [fetchMore, itemCount]);
return { scrollRef, sentinelRef };
}

View file

@ -1,19 +1,21 @@
import * as React from "react"
import * as React from "react";
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile
return !!isMobile;
}

View file

@ -52,7 +52,7 @@
--chart-4: oklch(0.6926 0.1112 346.5775);
--chart-5: oklch(0.7497 0.1003 85.0057);
--radius: 1.2rem;
--sidebar: oklch(0.9845 0.005 165.0106);
--sidebar: oklch(0.975 0 0);
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
--sidebar-primary-foreground: oklch(1 0 0);
@ -118,7 +118,7 @@
--chart-3: oklch(0.7554 0.1285 197.339);
--chart-4: oklch(0.7503 0.1199 346.7805);
--chart-5: oklch(0.799 0.1196 84.6633);
--sidebar: oklch(0.0853 0.0204 156.6743);
--sidebar: oklch(0.15 0 0);
--sidebar-foreground: oklch(0.9808 0.0059 170.4505);
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
--sidebar-primary-foreground: oklch(1 0 0);

View file

@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
export function formatCompact(n: number): string {
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}

View file

@ -21,13 +21,7 @@ const initialState: WizardState = {
};
function clampStep(step: number): StepNumber {
if (step <= MIN_STEP) {
return MIN_STEP;
}
if (step >= MAX_STEP) {
return MAX_STEP;
}
return step as StepNumber;
return Math.min(MAX_STEP, Math.max(MIN_STEP, step)) as StepNumber;
}
function canProceedForStep(state: WizardState): boolean {

View file

@ -1,5 +1,9 @@
export type ModelType = "vision" | "tts" | "embeddings" | "text";
export type TrainingMethod = "qlora" | "lora" | "full";
export function isAdapterMethod(method: TrainingMethod): boolean {
return method === "lora" || method === "qlora";
}
export type StepNumber = 1 | 2 | 3 | 4 | 5;
export type DatasetSource = "huggingface" | "upload";
export type DatasetFormat = "auto" | "alpaca" | "chatml" | "sharegpt";
@ -119,22 +123,4 @@ export interface StepConfig {
title: string;
subtitle: string;
description: string;
}
export interface ModelOption {
id: string;
name: string;
type: ModelType;
params: string;
vram?: string;
context?: string;
recommended?: boolean;
}
export interface DatasetOption {
id: string;
name: string;
description: string;
size: string;
recommended?: boolean;
}
}