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:
commit
b306a3f923
45 changed files with 2091 additions and 893 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,6 +25,7 @@ models/
|
|||
# IDE / Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
|
|
|
|||
66
CLAUDE.md
Normal file
66
CLAUDE.md
Normal 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
|
||||
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
15
studio/frontend/src/app/routes/export.tsx
Normal file
15
studio/frontend/src/app/routes/export.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { createRoute } from "@tanstack/react-router";
|
||||
import { lazy } from "react";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
const ExportPage = lazy(() =>
|
||||
import("@/features/export/export-page").then((m) => ({
|
||||
default: m.ExportPage,
|
||||
})),
|
||||
);
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/export",
|
||||
component: ExportPage,
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
11
studio/frontend/src/components/ui/spinner.tsx
Normal file
11
studio/frontend/src/components/ui/spinner.tsx
Normal 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 }
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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") };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,7 +124,6 @@ export function ThreadSidebar({
|
|||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
{/*<SidebarSeparator />*/}
|
||||
<SidebarGroup className="flex-1">
|
||||
<SidebarGroupLabel>Your Chats</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,3 @@ export interface MessageRecord {
|
|||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface RuntimeBridge {
|
||||
switchToThread: (threadId: string) => void;
|
||||
switchToNewThread: () => void;
|
||||
}
|
||||
|
|
|
|||
6
studio/frontend/src/features/export/anim.ts
Normal file
6
studio/frontend/src/features/export/anim.ts
Normal 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 },
|
||||
};
|
||||
212
studio/frontend/src/features/export/components/export-dialog.tsx
Normal file
212
studio/frontend/src/features/export/components/export-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
studio/frontend/src/features/export/components/method-picker.tsx
Normal file
127
studio/frontend/src/features/export/components/method-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
studio/frontend/src/features/export/components/quant-picker.tsx
Normal file
111
studio/frontend/src/features/export/components/quant-picker.tsx
Normal 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.
|
||||
Q4–Q5 is a good balance.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-[11px] text-muted-foreground/70">
|
||||
— select one or more
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 py-1 pl-1">
|
||||
{QUANT_OPTIONS.map((q) => {
|
||||
const active = value.includes(q.value);
|
||||
return (
|
||||
<button
|
||||
key={q.value}
|
||||
type="button"
|
||||
onClick={() => toggle(q.value)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ring-1 transition-all",
|
||||
active
|
||||
? "ring-primary bg-primary/10 text-primary"
|
||||
: "ring-border text-muted-foreground hover:text-foreground hover:ring-foreground/20",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle01Icon}
|
||||
className="size-3"
|
||||
/>
|
||||
)}
|
||||
{q.label}
|
||||
<span className="text-[10px] opacity-60">{q.size}</span>
|
||||
{q.recommended && !active && (
|
||||
<span className="rounded-full bg-emerald-100 px-1.5 py-0 text-[9px] font-semibold text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300">
|
||||
rec
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{value.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{value.length} selected
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([])}
|
||||
className="text-[11px] text-muted-foreground/70 hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
studio/frontend/src/features/export/constants.ts
Normal file
82
studio/frontend/src/features/export/constants.ts
Normal 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",
|
||||
];
|
||||
286
studio/frontend/src/features/export/export-page.tsx
Normal file
286
studio/frontend/src/features/export/export-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
studio/frontend/src/features/export/index.ts
Normal file
1
studio/frontend/src/features/export/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ExportPage } from "./export-page";
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
10
studio/frontend/src/hooks/use-debounced-value.ts
Normal file
10
studio/frontend/src/hooks/use-debounced-value.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delayMs = 300): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delayMs]);
|
||||
return debounced;
|
||||
}
|
||||
83
studio/frontend/src/hooks/use-hf-dataset-search.ts
Normal file
83
studio/frontend/src/hooks/use-hf-dataset-search.ts
Normal 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);
|
||||
}
|
||||
66
studio/frontend/src/hooks/use-hf-model-search.ts
Normal file
66
studio/frontend/src/hooks/use-hf-model-search.ts
Normal 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);
|
||||
}
|
||||
122
studio/frontend/src/hooks/use-hf-paginated-search.ts
Normal file
122
studio/frontend/src/hooks/use-hf-paginated-search.ts
Normal 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 };
|
||||
}
|
||||
25
studio/frontend/src/hooks/use-infinite-scroll.ts
Normal file
25
studio/frontend/src/hooks/use-infinite-scroll.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue