diff --git a/ui/README.md b/ui/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..171633400e2d06c56d2b0d712ad210a2a898cf4c
--- /dev/null
+++ b/ui/README.md
@@ -0,0 +1,55 @@
+This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+
+This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+
+## Database Modes
+
+Use the `NEXT_PUBLIC_DB_MODE` environment variable to control how UI data is persisted:
+
+- `server` (default): interacts with the shared SQLite database through Prisma. Supports local job orchestration.
+- `browser`: stores jobs and settings in the user's browser (localStorage). This mode only supports Hugging Face Jobs workflows; local GPU training controls are disabled and the GPU monitor shows a cloud-mode status banner.
+
+When running in browser mode every visitor sees only their own jobs, settings, and dataset catalog (all stored in their browser), making the UI safe to host for multiple users without sharing the SQLite file.
+
+## Hugging Face Authentication
+
+Users can authenticate either by pasting a personal access token or via the Hugging Face OAuth flow. To enable OAuth set the following environment variables for the UI:
+
+- `HF_OAUTH_CLIENT_ID` – the application client ID
+- `HF_OAUTH_CLIENT_SECRET` – the application secret (server-side only)
+- `NEXT_PUBLIC_HF_OAUTH_CLIENT_ID` – the client ID exposed to the browser (usually the same as `HF_OAUTH_CLIENT_ID`)
+
+If these values are not provided the UI falls back to manual token entry. In multi-user/browser mode the authenticated token and namespace are stored per browser session.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
diff --git a/ui/cron/worker.ts b/ui/cron/worker.ts
new file mode 100644
index 0000000000000000000000000000000000000000..589393a4f322ffc1af98431c06388eafaadb43c2
--- /dev/null
+++ b/ui/cron/worker.ts
@@ -0,0 +1,31 @@
+class CronWorker {
+ interval: number;
+ is_running: boolean;
+ intervalId: NodeJS.Timeout;
+ constructor() {
+ this.interval = 1000; // Default interval of 1 second
+ this.is_running = false;
+ this.intervalId = setInterval(() => {
+ this.run();
+ }, this.interval);
+ }
+ async run() {
+ if (this.is_running) {
+ return;
+ }
+ this.is_running = true;
+ try {
+ // Loop logic here
+ await this.loop();
+ } catch (error) {
+ console.error('Error in cron worker loop:', error);
+ }
+ this.is_running = false;
+ }
+
+ async loop() {}
+}
+
+// it automatically starts the loop
+const cronWorker = new CronWorker();
+console.log('Cron worker started with interval:', cronWorker.interval, 'ms');
diff --git a/ui/next-env.d.ts b/ui/next-env.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1b3be0840f3f6a2bc663b53f4b17d05d2d924df6
--- /dev/null
+++ b/ui/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/ui/next.config.ts b/ui/next.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8655fe00c302f94aa815e47184ec25f2b5b89836
--- /dev/null
+++ b/ui/next.config.ts
@@ -0,0 +1,15 @@
+import type { NextConfig } from 'next';
+
+const nextConfig: NextConfig = {
+ typescript: {
+ // Remove this. Build fails because of route types
+ ignoreBuildErrors: true,
+ },
+ experimental: {
+ serverActions: {
+ bodySizeLimit: '100mb',
+ },
+ },
+};
+
+export default nextConfig;
diff --git a/ui/package-lock.json b/ui/package-lock.json
new file mode 100644
index 0000000000000000000000000000000000000000..4c666bcfb7a64427740e88219a7c90d9f038a226
--- /dev/null
+++ b/ui/package-lock.json
@@ -0,0 +1,6125 @@
+{
+ "name": "ai-toolkit-ui",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ai-toolkit-ui",
+ "version": "0.1.0",
+ "dependencies": {
+ "@headlessui/react": "^2.2.0",
+ "@huggingface/hub": "^2.5.2",
+ "@monaco-editor/react": "^4.7.0",
+ "@prisma/client": "^6.3.1",
+ "archiver": "^7.0.1",
+ "axios": "^1.7.9",
+ "classnames": "^2.5.1",
+ "form-data": "^4.0.4",
+ "lucide-react": "^0.475.0",
+ "next": "15.1.7",
+ "node-cache": "^5.1.2",
+ "prisma": "^6.3.1",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "react-dropzone": "^14.3.5",
+ "react-global-hooks": "^1.3.5",
+ "react-icons": "^5.5.0",
+ "react-select": "^5.10.1",
+ "sqlite3": "^5.1.7",
+ "uuid": "^11.1.0",
+ "yaml": "^2.7.0"
+ },
+ "devDependencies": {
+ "@types/archiver": "^6.0.3",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "concurrently": "^9.1.2",
+ "postcss": "^8",
+ "prettier": "^3.5.1",
+ "prettier-basic": "^1.0.0",
+ "tailwindcss": "^3.4.1",
+ "ts-node-dev": "^2.0.0",
+ "typescript": "^5"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz",
+ "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==",
+ "dependencies": {
+ "@babel/parser": "^7.27.1",
+ "@babel/types": "^7.27.1",
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
+ "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+ "dependencies": {
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+ "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.27.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz",
+ "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==",
+ "dependencies": {
+ "@babel/types": "^7.27.1"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
+ "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.27.2",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
+ "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/parser": "^7.27.2",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz",
+ "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.27.1",
+ "@babel/parser": "^7.27.1",
+ "@babel/template": "^7.27.1",
+ "@babel/types": "^7.27.1",
+ "debug": "^4.3.1",
+ "globals": "^11.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
+ "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz",
+ "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emotion/babel-plugin": {
+ "version": "11.13.5",
+ "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
+ "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.16.7",
+ "@babel/runtime": "^7.18.3",
+ "@emotion/hash": "^0.9.2",
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/serialize": "^1.3.3",
+ "babel-plugin-macros": "^3.1.0",
+ "convert-source-map": "^1.5.0",
+ "escape-string-regexp": "^4.0.0",
+ "find-root": "^1.1.0",
+ "source-map": "^0.5.7",
+ "stylis": "4.2.0"
+ }
+ },
+ "node_modules/@emotion/cache": {
+ "version": "11.14.0",
+ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
+ "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
+ "dependencies": {
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/sheet": "^1.4.0",
+ "@emotion/utils": "^1.4.2",
+ "@emotion/weak-memoize": "^0.4.0",
+ "stylis": "4.2.0"
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.9.2",
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
+ "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="
+ },
+ "node_modules/@emotion/memoize": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
+ "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="
+ },
+ "node_modules/@emotion/react": {
+ "version": "11.14.0",
+ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
+ "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "@emotion/babel-plugin": "^11.13.5",
+ "@emotion/cache": "^11.14.0",
+ "@emotion/serialize": "^1.3.3",
+ "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
+ "@emotion/utils": "^1.4.2",
+ "@emotion/weak-memoize": "^0.4.0",
+ "hoist-non-react-statics": "^3.3.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@emotion/serialize": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
+ "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
+ "dependencies": {
+ "@emotion/hash": "^0.9.2",
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/unitless": "^0.10.0",
+ "@emotion/utils": "^1.4.2",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@emotion/sheet": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
+ "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="
+ },
+ "node_modules/@emotion/unitless": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
+ "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="
+ },
+ "node_modules/@emotion/use-insertion-effect-with-fallbacks": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
+ "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@emotion/utils": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
+ "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="
+ },
+ "node_modules/@emotion/weak-memoize": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
+ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.6.9",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
+ "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.9"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
+ "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
+ "dependencies": {
+ "@floating-ui/core": "^1.6.0",
+ "@floating-ui/utils": "^0.2.9"
+ }
+ },
+ "node_modules/@floating-ui/react": {
+ "version": "0.26.28",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
+ "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.1.2",
+ "@floating-ui/utils": "^0.2.8",
+ "tabbable": "^6.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
+ "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
+ "dependencies": {
+ "@floating-ui/dom": "^1.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
+ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="
+ },
+ "node_modules/@gar/promisify": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
+ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@headlessui/react": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz",
+ "integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==",
+ "dependencies": {
+ "@floating-ui/react": "^0.26.16",
+ "@react-aria/focus": "^3.17.1",
+ "@react-aria/interactions": "^3.21.3",
+ "@tanstack/react-virtual": "^3.8.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/@huggingface/hub": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/@huggingface/hub/-/hub-2.5.2.tgz",
+ "integrity": "sha512-/676dj3xLkvx2tx1whydX4G4Hvk+Tfzsn2keNJ6i5FZ93JdktBZjHOqzhneCcDVcvsObRXhSdEp3mWVXP0/sLQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@huggingface/tasks": "^0.19.37"
+ },
+ "bin": {
+ "hfjs": "dist/cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@huggingface/tasks": {
+ "version": "0.19.37",
+ "resolved": "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.19.37.tgz",
+ "integrity": "sha512-Te1VB1tB1HoLfTGluCwy8sLO90YV+uNOAFktQ1h7jKas4TlHT/7SlfwFaDJFTV8lN7qCw2nDB+7PRkKzwIb/hg==",
+ "license": "MIT"
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
+ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
+ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
+ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
+ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
+ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
+ "cpu": [
+ "arm"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
+ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
+ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
+ "cpu": [
+ "s390x"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
+ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
+ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
+ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
+ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
+ "cpu": [
+ "arm"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.0.5"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
+ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
+ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
+ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
+ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
+ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
+ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
+ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
+ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.8",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
+ "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
+ "dependencies": {
+ "@jridgewell/set-array": "^1.2.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
+ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
+ "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.25",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+ "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@monaco-editor/loader": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz",
+ "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==",
+ "dependencies": {
+ "state-local": "^1.0.6"
+ }
+ },
+ "node_modules/@monaco-editor/react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
+ "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
+ "dependencies": {
+ "@monaco-editor/loader": "^1.5.0"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.25.0 < 1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "15.1.7",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.7.tgz",
+ "integrity": "sha512-d9jnRrkuOH7Mhi+LHav2XW91HOgTAWHxjMPkXMGBc9B2b7614P7kjt8tAplRvJpbSt4nbO1lugcT/kAaWzjlLQ=="
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "15.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.7.tgz",
+ "integrity": "sha512-hPFwzPJDpA8FGj7IKV3Yf1web3oz2YsR8du4amKw8d+jAOHfYHYFpMkoF6vgSY4W6vB29RtZEklK9ayinGiCmQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "15.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.7.tgz",
+ "integrity": "sha512-2qoas+fO3OQKkU0PBUfwTiw/EYpN+kdAx62cePRyY1LqKtP09Vp5UcUntfZYajop5fDFTjSxCHfZVRxzi+9FYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "15.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.7.tgz",
+ "integrity": "sha512-sKLLwDX709mPdzxMnRIXLIT9zaX2w0GUlkLYQnKGoXeWUhcvpCrK+yevcwCJPdTdxZEUA0mOXGLdPsGkudGdnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "15.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.7.tgz",
+ "integrity": "sha512-zblK1OQbQWdC8fxdX4fpsHDw+VSpBPGEUX4PhSE9hkaWPrWoeIJn+baX53vbsbDRaDKd7bBNcXRovY1hEhFd7w==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "15.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.7.tgz",
+ "integrity": "sha512-GOzXutxuLvLHFDAPsMP2zDBMl1vfUHHpdNpFGhxu90jEzH6nNIgmtw/s1MDwpTOiM+MT5V8+I1hmVFeAUhkbgQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "15.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.7.tgz",
+ "integrity": "sha512-WrZ7jBhR7ATW1z5iEQ0ZJfE2twCNSXbpCSaAunF3BKcVeHFADSI/AW1y5Xt3DzTqPF1FzQlwQTewqetAABhZRQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "15.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.7.tgz",
+ "integrity": "sha512-LDnj1f3OVbou1BqvvXVqouJZKcwq++mV2F+oFHptToZtScIEnhNRJAhJzqAtTE2dB31qDYL45xJwrc+bLeKM2Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "15.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.7.tgz",
+ "integrity": "sha512-dC01f1quuf97viOfW05/K8XYv2iuBgAxJZl7mbCKEjMgdQl5JjAKJ0D2qMKZCgPWDeFbFT0Q0nYWwytEW0DWTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@npmcli/fs": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
+ "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "@gar/promisify": "^1.0.1",
+ "semver": "^7.3.5"
+ }
+ },
+ "node_modules/@npmcli/move-file": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz",
+ "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==",
+ "deprecated": "This functionality has been moved to @npmcli/fs",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "mkdirp": "^1.0.4",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@prisma/client": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.3.1.tgz",
+ "integrity": "sha512-ARAJaPs+eBkemdky/XU3cvGRl+mIPHCN2lCXsl5Vlb0E2gV+R6IN7aCI8CisRGszEZondwIsW9Iz8EJkTdykyA==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "peerDependencies": {
+ "prisma": "*",
+ "typescript": ">=5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.3.1.tgz",
+ "integrity": "sha512-RrEBkd+HLZx+ydfmYT0jUj7wjLiS95wfTOSQ+8FQbvb6vHh5AeKfEPt/XUQ5+Buljj8hltEfOslEW57/wQIVeA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.3.1.tgz",
+ "integrity": "sha512-sXdqEVLyGAJ5/iUoG/Ea5AdHMN71m6PzMBWRQnLmhhOejzqAaEr8rUd623ql6OJpED4s/U4vIn4dg1qkF7vGag==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.3.1",
+ "@prisma/engines-version": "6.3.0-17.acc0b9dd43eb689cbd20c9470515d719db10d0b0",
+ "@prisma/fetch-engine": "6.3.1",
+ "@prisma/get-platform": "6.3.1"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "6.3.0-17.acc0b9dd43eb689cbd20c9470515d719db10d0b0",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.3.0-17.acc0b9dd43eb689cbd20c9470515d719db10d0b0.tgz",
+ "integrity": "sha512-R/ZcMuaWZT2UBmgX3Ko6PAV3f8//ZzsjRIG1eKqp3f2rqEqVtCv+mtzuH2rBPUC9ujJ5kCb9wwpxeyCkLcHVyA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.3.1.tgz",
+ "integrity": "sha512-HOf/0umOgt+/S2xtZze+FHKoxpVg4YpVxROr6g2YG09VsI3Ipyb+rGvD6QGbCqkq5NTWAAZoOGNL+oy7t+IhaQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.3.1",
+ "@prisma/engines-version": "6.3.0-17.acc0b9dd43eb689cbd20c9470515d719db10d0b0",
+ "@prisma/get-platform": "6.3.1"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.3.1.tgz",
+ "integrity": "sha512-AYLq6Hk9xG73JdLWJ3Ip9Wg/vlP7xPvftGBalsPzKDOHr/ImhwJ09eS8xC2vNT12DlzGxhfk8BkL0ve2OriNhQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.3.1"
+ }
+ },
+ "node_modules/@react-aria/focus": {
+ "version": "3.19.1",
+ "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.19.1.tgz",
+ "integrity": "sha512-bix9Bu1Ue7RPcYmjwcjhB14BMu2qzfJ3tMQLqDc9pweJA66nOw8DThy3IfVr8Z7j2PHktOLf9kcbiZpydKHqzg==",
+ "dependencies": {
+ "@react-aria/interactions": "^3.23.0",
+ "@react-aria/utils": "^3.27.0",
+ "@react-types/shared": "^3.27.0",
+ "@swc/helpers": "^0.5.0",
+ "clsx": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-aria/interactions": {
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.23.0.tgz",
+ "integrity": "sha512-0qR1atBIWrb7FzQ+Tmr3s8uH5mQdyRH78n0krYaG8tng9+u1JlSi8DGRSaC9ezKyNB84m7vHT207xnHXGeJ3Fg==",
+ "dependencies": {
+ "@react-aria/ssr": "^3.9.7",
+ "@react-aria/utils": "^3.27.0",
+ "@react-types/shared": "^3.27.0",
+ "@swc/helpers": "^0.5.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-aria/ssr": {
+ "version": "3.9.7",
+ "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz",
+ "integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ },
+ "engines": {
+ "node": ">= 12"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-aria/utils": {
+ "version": "3.27.0",
+ "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.27.0.tgz",
+ "integrity": "sha512-p681OtApnKOdbeN8ITfnnYqfdHS0z7GE+4l8EXlfLnr70Rp/9xicBO6d2rU+V/B3JujDw2gPWxYKEnEeh0CGCw==",
+ "dependencies": {
+ "@react-aria/ssr": "^3.9.7",
+ "@react-stately/utils": "^3.10.5",
+ "@react-types/shared": "^3.27.0",
+ "@swc/helpers": "^0.5.0",
+ "clsx": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-stately/utils": {
+ "version": "3.10.5",
+ "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.5.tgz",
+ "integrity": "sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-types/shared": {
+ "version": "3.27.0",
+ "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.27.0.tgz",
+ "integrity": "sha512-gvznmLhi6JPEf0bsq7SwRYTHAKKq/wcmKqFez9sRdbED+SPMUmK5omfZ6w3EwUFQHbYUa4zPBYedQ7Knv70RMw==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.13.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.0.tgz",
+ "integrity": "sha512-CchF0NlLIowiM2GxtsoKBkXA4uqSnY2KvnXo+kyUFD4a4ll6+J0qzoRsUPMwXV/H26lRsxgJIr/YmjYum2oEjg==",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.13.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.13.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.0.tgz",
+ "integrity": "sha512-NBKJP3OIdmZY3COJdWkSonr50FMVIi+aj5ZJ7hI/DTpEKg2RMfo/KvP8A3B/zOSpMgIe52B5E2yn7rryULzA6g==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tootallnate/once": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
+ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/@tsconfig/node10": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
+ "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
+ "dev": true
+ },
+ "node_modules/@tsconfig/node12": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
+ "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
+ "dev": true
+ },
+ "node_modules/@tsconfig/node14": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
+ "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
+ "dev": true
+ },
+ "node_modules/@tsconfig/node16": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
+ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
+ "dev": true
+ },
+ "node_modules/@types/archiver": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz",
+ "integrity": "sha512-a6wUll6k3zX6qs5KlxIggs1P1JcYJaTCx2gnlr+f0S1yd2DoaEwoIK10HmBaLnZwWneBz+JBm0dwcZu0zECBcQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/readdir-glob": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.17.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.19.tgz",
+ "integrity": "sha512-LEwC7o1ifqg/6r2gn9Dns0f1rhK+fPFDoMiceTJ6kWmVk6bgXBI/9IOWfVan4WiAavK9pIVWdX0/e3J+eEUh5A==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~6.19.2"
+ }
+ },
+ "node_modules/@types/parse-json": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
+ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="
+ },
+ "node_modules/@types/react": {
+ "version": "19.0.10",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
+ "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
+ "dependencies": {
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.0.4",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz",
+ "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==",
+ "dev": true,
+ "peerDependencies": {
+ "@types/react": "^19.0.0"
+ }
+ },
+ "node_modules/@types/react-transition-group": {
+ "version": "4.4.12",
+ "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
+ "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/@types/readdir-glob": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz",
+ "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/strip-bom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz",
+ "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==",
+ "dev": true
+ },
+ "node_modules/@types/strip-json-comments": {
+ "version": "0.0.30",
+ "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz",
+ "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
+ "dev": true
+ },
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.4",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
+ "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+ "dev": true,
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/agentkeepalive": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+ "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "humanize-ms": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/aggregate-error": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+ "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "clean-stack": "^2.0.0",
+ "indent-string": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
+ "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+ "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/aproba": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
+ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/archiver": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
+ "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==",
+ "dependencies": {
+ "archiver-utils": "^5.0.2",
+ "async": "^3.2.4",
+ "buffer-crc32": "^1.0.0",
+ "readable-stream": "^4.0.0",
+ "readdir-glob": "^1.1.2",
+ "tar-stream": "^3.0.0",
+ "zip-stream": "^6.0.1"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/archiver-utils": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz",
+ "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==",
+ "dependencies": {
+ "glob": "^10.0.0",
+ "graceful-fs": "^4.2.0",
+ "is-stream": "^2.0.1",
+ "lazystream": "^1.0.0",
+ "lodash": "^4.17.15",
+ "normalize-path": "^3.0.0",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/archiver-utils/node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/archiver-utils/node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/archiver/node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/archiver/node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/archiver/node_modules/tar-stream": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
+ "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
+ "dependencies": {
+ "b4a": "^1.6.4",
+ "fast-fifo": "^1.2.0",
+ "streamx": "^2.15.0"
+ }
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz",
+ "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+ },
+ "node_modules/attr-accept": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
+ "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/axios": {
+ "version": "1.7.9",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
+ "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/b4a": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
+ "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg=="
+ },
+ "node_modules/babel-plugin-macros": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
+ "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "cosmiconfig": "^7.0.0",
+ "resolve": "^1.19.0"
+ },
+ "engines": {
+ "node": ">=10",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+ },
+ "node_modules/bare-events": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.1.tgz",
+ "integrity": "sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g==",
+ "optional": true
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
+ "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/cacache": {
+ "version": "15.3.0",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz",
+ "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "@npmcli/fs": "^1.0.0",
+ "@npmcli/move-file": "^1.0.1",
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "glob": "^7.1.4",
+ "infer-owner": "^1.0.4",
+ "lru-cache": "^6.0.0",
+ "minipass": "^3.1.1",
+ "minipass-collect": "^1.0.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.2",
+ "mkdirp": "^1.0.3",
+ "p-map": "^4.0.0",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^3.0.2",
+ "ssri": "^8.0.1",
+ "tar": "^6.0.2",
+ "unique-filename": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/cacache/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/cacache/node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/cacache/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/cacache/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/cacache/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001700",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
+ "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ]
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chalk/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/chalk/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/classnames": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
+ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
+ },
+ "node_modules/clean-stack": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "optional": true,
+ "dependencies": {
+ "color-convert": "^2.0.1",
+ "color-string": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=12.5.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+ },
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "optional": true,
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/compress-commons": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
+ "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==",
+ "dependencies": {
+ "crc-32": "^1.2.0",
+ "crc32-stream": "^6.0.0",
+ "is-stream": "^2.0.1",
+ "normalize-path": "^3.0.0",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/compress-commons/node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/compress-commons/node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/concurrently": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz",
+ "integrity": "sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==",
+ "dev": true,
+ "dependencies": {
+ "chalk": "^4.1.2",
+ "lodash": "^4.17.21",
+ "rxjs": "^7.8.1",
+ "shell-quote": "^1.8.1",
+ "supports-color": "^8.1.1",
+ "tree-kill": "^1.2.2",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "conc": "dist/bin/concurrently.js",
+ "concurrently": "dist/bin/concurrently.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+ }
+ },
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/convert-source-map": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
+ },
+ "node_modules/cosmiconfig": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
+ "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
+ "dependencies": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.2.1",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.10.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/cosmiconfig/node_modules/yaml": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/crc-32": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+ "bin": {
+ "crc32": "bin/crc32.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/crc32-stream": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz",
+ "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==",
+ "dependencies": {
+ "crc-32": "^1.2.0",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/crc32-stream/node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/crc32-stream/node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/create-require": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+ "dev": true
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+ },
+ "node_modules/debug": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+ "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/detect-libc": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
+ "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true
+ },
+ "node_modules/diff": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+ "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true
+ },
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/dynamic-dedupe": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz",
+ "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==",
+ "dev": true,
+ "dependencies": {
+ "xtend": "^4.0.0"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
+ },
+ "node_modules/encoding": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
+ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "^0.6.2"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+ "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/err-code": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
+ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+ "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "node_modules/error-ex/node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-fifo": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
+ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz",
+ "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==",
+ "dev": true,
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-selector": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
+ "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
+ "dependencies": {
+ "tslib": "^2.7.0"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-root": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
+ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.9",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
+ "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
+ "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
+ "dependencies": {
+ "cross-spawn": "^7.0.0",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs-minipass/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "devOptional": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gauge": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz",
+ "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.3",
+ "console-control-strings": "^1.1.0",
+ "has-unicode": "^2.0.1",
+ "signal-exit": "^3.0.7",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.5"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/gauge/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/gauge/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/gauge/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/gauge/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/gauge/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
+ "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.0",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/glob": {
+ "version": "10.4.5",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
+ "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "dependencies": {
+ "react-is": "^16.7.0"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
+ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
+ "license": "BSD-2-Clause",
+ "optional": true
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz",
+ "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tootallnate/once": "1",
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/humanize-ms": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.0.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/infer-owner": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
+ "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "devOptional": true,
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
+ "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "jsbn": "1.1.0",
+ "sprintf-js": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
+ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
+ "optional": true
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-lambda": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz",
+ "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+ },
+ "node_modules/jsbn": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
+ "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
+ },
+ "node_modules/lazystream": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
+ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
+ "dependencies": {
+ "readable-stream": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.6.3"
+ }
+ },
+ "node_modules/lazystream/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "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"
+ }
+ },
+ "node_modules/lazystream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
+ "node_modules/lazystream/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
+ },
+ "node_modules/lucide-react": {
+ "version": "0.475.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz",
+ "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/make-error": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+ "dev": true
+ },
+ "node_modules/make-fetch-happen": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz",
+ "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "agentkeepalive": "^4.1.3",
+ "cacache": "^15.2.0",
+ "http-cache-semantics": "^4.1.0",
+ "http-proxy-agent": "^4.0.1",
+ "https-proxy-agent": "^5.0.0",
+ "is-lambda": "^1.0.1",
+ "lru-cache": "^6.0.0",
+ "minipass": "^3.1.3",
+ "minipass-collect": "^1.0.2",
+ "minipass-fetch": "^1.3.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "negotiator": "^0.6.2",
+ "promise-retry": "^2.0.1",
+ "socks-proxy-agent": "^6.0.0",
+ "ssri": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/make-fetch-happen/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/make-fetch-happen/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minipass-collect": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz",
+ "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-collect/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-fetch": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz",
+ "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.1.0",
+ "minipass-sized": "^1.0.3",
+ "minizlib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "optionalDependencies": {
+ "encoding": "^0.1.12"
+ }
+ },
+ "node_modules/minipass-fetch/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-flush": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz",
+ "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-flush/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-pipeline": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
+ "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-pipeline/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-sized": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz",
+ "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-sized/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minizlib/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "license": "MIT",
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/monaco-editor": {
+ "version": "0.52.2",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
+ "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
+ "peer": true
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.8",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
+ "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/next": {
+ "version": "15.1.7",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.1.7.tgz",
+ "integrity": "sha512-GNeINPGS9c6OZKCvKypbL8GTsT5GhWPp4DM0fzkXJuXMilOO2EeFxuAY6JZbtk6XIl6Ws10ag3xRINDjSO5+wg==",
+ "dependencies": {
+ "@next/env": "15.1.7",
+ "@swc/counter": "0.1.3",
+ "@swc/helpers": "0.5.15",
+ "busboy": "1.6.0",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "15.1.7",
+ "@next/swc-darwin-x64": "15.1.7",
+ "@next/swc-linux-arm64-gnu": "15.1.7",
+ "@next/swc-linux-arm64-musl": "15.1.7",
+ "@next/swc-linux-x64-gnu": "15.1.7",
+ "@next/swc-linux-x64-musl": "15.1.7",
+ "@next/swc-win32-arm64-msvc": "15.1.7",
+ "@next/swc-win32-x64-msvc": "15.1.7",
+ "sharp": "^0.33.5"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.41.2",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/node-abi": {
+ "version": "3.74.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz",
+ "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "license": "MIT"
+ },
+ "node_modules/node-cache": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz",
+ "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==",
+ "dependencies": {
+ "clone": "2.x"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/node-gyp": {
+ "version": "8.4.1",
+ "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz",
+ "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "env-paths": "^2.2.0",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.2.6",
+ "make-fetch-happen": "^9.1.0",
+ "nopt": "^5.0.0",
+ "npmlog": "^6.0.0",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.5",
+ "tar": "^6.1.2",
+ "which": "^2.0.2"
+ },
+ "bin": {
+ "node-gyp": "bin/node-gyp.js"
+ },
+ "engines": {
+ "node": ">= 10.12.0"
+ }
+ },
+ "node_modules/node-gyp/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/node-gyp/node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/node-gyp/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/nopt": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "abbrev": "1"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/npmlog": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
+ "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "are-we-there-yet": "^3.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^4.0.3",
+ "set-blocking": "^2.0.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/p-map": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+ "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "aggregate-error": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
+ "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz",
+ "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.8",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
+ "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
+ "dev": true,
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
+ "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "lilconfig": "^3.0.0",
+ "yaml": "^2.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ },
+ "peerDependencies": {
+ "postcss": ">=8.0.9",
+ "ts-node": ">=9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "postcss": {
+ "optional": true
+ },
+ "ts-node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz",
+ "integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/prettier-basic": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/prettier-basic/-/prettier-basic-1.0.0.tgz",
+ "integrity": "sha512-cBAeJbegnXLEOUX9q+xU5l8zOehkZkR9dG4VSrN95hwRqBrdGCPzYmxG9ojdgxGuX7Y2hkqKZq9tlIeAvCvOAA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prisma": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.3.1.tgz",
+ "integrity": "sha512-JKCZWvBC3enxk51tY4TWzS4b5iRt4sSU1uHn2I183giZTvonXaQonzVtjLzpOHE7qu9MxY510kAtFGJwryKe3Q==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/engines": "6.3.1"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": ">=18.18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+ },
+ "node_modules/promise-inflight": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
+ "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/promise-retry": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
+ "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "err-code": "^2.0.2",
+ "retry": "^0.12.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+ },
+ "node_modules/pump": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
+ "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.0.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
+ "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.0.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
+ "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
+ "dependencies": {
+ "scheduler": "^0.25.0"
+ },
+ "peerDependencies": {
+ "react": "^19.0.0"
+ }
+ },
+ "node_modules/react-dropzone": {
+ "version": "14.3.5",
+ "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.5.tgz",
+ "integrity": "sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ==",
+ "dependencies": {
+ "attr-accept": "^2.2.4",
+ "file-selector": "^2.1.0",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">= 10.13"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8 || 18.0.0"
+ }
+ },
+ "node_modules/react-global-hooks": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/react-global-hooks/-/react-global-hooks-1.3.5.tgz",
+ "integrity": "sha512-xEvDSV6fkZ1ZAZ2qgrldw6d51awCtru6SzSVuWbrOi+tVIrGwroQLC2tdpFBYmszUCGOKi7UTuqOCYDyeJqvug==",
+ "peerDependencies": {
+ "react": "^16 || 17 || 18 || 19"
+ }
+ },
+ "node_modules/react-icons": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
+ "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+ },
+ "node_modules/react-select": {
+ "version": "5.10.1",
+ "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.1.tgz",
+ "integrity": "sha512-roPEZUL4aRZDx6DcsD+ZNreVl+fM8VsKn0Wtex1v4IazH60ILp5xhdlp464IsEAlJdXeD+BhDAFsBVMfvLQueA==",
+ "dependencies": {
+ "@babel/runtime": "^7.12.0",
+ "@emotion/cache": "^11.4.0",
+ "@emotion/react": "^11.8.1",
+ "@floating-ui/dom": "^1.0.1",
+ "@types/react-transition-group": "^4.4.0",
+ "memoize-one": "^6.0.0",
+ "prop-types": "^15.6.0",
+ "react-transition-group": "^4.3.0",
+ "use-isomorphic-layout-effect": "^1.2.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "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"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/readdir-glob": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
+ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
+ "dependencies": {
+ "minimatch": "^5.1.0"
+ }
+ },
+ "node_modules/readdir-glob/node_modules/minimatch": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+ "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.10",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
+ "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
+ "dependencies": {
+ "is-core-module": "^2.16.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true,
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rimraf/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/rimraf/node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rimraf/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/scheduler": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
+ "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="
+ },
+ "node_modules/semver": {
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
+ "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/sharp": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
+ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
+ "hasInstallScript": true,
+ "optional": true,
+ "dependencies": {
+ "color": "^4.2.3",
+ "detect-libc": "^2.0.3",
+ "semver": "^7.6.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.33.5",
+ "@img/sharp-darwin-x64": "0.33.5",
+ "@img/sharp-libvips-darwin-arm64": "1.0.4",
+ "@img/sharp-libvips-darwin-x64": "1.0.4",
+ "@img/sharp-libvips-linux-arm": "1.0.5",
+ "@img/sharp-libvips-linux-arm64": "1.0.4",
+ "@img/sharp-libvips-linux-s390x": "1.0.4",
+ "@img/sharp-libvips-linux-x64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
+ "@img/sharp-linux-arm": "0.33.5",
+ "@img/sharp-linux-arm64": "0.33.5",
+ "@img/sharp-linux-s390x": "0.33.5",
+ "@img/sharp-linux-x64": "0.33.5",
+ "@img/sharp-linuxmusl-arm64": "0.33.5",
+ "@img/sharp-linuxmusl-x64": "0.33.5",
+ "@img/sharp-wasm32": "0.33.5",
+ "@img/sharp-win32-ia32": "0.33.5",
+ "@img/sharp-win32-x64": "0.33.5"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shell-quote": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
+ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/simple-swizzle": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
+ "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
+ "optional": true,
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.4",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz",
+ "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ip-address": "^9.0.5",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz",
+ "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "^6.0.2",
+ "debug": "^4.3.3",
+ "socks": "^2.6.2"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/source-map-support/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/sqlite3": {
+ "version": "5.1.7",
+ "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz",
+ "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "node-addon-api": "^7.0.0",
+ "prebuild-install": "^7.1.1",
+ "tar": "^6.1.11"
+ },
+ "optionalDependencies": {
+ "node-gyp": "8.x"
+ },
+ "peerDependencies": {
+ "node-gyp": "8.x"
+ },
+ "peerDependenciesMeta": {
+ "node-gyp": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ssri": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
+ "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/ssri/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/state-local": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
+ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/streamx": {
+ "version": "2.22.1",
+ "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz",
+ "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==",
+ "dependencies": {
+ "fast-fifo": "^1.3.2",
+ "text-decoder": "^1.1.0"
+ },
+ "optionalDependencies": {
+ "bare-events": "^2.2.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-bom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/stylis": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
+ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.0",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
+ "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "glob": "^10.3.10",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tabbable": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
+ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.17",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
+ "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
+ "dev": true,
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.6",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tar": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+ "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
+ "license": "ISC",
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^5.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz",
+ "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-fs/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tar/node_modules/minipass": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/text-decoder": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
+ "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
+ "dependencies": {
+ "b4a": "^1.6.4"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true
+ },
+ "node_modules/ts-node-dev": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz",
+ "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==",
+ "dev": true,
+ "dependencies": {
+ "chokidar": "^3.5.1",
+ "dynamic-dedupe": "^0.3.0",
+ "minimist": "^1.2.6",
+ "mkdirp": "^1.0.4",
+ "resolve": "^1.0.0",
+ "rimraf": "^2.6.1",
+ "source-map-support": "^0.5.12",
+ "tree-kill": "^1.2.2",
+ "ts-node": "^10.4.0",
+ "tsconfig": "^7.0.0"
+ },
+ "bin": {
+ "ts-node-dev": "lib/bin.js",
+ "tsnd": "lib/bin.js"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "*",
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ts-node-dev/node_modules/arg": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+ "dev": true
+ },
+ "node_modules/ts-node-dev/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/ts-node-dev/node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "dev": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ts-node-dev/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ts-node-dev/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/ts-node-dev/node_modules/ts-node": {
+ "version": "10.9.2",
+ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
+ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
+ "dev": true,
+ "dependencies": {
+ "@cspotcode/source-map-support": "^0.8.0",
+ "@tsconfig/node10": "^1.0.7",
+ "@tsconfig/node12": "^1.0.7",
+ "@tsconfig/node14": "^1.0.0",
+ "@tsconfig/node16": "^1.0.2",
+ "acorn": "^8.4.1",
+ "acorn-walk": "^8.1.1",
+ "arg": "^4.1.0",
+ "create-require": "^1.1.0",
+ "diff": "^4.0.1",
+ "make-error": "^1.1.1",
+ "v8-compile-cache-lib": "^3.0.1",
+ "yn": "3.1.1"
+ },
+ "bin": {
+ "ts-node": "dist/bin.js",
+ "ts-node-cwd": "dist/bin-cwd.js",
+ "ts-node-esm": "dist/bin-esm.js",
+ "ts-node-script": "dist/bin-script.js",
+ "ts-node-transpile-only": "dist/bin-transpile.js",
+ "ts-script": "dist/bin-script-deprecated.js"
+ },
+ "peerDependencies": {
+ "@swc/core": ">=1.2.50",
+ "@swc/wasm": ">=1.2.50",
+ "@types/node": "*",
+ "typescript": ">=2.7"
+ },
+ "peerDependenciesMeta": {
+ "@swc/core": {
+ "optional": true
+ },
+ "@swc/wasm": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tsconfig": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz",
+ "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==",
+ "dev": true,
+ "dependencies": {
+ "@types/strip-bom": "^3.0.0",
+ "@types/strip-json-comments": "0.0.30",
+ "strip-bom": "^3.0.0",
+ "strip-json-comments": "^2.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.7.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
+ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
+ "devOptional": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.19.8",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
+ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
+ "dev": true
+ },
+ "node_modules/unique-filename": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",
+ "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "unique-slug": "^2.0.0"
+ }
+ },
+ "node_modules/unique-slug": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz",
+ "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "imurmurhash": "^0.1.4"
+ }
+ },
+ "node_modules/use-isomorphic-layout-effect": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz",
+ "integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+ },
+ "node_modules/uuid": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
+ "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "bin": {
+ "uuid": "dist/esm/bin/uuid"
+ }
+ },
+ "node_modules/v8-compile-cache-lib": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
+ "dev": true
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "node_modules/wide-align/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wide-align/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/wide-align/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wide-align/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
+ "node_modules/yaml": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
+ "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yn": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+ "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/zip-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
+ "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==",
+ "dependencies": {
+ "archiver-utils": "^5.0.0",
+ "compress-commons": "^6.0.2",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/zip-stream/node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/zip-stream/node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ }
+ }
+}
diff --git a/ui/package.json b/ui/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..37fd216aa35ac2454bb55970e0d61d5bf16e8b4b
--- /dev/null
+++ b/ui/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "ai-toolkit-ui",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "concurrently -k -n WORKER,UI \"ts-node-dev --respawn --watch cron --transpile-only cron/worker.ts\" \"next dev --turbopack\"",
+ "build": "tsc -p tsconfig.worker.json && next build",
+ "start": "concurrently --restart-tries -1 --restart-after 1000 -n WORKER,UI \"node dist/worker.js\" \"next start --port 8675\"",
+ "build_and_start": "npm install && npm run update_db && npm run build && npm run start",
+ "lint": "next lint",
+ "update_db": "npx prisma generate && npx prisma db push",
+ "format": "prettier --write \"**/*.{js,jsx,ts,tsx,css,scss}\""
+ },
+ "dependencies": {
+ "@headlessui/react": "^2.2.0",
+ "@huggingface/hub": "^2.5.2",
+ "@monaco-editor/react": "^4.7.0",
+ "@prisma/client": "^6.3.1",
+ "archiver": "^7.0.1",
+ "axios": "^1.7.9",
+ "classnames": "^2.5.1",
+ "form-data": "^4.0.4",
+ "lucide-react": "^0.475.0",
+ "next": "15.1.7",
+ "node-cache": "^5.1.2",
+ "prisma": "^6.3.1",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "react-dropzone": "^14.3.5",
+ "react-global-hooks": "^1.3.5",
+ "react-icons": "^5.5.0",
+ "react-select": "^5.10.1",
+ "sqlite3": "^5.1.7",
+ "uuid": "^11.1.0",
+ "yaml": "^2.7.0"
+ },
+ "devDependencies": {
+ "@types/archiver": "^6.0.3",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "concurrently": "^9.1.2",
+ "postcss": "^8",
+ "prettier": "^3.5.1",
+ "prettier-basic": "^1.0.0",
+ "tailwindcss": "^3.4.1",
+ "ts-node-dev": "^2.0.0",
+ "typescript": "^5"
+ },
+ "prettier": "prettier-basic"
+}
diff --git a/ui/postcss.config.mjs b/ui/postcss.config.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..1a69fd2a450afc3bf47e08b22c149190df0ffdb4
--- /dev/null
+++ b/ui/postcss.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+ plugins: {
+ tailwindcss: {},
+ },
+};
+
+export default config;
diff --git a/ui/prisma/schema.prisma b/ui/prisma/schema.prisma
new file mode 100644
index 0000000000000000000000000000000000000000..96f6399b770c75685c99f866efffd32b07cd14d8
--- /dev/null
+++ b/ui/prisma/schema.prisma
@@ -0,0 +1,38 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = "file:../../aitk_db.db"
+}
+
+model Settings {
+ id Int @id @default(autoincrement())
+ key String @unique
+ value String
+}
+
+model Job {
+ id String @id @default(uuid())
+ name String @unique
+ gpu_ids String
+ job_config String // JSON string
+ created_at DateTime @default(now())
+ updated_at DateTime @updatedAt
+ status String @default("stopped")
+ stop Boolean @default(false)
+ step Int @default(0)
+ info String @default("")
+ speed_string String @default("")
+}
+
+model Queue {
+ id String @id @default(uuid())
+ channel String
+ job_id String
+ created_at DateTime @default(now())
+ updated_at DateTime @updatedAt
+ status String @default("waiting")
+ @@index([job_id, channel])
+}
\ No newline at end of file
diff --git a/ui/public/file.svg b/ui/public/file.svg
new file mode 100644
index 0000000000000000000000000000000000000000..004145cddf3f9db91b57b9cb596683c8eb420862
--- /dev/null
+++ b/ui/public/file.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/public/globe.svg b/ui/public/globe.svg
new file mode 100644
index 0000000000000000000000000000000000000000..567f17b0d7c7fb662c16d4357dd74830caf2dccb
--- /dev/null
+++ b/ui/public/globe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/public/next.svg b/ui/public/next.svg
new file mode 100644
index 0000000000000000000000000000000000000000..5174b28c565c285e3e312ec5178be64fbeca8398
--- /dev/null
+++ b/ui/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/public/ostris_logo.png b/ui/public/ostris_logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..a8e24e4330b13c6cc3a2cd418e0b5789aa47df41
Binary files /dev/null and b/ui/public/ostris_logo.png differ
diff --git a/ui/public/vercel.svg b/ui/public/vercel.svg
new file mode 100644
index 0000000000000000000000000000000000000000..77053960334e2e34dc584dea8019925c3b4ccca9
--- /dev/null
+++ b/ui/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/public/web-app-manifest-192x192.png b/ui/public/web-app-manifest-192x192.png
new file mode 100644
index 0000000000000000000000000000000000000000..0cbc298157e73fe7e465fb59ae9122456a539ef9
Binary files /dev/null and b/ui/public/web-app-manifest-192x192.png differ
diff --git a/ui/public/web-app-manifest-512x512.png b/ui/public/web-app-manifest-512x512.png
new file mode 100644
index 0000000000000000000000000000000000000000..2966663af60e4b374145d8607113fd3f46cd79c4
Binary files /dev/null and b/ui/public/web-app-manifest-512x512.png differ
diff --git a/ui/public/window.svg b/ui/public/window.svg
new file mode 100644
index 0000000000000000000000000000000000000000..b2b2a44f6ebc70c450043c05a002e7a93ba5d651
--- /dev/null
+++ b/ui/public/window.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/.DS_Store b/ui/src/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..beccbe8c7661b5ffac6b0e47b8a9e5b75662030a
Binary files /dev/null and b/ui/src/.DS_Store differ
diff --git a/ui/src/app/.DS_Store b/ui/src/app/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..e9198493eb132abdd124fc985c5a0b98cf286813
Binary files /dev/null and b/ui/src/app/.DS_Store differ
diff --git a/ui/src/app/api/.DS_Store b/ui/src/app/api/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..9c53805bd6f88b15c6df6b53d3caf371a3323bbb
Binary files /dev/null and b/ui/src/app/api/.DS_Store differ
diff --git a/ui/src/app/api/auth/hf/callback/route.ts b/ui/src/app/api/auth/hf/callback/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..664fc12ee214e0d625173cec19ec12e196e70566
--- /dev/null
+++ b/ui/src/app/api/auth/hf/callback/route.ts
@@ -0,0 +1,112 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { cookies } from 'next/headers';
+
+const TOKEN_ENDPOINT = 'https://huggingface.co/oauth/token';
+const USERINFO_ENDPOINT = 'https://huggingface.co/oauth/userinfo';
+const STATE_COOKIE = 'hf_oauth_state';
+
+function htmlResponse(script: string) {
+ return new NextResponse(
+ `
`,
+ {
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ },
+ );
+}
+
+export async function GET(request: NextRequest) {
+ const clientId = process.env.HF_OAUTH_CLIENT_ID || process.env.NEXT_PUBLIC_HF_OAUTH_CLIENT_ID;
+ const clientSecret = process.env.HF_OAUTH_CLIENT_SECRET;
+
+ if (!clientId || !clientSecret) {
+ return NextResponse.json({ error: 'OAuth application is not configured' }, { status: 500 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const code = searchParams.get('code');
+ const incomingState = searchParams.get('state');
+
+ const cookieStore = cookies();
+ const storedState = cookieStore.get(STATE_COOKIE)?.value;
+
+ cookieStore.delete(STATE_COOKIE);
+
+ const origin = request.nextUrl.origin;
+
+ if (!code || !incomingState || !storedState || incomingState !== storedState) {
+ const script = `
+ window.opener && window.opener.postMessage({
+ type: 'HF_OAUTH_ERROR',
+ payload: { message: 'Invalid or expired OAuth state.' }
+ }, '${origin}');
+ window.close();
+ `;
+ return htmlResponse(script.trim());
+ }
+
+ const redirectUri = `${origin}/api/auth/hf/callback`;
+
+ try {
+ const tokenResponse = await fetch(TOKEN_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: redirectUri,
+ client_id: clientId,
+ client_secret: clientSecret,
+ }),
+ });
+
+ if (!tokenResponse.ok) {
+ const errorPayload = await tokenResponse.json().catch(() => ({}));
+ throw new Error(errorPayload?.error_description || 'Failed to exchange code for token');
+ }
+
+ const tokenData = await tokenResponse.json();
+ const accessToken = tokenData?.access_token;
+ if (!accessToken) {
+ throw new Error('Access token missing in response');
+ }
+
+ const userResponse = await fetch(USERINFO_ENDPOINT, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (!userResponse.ok) {
+ throw new Error('Failed to fetch user info');
+ }
+
+ const profile = await userResponse.json();
+ const namespace = profile?.preferred_username || profile?.name || 'user';
+
+ const script = `
+ window.opener && window.opener.postMessage({
+ type: 'HF_OAUTH_SUCCESS',
+ payload: {
+ token: ${JSON.stringify(accessToken)},
+ namespace: ${JSON.stringify(namespace)},
+ }
+ }, '${origin}');
+ window.close();
+ `;
+
+ return htmlResponse(script.trim());
+ } catch (error: any) {
+ const message = error?.message || 'OAuth flow failed';
+ const script = `
+ window.opener && window.opener.postMessage({
+ type: 'HF_OAUTH_ERROR',
+ payload: { message: ${JSON.stringify(message)} }
+ }, '${origin}');
+ window.close();
+ `;
+
+ return htmlResponse(script.trim());
+ }
+}
diff --git a/ui/src/app/api/auth/hf/login/route.ts b/ui/src/app/api/auth/hf/login/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..075237fe5a3dabd0b17224924f595170756d6a9b
--- /dev/null
+++ b/ui/src/app/api/auth/hf/login/route.ts
@@ -0,0 +1,36 @@
+import { randomUUID } from 'crypto';
+import { NextRequest, NextResponse } from 'next/server';
+
+const HF_AUTHORIZE_URL = 'https://huggingface.co/oauth/authorize';
+const STATE_COOKIE = 'hf_oauth_state';
+
+export async function GET(request: NextRequest) {
+ const clientId = process.env.HF_OAUTH_CLIENT_ID || process.env.NEXT_PUBLIC_HF_OAUTH_CLIENT_ID;
+ if (!clientId) {
+ return NextResponse.json({ error: 'OAuth client ID not configured' }, { status: 500 });
+ }
+
+ const state = randomUUID();
+ const origin = request.nextUrl.origin;
+ const redirectUri = `${origin}/api/auth/hf/callback`;
+
+ const authorizeUrl = new URL(HF_AUTHORIZE_URL);
+ authorizeUrl.searchParams.set('response_type', 'code');
+ authorizeUrl.searchParams.set('client_id', clientId);
+ authorizeUrl.searchParams.set('redirect_uri', redirectUri);
+ authorizeUrl.searchParams.set('scope', 'openid profile read-repos');
+ authorizeUrl.searchParams.set('state', state);
+
+ const response = NextResponse.redirect(authorizeUrl.toString(), { status: 302 });
+ response.cookies.set({
+ name: STATE_COOKIE,
+ value: state,
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: process.env.NODE_ENV === 'production',
+ maxAge: 60 * 5,
+ path: '/',
+ });
+
+ return response;
+}
diff --git a/ui/src/app/api/auth/hf/validate/route.ts b/ui/src/app/api/auth/hf/validate/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..32dc41fb4d3a7e82d8434ce577aa9e563c349203
--- /dev/null
+++ b/ui/src/app/api/auth/hf/validate/route.ts
@@ -0,0 +1,22 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { whoAmI } from '@huggingface/hub';
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json().catch(() => ({}));
+ const token = (body?.token || '').trim();
+
+ if (!token) {
+ return NextResponse.json({ error: 'Token is required' }, { status: 400 });
+ }
+
+ const info = await whoAmI({ accessToken: token });
+ return NextResponse.json({
+ name: info?.name || info?.username || 'user',
+ email: info?.email || null,
+ orgs: info?.orgs || [],
+ });
+ } catch (error: any) {
+ return NextResponse.json({ error: error?.message || 'Invalid token' }, { status: 401 });
+ }
+}
diff --git a/ui/src/app/api/auth/route.ts b/ui/src/app/api/auth/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1dc229739fbbeaabf307e3be544dd7e2bc8ab66f
--- /dev/null
+++ b/ui/src/app/api/auth/route.ts
@@ -0,0 +1,6 @@
+import { NextResponse } from 'next/server';
+
+export async function GET() {
+ // if this gets hit, auth has already been verified
+ return NextResponse.json({ isAuthenticated: true });
+}
diff --git a/ui/src/app/api/caption/get/route.ts b/ui/src/app/api/caption/get/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4f8d2818318805f97a80370e1a9cfc584cd9dc26
--- /dev/null
+++ b/ui/src/app/api/caption/get/route.ts
@@ -0,0 +1,46 @@
+/* eslint-disable */
+import { NextRequest, NextResponse } from 'next/server';
+import fs from 'fs';
+import path from 'path';
+import { getDatasetsRoot } from '@/server/settings';
+
+export async function POST(request: NextRequest) {
+
+ const body = await request.json();
+ const { imgPath } = body;
+ console.log('Received POST request for caption:', imgPath);
+ try {
+ // Decode the path
+ const filepath = imgPath;
+ console.log('Decoded image path:', filepath);
+
+ // caption name is the filepath without extension but with .txt
+ const captionPath = filepath.replace(/\.[^/.]+$/, '') + '.txt';
+
+ // Get allowed directories
+ const allowedDir = await getDatasetsRoot();
+
+ // Security check: Ensure path is in allowed directory
+ const isAllowed = filepath.startsWith(allowedDir) && !filepath.includes('..');
+
+ if (!isAllowed) {
+ console.warn(`Access denied: ${filepath} not in ${allowedDir}`);
+ return new NextResponse('Access denied', { status: 403 });
+ }
+
+ // Check if file exists
+ if (!fs.existsSync(captionPath)) {
+ // send back blank string if caption file does not exist
+ return new NextResponse('');
+ }
+
+ // Read caption file
+ const caption = fs.readFileSync(captionPath, 'utf-8');
+
+ // Return caption
+ return new NextResponse(caption);
+ } catch (error) {
+ console.error('Error getting caption:', error);
+ return new NextResponse('Error getting caption', { status: 500 });
+ }
+}
diff --git a/ui/src/app/api/datasets/create/route.tsx b/ui/src/app/api/datasets/create/route.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e005d058f3423db41f4830b69a1d51c7872d1351
--- /dev/null
+++ b/ui/src/app/api/datasets/create/route.tsx
@@ -0,0 +1,25 @@
+import { NextResponse } from 'next/server';
+import fs from 'fs';
+import path from 'path';
+import { getDatasetsRoot } from '@/server/settings';
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ let { name } = body;
+ // clean name by making lower case, removing special characters, and replacing spaces with underscores
+ name = name.toLowerCase().replace(/[^a-z0-9]+/g, '_');
+
+ let datasetsPath = await getDatasetsRoot();
+ let datasetPath = path.join(datasetsPath, name);
+
+ // if folder doesnt exist, create it
+ if (!fs.existsSync(datasetPath)) {
+ fs.mkdirSync(datasetPath);
+ }
+
+ return NextResponse.json({ success: true, name: name, path: datasetPath });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to create dataset' }, { status: 500 });
+ }
+}
diff --git a/ui/src/app/api/datasets/delete/route.tsx b/ui/src/app/api/datasets/delete/route.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9a1d970ee415c9d040596854ce74ad5401859259
--- /dev/null
+++ b/ui/src/app/api/datasets/delete/route.tsx
@@ -0,0 +1,24 @@
+import { NextResponse } from 'next/server';
+import fs from 'fs';
+import path from 'path';
+import { getDatasetsRoot } from '@/server/settings';
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const { name } = body;
+ let datasetsPath = await getDatasetsRoot();
+ let datasetPath = path.join(datasetsPath, name);
+
+ // if folder doesnt exist, ignore
+ if (!fs.existsSync(datasetPath)) {
+ return NextResponse.json({ success: true });
+ }
+
+ // delete it and return success
+ fs.rmdirSync(datasetPath, { recursive: true });
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to create dataset' }, { status: 500 });
+ }
+}
diff --git a/ui/src/app/api/datasets/list/route.ts b/ui/src/app/api/datasets/list/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dc829c65f3cab2829221f85341967fc1b52a921c
--- /dev/null
+++ b/ui/src/app/api/datasets/list/route.ts
@@ -0,0 +1,25 @@
+import { NextResponse } from 'next/server';
+import fs from 'fs';
+import { getDatasetsRoot } from '@/server/settings';
+
+export async function GET() {
+ try {
+ let datasetsPath = await getDatasetsRoot();
+
+ // if folder doesnt exist, create it
+ if (!fs.existsSync(datasetsPath)) {
+ fs.mkdirSync(datasetsPath);
+ }
+
+ // find all the folders in the datasets folder
+ let folders = fs
+ .readdirSync(datasetsPath, { withFileTypes: true })
+ .filter(dirent => dirent.isDirectory())
+ .filter(dirent => !dirent.name.startsWith('.'))
+ .map(dirent => dirent.name);
+
+ return NextResponse.json(folders);
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to fetch datasets' }, { status: 500 });
+ }
+}
diff --git a/ui/src/app/api/datasets/listImages/route.ts b/ui/src/app/api/datasets/listImages/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..06dca84ae780c7fddb200fc6de422b7a42e309ea
--- /dev/null
+++ b/ui/src/app/api/datasets/listImages/route.ts
@@ -0,0 +1,61 @@
+import { NextResponse } from 'next/server';
+import fs from 'fs';
+import path from 'path';
+import { getDatasetsRoot } from '@/server/settings';
+
+export async function POST(request: Request) {
+ const datasetsPath = await getDatasetsRoot();
+ const body = await request.json();
+ const { datasetName } = body;
+ const datasetFolder = path.join(datasetsPath, datasetName);
+
+ try {
+ // Check if folder exists
+ if (!fs.existsSync(datasetFolder)) {
+ return NextResponse.json({ error: `Folder '${datasetName}' not found` }, { status: 404 });
+ }
+
+ // Find all images recursively
+ const imageFiles = findImagesRecursively(datasetFolder);
+
+ // Format response
+ const result = imageFiles.map(imgPath => ({
+ img_path: imgPath,
+ }));
+
+ return NextResponse.json({ images: result });
+ } catch (error) {
+ console.error('Error finding images:', error);
+ return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
+ }
+}
+
+/**
+ * Recursively finds all image files in a directory and its subdirectories
+ * @param dir Directory to search
+ * @returns Array of absolute paths to image files
+ */
+function findImagesRecursively(dir: string): string[] {
+ const imageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'];
+ let results: string[] = [];
+
+ const items = fs.readdirSync(dir);
+
+ for (const item of items) {
+ const itemPath = path.join(dir, item);
+ const stat = fs.statSync(itemPath);
+
+ if (stat.isDirectory() && item !== '_controls' && !item.startsWith('.')) {
+ // If it's a directory, recursively search it
+ results = results.concat(findImagesRecursively(itemPath));
+ } else {
+ // If it's a file, check if it's an image
+ const ext = path.extname(itemPath).toLowerCase();
+ if (imageExtensions.includes(ext)) {
+ results.push(itemPath);
+ }
+ }
+ }
+
+ return results;
+}
diff --git a/ui/src/app/api/datasets/upload/route.ts b/ui/src/app/api/datasets/upload/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..51aff81fd3bf4b091f10a1df9f2da887910f4753
--- /dev/null
+++ b/ui/src/app/api/datasets/upload/route.ts
@@ -0,0 +1,57 @@
+// src/app/api/datasets/upload/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { writeFile, mkdir } from 'fs/promises';
+import { join } from 'path';
+import { getDatasetsRoot } from '@/server/settings';
+
+export async function POST(request: NextRequest) {
+ try {
+ const datasetsPath = await getDatasetsRoot();
+ if (!datasetsPath) {
+ return NextResponse.json({ error: 'Datasets path not found' }, { status: 500 });
+ }
+ const formData = await request.formData();
+ const files = formData.getAll('files');
+ const datasetName = formData.get('datasetName') as string;
+
+ if (!files || files.length === 0) {
+ return NextResponse.json({ error: 'No files provided' }, { status: 400 });
+ }
+
+ // Create upload directory if it doesn't exist
+ const uploadDir = join(datasetsPath, datasetName);
+ await mkdir(uploadDir, { recursive: true });
+
+ const savedFiles: string[] = [];
+
+ // Process files sequentially to avoid overwhelming the system
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i] as any;
+ const bytes = await file.arrayBuffer();
+ const buffer = Buffer.from(bytes);
+
+ // Clean filename and ensure it's unique
+ const fileName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
+ const filePath = join(uploadDir, fileName);
+
+ await writeFile(filePath, buffer);
+ savedFiles.push(fileName);
+ }
+
+ return NextResponse.json({
+ message: 'Files uploaded successfully',
+ files: savedFiles,
+ });
+ } catch (error) {
+ console.error('Upload error:', error);
+ return NextResponse.json({ error: 'Error uploading files' }, { status: 500 });
+ }
+}
+
+// Increase payload size limit (default is 4mb)
+export const config = {
+ api: {
+ bodyParser: false,
+ responseLimit: '50mb',
+ },
+};
diff --git a/ui/src/app/api/files/[...filePath]/route.ts b/ui/src/app/api/files/[...filePath]/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..46eb5c4ab08b9c02ba4ff8d0fe7f6dc2cd15442a
--- /dev/null
+++ b/ui/src/app/api/files/[...filePath]/route.ts
@@ -0,0 +1,116 @@
+/* eslint-disable */
+import { NextRequest, NextResponse } from 'next/server';
+import fs from 'fs';
+import path from 'path';
+import { getDatasetsRoot, getTrainingFolder } from '@/server/settings';
+
+export async function GET(request: NextRequest, { params }: { params: { filePath: string } }) {
+ const { filePath } = await params;
+ try {
+ // Decode the path
+ const decodedFilePath = decodeURIComponent(filePath);
+
+ // Get allowed directories
+ const datasetRoot = await getDatasetsRoot();
+ const trainingRoot = await getTrainingFolder();
+ const allowedDirs = [datasetRoot, trainingRoot];
+
+ // Security check: Ensure path is in allowed directory
+ const isAllowed =
+ allowedDirs.some(allowedDir => decodedFilePath.startsWith(allowedDir)) && !decodedFilePath.includes('..');
+
+ if (!isAllowed) {
+ console.warn(`Access denied: ${decodedFilePath} not in ${allowedDirs.join(', ')}`);
+ return new NextResponse('Access denied', { status: 403 });
+ }
+
+ // Check if file exists
+ if (!fs.existsSync(decodedFilePath)) {
+ console.warn(`File not found: ${decodedFilePath}`);
+ return new NextResponse('File not found', { status: 404 });
+ }
+
+ // Get file info
+ const stat = fs.statSync(decodedFilePath);
+ if (!stat.isFile()) {
+ return new NextResponse('Not a file', { status: 400 });
+ }
+
+ // Get filename for Content-Disposition
+ const filename = path.basename(decodedFilePath);
+
+ // Determine content type
+ const ext = path.extname(decodedFilePath).toLowerCase();
+ const contentTypeMap: { [key: string]: string } = {
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.png': 'image/png',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.svg': 'image/svg+xml',
+ '.bmp': 'image/bmp',
+ '.safetensors': 'application/octet-stream',
+ '.zip': 'application/zip',
+ // Videos
+ '.mp4': 'video/mp4',
+ '.avi': 'video/x-msvideo',
+ '.mov': 'video/quicktime',
+ '.mkv': 'video/x-matroska',
+ '.wmv': 'video/x-ms-wmv',
+ '.m4v': 'video/x-m4v',
+ '.flv': 'video/x-flv'
+ };
+
+ const contentType = contentTypeMap[ext] || 'application/octet-stream';
+
+ // Get range header for partial content support
+ const range = request.headers.get('range');
+
+ // Common headers for better download handling
+ const commonHeaders = {
+ 'Content-Type': contentType,
+ 'Accept-Ranges': 'bytes',
+ 'Cache-Control': 'public, max-age=86400',
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
+ 'X-Content-Type-Options': 'nosniff',
+ };
+
+ if (range) {
+ // Parse range header
+ const parts = range.replace(/bytes=/, '').split('-');
+ const start = parseInt(parts[0], 10);
+ const end = parts[1] ? parseInt(parts[1], 10) : Math.min(start + 10 * 1024 * 1024, stat.size - 1); // 10MB chunks
+ const chunkSize = end - start + 1;
+
+ const fileStream = fs.createReadStream(decodedFilePath, {
+ start,
+ end,
+ highWaterMark: 64 * 1024, // 64KB buffer
+ });
+
+ return new NextResponse(fileStream as any, {
+ status: 206,
+ headers: {
+ ...commonHeaders,
+ 'Content-Range': `bytes ${start}-${end}/${stat.size}`,
+ 'Content-Length': String(chunkSize),
+ },
+ });
+ } else {
+ // For full file download, read directly without streaming wrapper
+ const fileStream = fs.createReadStream(decodedFilePath, {
+ highWaterMark: 64 * 1024, // 64KB buffer
+ });
+
+ return new NextResponse(fileStream as any, {
+ headers: {
+ ...commonHeaders,
+ 'Content-Length': String(stat.size),
+ },
+ });
+ }
+ } catch (error) {
+ console.error('Error serving file:', error);
+ return new NextResponse('Internal Server Error', { status: 500 });
+ }
+}
diff --git a/ui/src/app/api/gpu/route.ts b/ui/src/app/api/gpu/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8b11dbb0e6d8e8de0f191bb1e78bb8687376881a
--- /dev/null
+++ b/ui/src/app/api/gpu/route.ts
@@ -0,0 +1,121 @@
+import { NextResponse } from 'next/server';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import os from 'os';
+
+const execAsync = promisify(exec);
+
+export async function GET() {
+ try {
+ // Get platform
+ const platform = os.platform();
+ const isWindows = platform === 'win32';
+
+ // Check if nvidia-smi is available
+ const hasNvidiaSmi = await checkNvidiaSmi(isWindows);
+
+ if (!hasNvidiaSmi) {
+ return NextResponse.json({
+ hasNvidiaSmi: false,
+ gpus: [],
+ error: 'nvidia-smi not found or not accessible',
+ });
+ }
+
+ // Get GPU stats
+ const gpuStats = await getGpuStats(isWindows);
+
+ return NextResponse.json({
+ hasNvidiaSmi: true,
+ gpus: gpuStats,
+ });
+ } catch (error) {
+ console.error('Error fetching NVIDIA GPU stats:', error);
+ return NextResponse.json(
+ {
+ hasNvidiaSmi: false,
+ gpus: [],
+ error: `Failed to fetch GPU stats: ${error instanceof Error ? error.message : String(error)}`,
+ },
+ { status: 500 },
+ );
+ }
+}
+
+async function checkNvidiaSmi(isWindows: boolean): Promise {
+ try {
+ if (isWindows) {
+ // Check if nvidia-smi is available on Windows
+ // It's typically located in C:\Program Files\NVIDIA Corporation\NVSMI\nvidia-smi.exe
+ // but we'll just try to run it directly as it may be in PATH
+ await execAsync('nvidia-smi -L');
+ } else {
+ // Linux/macOS check
+ await execAsync('which nvidia-smi');
+ }
+ return true;
+ } catch (error) {
+ return false;
+ }
+}
+
+async function getGpuStats(isWindows: boolean) {
+ // Command is the same for both platforms, but the path might be different
+ const command =
+ 'nvidia-smi --query-gpu=index,name,driver_version,temperature.gpu,utilization.gpu,utilization.memory,memory.total,memory.free,memory.used,power.draw,power.limit,clocks.current.graphics,clocks.current.memory,fan.speed --format=csv,noheader,nounits';
+
+ // Execute command
+ const { stdout } = await execAsync(command);
+
+ // Parse CSV output
+ const gpus = stdout
+ .trim()
+ .split('\n')
+ .map(line => {
+ const [
+ index,
+ name,
+ driverVersion,
+ temperature,
+ gpuUtil,
+ memoryUtil,
+ memoryTotal,
+ memoryFree,
+ memoryUsed,
+ powerDraw,
+ powerLimit,
+ clockGraphics,
+ clockMemory,
+ fanSpeed,
+ ] = line.split(', ').map(item => item.trim());
+
+ return {
+ index: parseInt(index),
+ name,
+ driverVersion,
+ temperature: parseInt(temperature),
+ utilization: {
+ gpu: parseInt(gpuUtil),
+ memory: parseInt(memoryUtil),
+ },
+ memory: {
+ total: parseInt(memoryTotal),
+ free: parseInt(memoryFree),
+ used: parseInt(memoryUsed),
+ },
+ power: {
+ draw: parseFloat(powerDraw),
+ limit: parseFloat(powerLimit),
+ },
+ clocks: {
+ graphics: parseInt(clockGraphics),
+ memory: parseInt(clockMemory),
+ },
+ fan: {
+ speed: parseInt(fanSpeed) || 0, // Some GPUs might not report fan speed, default to 0
+ },
+ };
+ });
+
+ return gpus;
+}
diff --git a/ui/src/app/api/hf-hub/route.ts b/ui/src/app/api/hf-hub/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..afdfb64c599b6fbad3c832d7450176ba3ca2b2c0
--- /dev/null
+++ b/ui/src/app/api/hf-hub/route.ts
@@ -0,0 +1,165 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { whoAmI, createRepo, uploadFiles, datasetInfo } from '@huggingface/hub';
+import { readdir, stat } from 'fs/promises';
+import path from 'path';
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { action, token, namespace, datasetName, datasetPath, datasetId } = body;
+
+ if (!token) {
+ return NextResponse.json({ error: 'HF token is required' }, { status: 400 });
+ }
+
+ switch (action) {
+ case 'whoami':
+ try {
+ const user = await whoAmI({ accessToken: token });
+ return NextResponse.json({ user });
+ } catch (error) {
+ return NextResponse.json({ error: 'Invalid token or network error' }, { status: 401 });
+ }
+
+ case 'createDataset':
+ try {
+ if (!namespace || !datasetName) {
+ return NextResponse.json({ error: 'Namespace and dataset name required' }, { status: 400 });
+ }
+
+ const repoId = `datasets/${namespace}/${datasetName}`;
+
+ // Create repository
+ await createRepo({
+ repo: repoId,
+ accessToken: token,
+ private: false,
+ });
+
+ return NextResponse.json({ success: true, repoId });
+ } catch (error: any) {
+ if (error.message?.includes('already exists')) {
+ return NextResponse.json({ success: true, repoId: `${namespace}/${datasetName}`, exists: true });
+ }
+ return NextResponse.json({ error: error.message || 'Failed to create dataset' }, { status: 500 });
+ }
+
+ case 'uploadDataset':
+ try {
+ if (!namespace || !datasetName || !datasetPath) {
+ return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
+ }
+
+ const repoId = `datasets/${namespace}/${datasetName}`;
+
+ // Check if directory exists
+ try {
+ await stat(datasetPath);
+ } catch {
+ return NextResponse.json({ error: 'Dataset path does not exist' }, { status: 400 });
+ }
+
+ // Read files from directory and upload them
+ const files = await readdir(datasetPath);
+ const filesToUpload = [];
+
+ for (const fileName of files) {
+ const filePath = path.join(datasetPath, fileName);
+ const fileStats = await stat(filePath);
+
+ if (fileStats.isFile()) {
+ filesToUpload.push({
+ path: fileName,
+ content: new URL(`file://${filePath}`)
+ });
+ }
+ }
+
+ if (filesToUpload.length > 0) {
+ await uploadFiles({
+ repo: repoId,
+ accessToken: token,
+ files: filesToUpload,
+ });
+ }
+
+ return NextResponse.json({ success: true, repoId });
+ } catch (error: any) {
+ console.error('Upload error:', error);
+ return NextResponse.json({ error: error.message || 'Failed to upload dataset' }, { status: 500 });
+ }
+
+ case 'listFiles':
+ try {
+ if (!datasetPath) {
+ return NextResponse.json({ error: 'Dataset path required' }, { status: 400 });
+ }
+
+ const files = await readdir(datasetPath, { withFileTypes: true });
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.bmp'];
+
+ const imageFiles = files
+ .filter(file => file.isFile())
+ .filter(file => imageExtensions.some(ext => file.name.toLowerCase().endsWith(ext)))
+ .map(file => ({
+ name: file.name,
+ path: path.join(datasetPath, file.name),
+ }));
+
+ const captionFiles = files
+ .filter(file => file.isFile())
+ .filter(file => file.name.endsWith('.txt'))
+ .map(file => ({
+ name: file.name,
+ path: path.join(datasetPath, file.name),
+ }));
+
+ return NextResponse.json({
+ images: imageFiles,
+ captions: captionFiles,
+ total: imageFiles.length
+ });
+ } catch (error: any) {
+ return NextResponse.json({ error: error.message || 'Failed to list files' }, { status: 500 });
+ }
+
+ case 'validateDataset':
+ try {
+ if (!datasetId) {
+ return NextResponse.json({ error: 'Dataset ID required' }, { status: 400 });
+ }
+
+ // Try to get dataset info to validate it exists and is accessible
+ const dataset = await datasetInfo({
+ name: datasetId,
+ accessToken: token,
+ });
+
+ return NextResponse.json({
+ exists: true,
+ dataset: {
+ id: dataset.id,
+ author: dataset.author,
+ downloads: dataset.downloads,
+ likes: dataset.likes,
+ private: dataset.private,
+ }
+ });
+ } catch (error: any) {
+ if (error.message?.includes('404') || error.message?.includes('not found')) {
+ return NextResponse.json({ exists: false }, { status: 200 });
+ }
+ if (error.message?.includes('401') || error.message?.includes('403')) {
+ return NextResponse.json({ error: 'Dataset not accessible with current token' }, { status: 403 });
+ }
+ return NextResponse.json({ error: error.message || 'Failed to validate dataset' }, { status: 500 });
+ }
+
+ default:
+ return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
+ }
+ } catch (error: any) {
+ console.error('HF Hub API error:', error);
+ return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/ui/src/app/api/hf-jobs/route.ts b/ui/src/app/api/hf-jobs/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..12fe64374cc3552584f8f9fdbe2948fa47996b62
--- /dev/null
+++ b/ui/src/app/api/hf-jobs/route.ts
@@ -0,0 +1,761 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { spawn } from 'child_process';
+import { writeFile } from 'fs/promises';
+import path from 'path';
+import { tmpdir } from 'os';
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { action, token, hardware, namespace, jobConfig, datasetRepo } = body;
+
+ switch (action) {
+ case 'checkStatus':
+ try {
+ if (!token || !jobConfig?.hf_job_id) {
+ return NextResponse.json({ error: 'Token and job ID required' }, { status: 400 });
+ }
+
+ const jobStatus = await checkHFJobStatus(token, jobConfig.hf_job_id);
+ return NextResponse.json({ status: jobStatus });
+ } catch (error: any) {
+ console.error('Job status check error:', error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+
+ case 'generateScript':
+ try {
+ const uvScript = generateUVScript({
+ jobConfig,
+ datasetRepo,
+ namespace,
+ token: token || 'YOUR_HF_TOKEN',
+ });
+
+ return NextResponse.json({
+ script: uvScript,
+ filename: `train_${jobConfig.config.name.replace(/[^a-zA-Z0-9]/g, '_')}.py`
+ });
+ } catch (error: any) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+
+ case 'submitJob':
+ try {
+ if (!token || !hardware) {
+ return NextResponse.json({ error: 'Token and hardware required' }, { status: 400 });
+ }
+
+ // Generate UV script
+ const uvScript = generateUVScript({
+ jobConfig,
+ datasetRepo,
+ namespace,
+ token,
+ });
+
+ // Write script to temporary file
+ const scriptPath = path.join(tmpdir(), `train_${Date.now()}.py`);
+ await writeFile(scriptPath, uvScript);
+
+ // Submit HF job using uv run
+ const jobId = await submitHFJobUV(token, hardware, scriptPath);
+
+ return NextResponse.json({
+ success: true,
+ jobId,
+ message: `Job submitted successfully with ID: ${jobId}`
+ });
+ } catch (error: any) {
+ console.error('Job submission error:', error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+
+ default:
+ return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
+ }
+ } catch (error: any) {
+ console.error('HF Jobs API error:', error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
+
+function generateUVScript({ jobConfig, datasetRepo, namespace, token }: {
+ jobConfig: any;
+ datasetRepo: string;
+ namespace: string;
+ token: string;
+}) {
+ const config = jobConfig.config;
+ const process = config.process[0];
+
+ return `# /// script
+# dependencies = [
+# "torch>=2.0.0",
+# "torchvision",
+# "torchao==0.10.0",
+# "safetensors",
+# "diffusers @ git+https://github.com/huggingface/diffusers@7a2b78bf0f788d311cc96b61e660a8e13e3b1e63",
+# "transformers==4.52.4",
+# "lycoris-lora==1.8.3",
+# "flatten_json",
+# "pyyaml",
+# "oyaml",
+# "tensorboard",
+# "kornia",
+# "invisible-watermark",
+# "einops",
+# "accelerate",
+# "toml",
+# "albumentations==1.4.15",
+# "albucore==0.0.16",
+# "pydantic",
+# "omegaconf",
+# "k-diffusion",
+# "open_clip_torch",
+# "timm",
+# "prodigyopt",
+# "controlnet_aux==0.0.10",
+# "python-dotenv",
+# "bitsandbytes",
+# "hf_transfer",
+# "lpips",
+# "pytorch_fid",
+# "optimum-quanto==0.2.4",
+# "sentencepiece",
+# "huggingface_hub",
+# "peft",
+# "python-slugify",
+# "opencv-python-headless",
+# "pytorch-wavelets==1.3.0",
+# "matplotlib==3.10.1",
+# "setuptools==69.5.1",
+# "datasets==4.0.0",
+# "pyarrow==20.0.0",
+# "pillow",
+# "ftfy",
+# ]
+# ///
+
+import os
+import sys
+import subprocess
+import argparse
+import oyaml as yaml
+from datasets import load_dataset
+from huggingface_hub import HfApi, create_repo, upload_folder, snapshot_download
+import tempfile
+import shutil
+import glob
+from PIL import Image
+
+def setup_ai_toolkit():
+ """Clone and setup ai-toolkit repository"""
+ repo_dir = "ai-toolkit"
+ if not os.path.exists(repo_dir):
+ print("Cloning ai-toolkit repository...")
+ subprocess.run(
+ ["git", "clone", "https://github.com/ostris/ai-toolkit.git", repo_dir],
+ check=True
+ )
+ sys.path.insert(0, os.path.abspath(repo_dir))
+ return repo_dir
+
+def download_dataset(dataset_repo: str, local_path: str):
+ """Download dataset from HF Hub as files"""
+ print(f"Downloading dataset from {dataset_repo}...")
+
+ # Create local dataset directory
+ os.makedirs(local_path, exist_ok=True)
+
+ # Use snapshot_download to get the dataset files directly
+ from huggingface_hub import snapshot_download
+
+ try:
+ # First try to download as a structured dataset
+ dataset = load_dataset(dataset_repo, split="train")
+
+ # Download images and captions from structured dataset
+ for i, item in enumerate(dataset):
+ # Save image
+ if "image" in item:
+ image_path = os.path.join(local_path, f"image_{i:06d}.jpg")
+ image = item["image"]
+
+ # Convert RGBA to RGB if necessary (for JPEG compatibility)
+ if image.mode == 'RGBA':
+ # Create a white background and paste the RGBA image on it
+ background = Image.new('RGB', image.size, (255, 255, 255))
+ background.paste(image, mask=image.split()[-1]) # Use alpha channel as mask
+ image = background
+ elif image.mode not in ['RGB', 'L']:
+ # Convert any other mode to RGB
+ image = image.convert('RGB')
+
+ image.save(image_path, 'JPEG')
+
+ # Save caption
+ if "text" in item:
+ caption_path = os.path.join(local_path, f"image_{i:06d}.txt")
+ with open(caption_path, "w", encoding="utf-8") as f:
+ f.write(item["text"])
+
+ print(f"Downloaded {len(dataset)} items to {local_path}")
+
+ except Exception as e:
+ print(f"Failed to load as structured dataset: {e}")
+ print("Attempting to download raw files...")
+
+ # Download the dataset repository as files
+ temp_repo_path = snapshot_download(repo_id=dataset_repo, repo_type="dataset")
+
+ # Copy all image and text files to the local path
+ import glob
+ import shutil
+
+ print(f"Downloaded repo to: {temp_repo_path}")
+ print(f"Contents: {os.listdir(temp_repo_path)}")
+
+ # Find all image files
+ image_extensions = ['*.jpg', '*.jpeg', '*.png', '*.webp', '*.bmp', '*.JPG', '*.JPEG', '*.PNG']
+ image_files = []
+ for ext in image_extensions:
+ pattern = os.path.join(temp_repo_path, "**", ext)
+ found_files = glob.glob(pattern, recursive=True)
+ image_files.extend(found_files)
+ print(f"Pattern {pattern} found {len(found_files)} files")
+
+ # Find all text files
+ text_files = glob.glob(os.path.join(temp_repo_path, "**", "*.txt"), recursive=True)
+
+ print(f"Found {len(image_files)} image files and {len(text_files)} text files")
+
+ # Copy image files
+ for i, img_file in enumerate(image_files):
+ dest_path = os.path.join(local_path, f"image_{i:06d}.jpg")
+
+ # Load and convert image if needed
+ try:
+ with Image.open(img_file) as image:
+ if image.mode == 'RGBA':
+ background = Image.new('RGB', image.size, (255, 255, 255))
+ background.paste(image, mask=image.split()[-1])
+ image = background
+ elif image.mode not in ['RGB', 'L']:
+ image = image.convert('RGB')
+
+ image.save(dest_path, 'JPEG')
+ except Exception as img_error:
+ print(f"Error processing image {img_file}: {img_error}")
+ continue
+
+ # Copy text files (captions)
+ for i, txt_file in enumerate(text_files[:len(image_files)]): # Match number of images
+ dest_path = os.path.join(local_path, f"image_{i:06d}.txt")
+ try:
+ shutil.copy2(txt_file, dest_path)
+ except Exception as txt_error:
+ print(f"Error copying text file {txt_file}: {txt_error}")
+ continue
+
+ print(f"Downloaded {len(image_files)} images and {len(text_files)} captions to {local_path}")
+
+def create_config(dataset_path: str, output_path: str):
+ """Create training configuration"""
+ import json
+
+ # Load config from JSON string and fix boolean/null values for Python
+ config_str = """${JSON.stringify(jobConfig, null, 2)}"""
+ config_str = config_str.replace('true', 'True').replace('false', 'False').replace('null', 'None')
+ config = eval(config_str)
+
+ # Update paths for cloud environment
+ config["config"]["process"][0]["datasets"][0]["folder_path"] = dataset_path
+ config["config"]["process"][0]["training_folder"] = output_path
+
+ # Remove sqlite_db_path as it's not needed for cloud training
+ if "sqlite_db_path" in config["config"]["process"][0]:
+ del config["config"]["process"][0]["sqlite_db_path"]
+
+ # Also change trainer type from ui_trainer to standard trainer to avoid UI dependencies
+ if config["config"]["process"][0]["type"] == "ui_trainer":
+ config["config"]["process"][0]["type"] = "sd_trainer"
+
+ return config
+
+def upload_results(output_path: str, model_name: str, namespace: str, token: str, config: dict):
+ """Upload trained model to HF Hub with README generation and proper file organization"""
+ import tempfile
+ import shutil
+ import glob
+ import re
+ import yaml
+ from datetime import datetime
+ from huggingface_hub import create_repo, upload_file, HfApi
+
+ try:
+ repo_id = f"{namespace}/{model_name}"
+
+ # Create repository
+ create_repo(repo_id=repo_id, token=token, exist_ok=True)
+
+ print(f"Uploading model to {repo_id}...")
+
+ # Create temporary directory for organized upload
+ with tempfile.TemporaryDirectory() as temp_upload_dir:
+ api = HfApi()
+
+ # 1. Find and upload model files to root directory
+ safetensors_files = glob.glob(os.path.join(output_path, "**", "*.safetensors"), recursive=True)
+ json_files = glob.glob(os.path.join(output_path, "**", "*.json"), recursive=True)
+ txt_files = glob.glob(os.path.join(output_path, "**", "*.txt"), recursive=True)
+
+ uploaded_files = []
+
+ # Upload .safetensors files to root
+ for file_path in safetensors_files:
+ filename = os.path.basename(file_path)
+ print(f"Uploading {filename} to repository root...")
+ api.upload_file(
+ path_or_fileobj=file_path,
+ path_in_repo=filename,
+ repo_id=repo_id,
+ token=token
+ )
+ uploaded_files.append(filename)
+
+ # Upload relevant JSON config files to root (skip metadata.json and other internal files)
+ config_files_uploaded = []
+ for file_path in json_files:
+ filename = os.path.basename(file_path)
+ # Only upload important config files, skip internal metadata
+ if any(keyword in filename.lower() for keyword in ['config', 'adapter', 'lora', 'model']):
+ print(f"Uploading {filename} to repository root...")
+ api.upload_file(
+ path_or_fileobj=file_path,
+ path_in_repo=filename,
+ repo_id=repo_id,
+ token=token
+ )
+ uploaded_files.append(filename)
+ config_files_uploaded.append(filename)
+
+ # 2. Handle sample images
+ samples_uploaded = []
+ samples_dir = os.path.join(output_path, "samples")
+ if os.path.isdir(samples_dir):
+ print("Uploading sample images...")
+ # Create samples directory in repo
+ for filename in os.listdir(samples_dir):
+ if filename.lower().endswith(('.jpg', '.jpeg', '.png', '.webp')):
+ file_path = os.path.join(samples_dir, filename)
+ repo_path = f"samples/{filename}"
+ api.upload_file(
+ path_or_fileobj=file_path,
+ path_in_repo=repo_path,
+ repo_id=repo_id,
+ token=token
+ )
+ samples_uploaded.append(repo_path)
+
+ # 3. Generate and upload README.md
+ readme_content = generate_model_card_readme(
+ repo_id=repo_id,
+ config=config,
+ model_name=model_name,
+ samples_dir=samples_dir if os.path.isdir(samples_dir) else None,
+ uploaded_files=uploaded_files
+ )
+
+ # Create README.md file and upload to root
+ readme_path = os.path.join(temp_upload_dir, "README.md")
+ with open(readme_path, "w", encoding="utf-8") as f:
+ f.write(readme_content)
+
+ print("Uploading README.md to repository root...")
+ api.upload_file(
+ path_or_fileobj=readme_path,
+ path_in_repo="README.md",
+ repo_id=repo_id,
+ token=token
+ )
+
+ print(f"Model uploaded successfully to https://huggingface.co/{repo_id}")
+ print(f"Files uploaded: {len(uploaded_files)} model files, {len(samples_uploaded)} samples, README.md")
+
+ except Exception as e:
+ print(f"Failed to upload model: {e}")
+ raise e
+
+def generate_model_card_readme(repo_id: str, config: dict, model_name: str, samples_dir: str = None, uploaded_files: list = None) -> str:
+ """Generate README.md content for the model card based on AI Toolkit's implementation"""
+ import re
+ import yaml
+ import os
+
+ try:
+ # Extract configuration details
+ process_config = config.get("config", {}).get("process", [{}])[0]
+ model_config = process_config.get("model", {})
+ train_config = process_config.get("train", {})
+ sample_config = process_config.get("sample", {})
+
+ # Gather model info
+ base_model = model_config.get("name_or_path", "unknown")
+ trigger_word = process_config.get("trigger_word")
+ arch = model_config.get("arch", "")
+
+ # Determine license based on base model
+ if "FLUX.1-schnell" in base_model:
+ license_info = {"license": "apache-2.0"}
+ elif "FLUX.1-dev" in base_model:
+ license_info = {
+ "license": "other",
+ "license_name": "flux-1-dev-non-commercial-license",
+ "license_link": "https://huggingface.co/black-forest-labs/FLUX.1-dev/blob/main/LICENSE.md"
+ }
+ else:
+ license_info = {"license": "creativeml-openrail-m"}
+
+ # Generate tags based on model architecture
+ tags = ["text-to-image"]
+
+ if "xl" in arch.lower():
+ tags.append("stable-diffusion-xl")
+ if "flux" in arch.lower():
+ tags.append("flux")
+ if "lumina" in arch.lower():
+ tags.append("lumina2")
+ if "sd3" in arch.lower() or "v3" in arch.lower():
+ tags.append("sd3")
+
+ # Add LoRA-specific tags
+ tags.extend(["lora", "diffusers", "template:sd-lora", "ai-toolkit"])
+
+ # Generate widgets from sample images and prompts
+ widgets = []
+ if samples_dir and os.path.isdir(samples_dir):
+ sample_prompts = sample_config.get("samples", [])
+ if not sample_prompts:
+ # Fallback to old format
+ sample_prompts = [{"prompt": p} for p in sample_config.get("prompts", [])]
+
+ # Get sample image files
+ sample_files = []
+ if os.path.isdir(samples_dir):
+ for filename in os.listdir(samples_dir):
+ if filename.lower().endswith(('.jpg', '.jpeg', '.png', '.webp')):
+ # Parse filename pattern: timestamp__steps_index.jpg
+ match = re.search(r"__(\d+)_(\d+)\.jpg$", filename)
+ if match:
+ steps, index = int(match.group(1)), int(match.group(2))
+ # Only use samples from final training step
+ final_steps = train_config.get("steps", 1000)
+ if steps == final_steps:
+ sample_files.append((index, f"samples/{filename}"))
+
+ # Sort by index and create widgets
+ sample_files.sort(key=lambda x: x[0])
+
+ for i, prompt_obj in enumerate(sample_prompts):
+ prompt = prompt_obj.get("prompt", "") if isinstance(prompt_obj, dict) else str(prompt_obj)
+ if i < len(sample_files):
+ _, image_path = sample_files[i]
+ widgets.append({
+ "text": prompt,
+ "output": {"url": image_path}
+ })
+
+ # Determine torch dtype based on model
+ dtype = "torch.bfloat16" if "flux" in arch.lower() else "torch.float16"
+
+ # Find the main safetensors file for usage example
+ main_safetensors = f"{model_name}.safetensors"
+ if uploaded_files:
+ safetensors_files = [f for f in uploaded_files if f.endswith('.safetensors')]
+ if safetensors_files:
+ main_safetensors = safetensors_files[0]
+
+ # Construct YAML frontmatter
+ frontmatter = {
+ "tags": tags,
+ "base_model": base_model,
+ **license_info
+ }
+
+ if widgets:
+ frontmatter["widget"] = widgets
+
+ if trigger_word:
+ frontmatter["instance_prompt"] = trigger_word
+
+ # Get first prompt for usage example
+ usage_prompt = trigger_word or "a beautiful landscape"
+ if widgets:
+ usage_prompt = widgets[0]["text"]
+ elif trigger_word:
+ usage_prompt = trigger_word
+
+ # Construct README content
+ trigger_section = f"You should use \`{trigger_word}\` to trigger the image generation." if trigger_word else "No trigger words defined."
+
+ # Build YAML frontmatter string
+ frontmatter_yaml = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True, sort_keys=False).strip()
+
+ readme_content = f"""---
+{frontmatter_yaml}
+---
+
+# {model_name}
+
+Model trained with [AI Toolkit by Ostris](https://github.com/ostris/ai-toolkit)
+
+
+
+## Trigger words
+
+{trigger_section}
+
+## Download model and use it with ComfyUI, AUTOMATIC1111, SD.Next, Invoke AI, etc.
+
+Weights for this model are available in Safetensors format.
+
+[Download]({repo_id}/tree/main) them in the Files & versions tab.
+
+## Use it with the [🧨 diffusers library](https://github.com/huggingface/diffusers)
+
+\`\`\`py
+from diffusers import AutoPipelineForText2Image
+import torch
+
+pipeline = AutoPipelineForText2Image.from_pretrained('{base_model}', torch_dtype={dtype}).to('cuda')
+pipeline.load_lora_weights('{repo_id}', weight_name='{main_safetensors}')
+image = pipeline('{usage_prompt}').images[0]
+image.save("my_image.png")
+\`\`\`
+
+For more details, including weighting, merging and fusing LoRAs, check the [documentation on loading LoRAs in diffusers](https://huggingface.co/docs/diffusers/main/en/using-diffusers/loading_adapters)
+
+"""
+ return readme_content
+
+ except Exception as e:
+ print(f"Error generating README: {e}")
+ # Fallback simple README
+ return f"""# {model_name}
+
+Model trained with [AI Toolkit by Ostris](https://github.com/ostris/ai-toolkit)
+
+## Download model
+
+Weights for this model are available in Safetensors format.
+
+[Download]({repo_id}/tree/main) them in the Files & versions tab.
+"""
+
+def main():
+ # Setup environment - token comes from HF Jobs secrets
+ if "HF_TOKEN" not in os.environ:
+ raise ValueError("HF_TOKEN environment variable not set")
+
+ # Install system dependencies for headless operation
+ print("Installing system dependencies...")
+ try:
+ subprocess.run(["apt-get", "update"], check=True, capture_output=True)
+ subprocess.run([
+ "apt-get", "install", "-y",
+ "libgl1-mesa-glx",
+ "libglib2.0-0",
+ "libsm6",
+ "libxext6",
+ "libxrender-dev",
+ "libgomp1",
+ "ffmpeg"
+ ], check=True, capture_output=True)
+ print("System dependencies installed successfully")
+ except subprocess.CalledProcessError as e:
+ print(f"Failed to install system dependencies: {e}")
+ print("Continuing without system dependencies...")
+
+ # Setup ai-toolkit
+ toolkit_dir = setup_ai_toolkit()
+
+ # Create temporary directories
+ with tempfile.TemporaryDirectory() as temp_dir:
+ dataset_path = os.path.join(temp_dir, "dataset")
+ output_path = os.path.join(temp_dir, "output")
+
+ # Download dataset
+ download_dataset("${datasetRepo}", dataset_path)
+
+ # Create config
+ config = create_config(dataset_path, output_path)
+ config_path = os.path.join(temp_dir, "config.yaml")
+
+ with open(config_path, "w") as f:
+ yaml.dump(config, f, default_flow_style=False)
+
+ # Run training
+ print("Starting training...")
+ os.chdir(toolkit_dir)
+
+ subprocess.run([
+ sys.executable, "run.py",
+ config_path
+ ], check=True)
+
+ print("Training completed!")
+
+ # Upload results
+ model_name = f"${jobConfig.config.name}-lora"
+ upload_results(output_path, model_name, "${namespace}", os.environ["HF_TOKEN"], config)
+
+if __name__ == "__main__":
+ main()
+`;
+}
+
+async function submitHFJobUV(token: string, hardware: string, scriptPath: string): Promise {
+ return new Promise((resolve, reject) => {
+ // Ensure token is available
+ if (!token) {
+ reject(new Error('HF_TOKEN is required'));
+ return;
+ }
+
+ console.log('Setting up environment with HF_TOKEN for job submission');
+ console.log(`Command: hf jobs uv run --flavor ${hardware} --timeout 5h --secrets HF_TOKEN --detach ${scriptPath}`);
+
+ // Use hf jobs uv run command with timeout and detach to get job ID
+ const childProcess = spawn('hf', [
+ 'jobs', 'uv', 'run',
+ '--flavor', hardware,
+ '--timeout', '5h',
+ '--secrets', 'HF_TOKEN',
+ '--detach',
+ scriptPath
+ ], {
+ env: {
+ ...process.env,
+ HF_TOKEN: token
+ }
+ });
+
+ let output = '';
+ let error = '';
+
+ childProcess.stdout.on('data', (data) => {
+ const text = data.toString();
+ output += text;
+ console.log('HF Jobs stdout:', text);
+ });
+
+ childProcess.stderr.on('data', (data) => {
+ const text = data.toString();
+ error += text;
+ console.log('HF Jobs stderr:', text);
+ });
+
+ childProcess.on('close', (code) => {
+ console.log('HF Jobs process closed with code:', code);
+ console.log('Full output:', output);
+ console.log('Full error:', error);
+
+ if (code === 0) {
+ // With --detach flag, the output should be just the job ID
+ const fullText = (output + ' ' + error).trim();
+
+ // Updated patterns to handle variable-length hex job IDs (16-24+ characters)
+ const jobIdPatterns = [
+ /Job started with ID:\s*([a-f0-9]{16,})/i, // "Job started with ID: 68b26b73767540db9fc726ac"
+ /job\s+([a-f0-9]{16,})/i, // "job 68b26b73767540db9fc726ac"
+ /Job ID:\s*([a-f0-9]{16,})/i, // "Job ID: 68b26b73767540db9fc726ac"
+ /created\s+job\s+([a-f0-9]{16,})/i, // "created job 68b26b73767540db9fc726ac"
+ /submitted.*?job\s+([a-f0-9]{16,})/i, // "submitted ... job 68b26b73767540db9fc726ac"
+ /https:\/\/huggingface\.co\/jobs\/[^\/]+\/([a-f0-9]{16,})/i, // URL pattern
+ /([a-f0-9]{20,})/i, // Fallback: any 20+ char hex string
+ ];
+
+ let jobId = 'unknown';
+
+ for (const pattern of jobIdPatterns) {
+ const match = fullText.match(pattern);
+ if (match && match[1] && match[1] !== 'started') {
+ jobId = match[1];
+ console.log(`Extracted job ID using pattern: ${pattern.toString()} -> ${jobId}`);
+ break;
+ }
+ }
+
+ resolve(jobId);
+ } else {
+ reject(new Error(error || output || 'Failed to submit job'));
+ }
+ });
+
+ childProcess.on('error', (err) => {
+ console.error('HF Jobs process error:', err);
+ reject(new Error(`Process error: ${err.message}`));
+ });
+ });
+}
+
+async function checkHFJobStatus(token: string, jobId: string): Promise {
+ return new Promise((resolve, reject) => {
+ console.log(`Checking HF Job status for: ${jobId}`);
+
+ const childProcess = spawn('hf', [
+ 'jobs', 'inspect', jobId
+ ], {
+ env: {
+ ...process.env,
+ HF_TOKEN: token
+ }
+ });
+
+ let output = '';
+ let error = '';
+
+ childProcess.stdout.on('data', (data) => {
+ const text = data.toString();
+ output += text;
+ });
+
+ childProcess.stderr.on('data', (data) => {
+ const text = data.toString();
+ error += text;
+ });
+
+ childProcess.on('close', (code) => {
+ if (code === 0) {
+ try {
+ // Parse the JSON output from hf jobs inspect
+ const jobInfo = JSON.parse(output);
+ if (Array.isArray(jobInfo) && jobInfo.length > 0) {
+ const job = jobInfo[0];
+ resolve({
+ id: job.id,
+ status: job.status?.stage || 'UNKNOWN',
+ message: job.status?.message,
+ created_at: job.created_at,
+ flavor: job.flavor,
+ url: job.url,
+ });
+ } else {
+ reject(new Error('Invalid job info response'));
+ }
+ } catch (parseError: any) {
+ console.error('Failed to parse job status:', parseError, output);
+ reject(new Error('Failed to parse job status'));
+ }
+ } else {
+ reject(new Error(error || output || 'Failed to check job status'));
+ }
+ });
+
+ childProcess.on('error', (err) => {
+ console.error('HF Jobs inspect process error:', err);
+ reject(new Error(`Process error: ${err.message}`));
+ });
+ });
+}
\ No newline at end of file
diff --git a/ui/src/app/api/img/[...imagePath]/route.ts b/ui/src/app/api/img/[...imagePath]/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..80fc727216dd6a64e402385078725443234e636a
--- /dev/null
+++ b/ui/src/app/api/img/[...imagePath]/route.ts
@@ -0,0 +1,78 @@
+/* eslint-disable */
+import { NextRequest, NextResponse } from 'next/server';
+import fs from 'fs';
+import path from 'path';
+import { getDatasetsRoot, getTrainingFolder, getDataRoot } from '@/server/settings';
+
+export async function GET(request: NextRequest, { params }: { params: { imagePath: string } }) {
+ const { imagePath } = await params;
+ try {
+ // Decode the path
+ const filepath = decodeURIComponent(imagePath);
+
+ // Get allowed directories
+ const datasetRoot = await getDatasetsRoot();
+ const trainingRoot = await getTrainingFolder();
+ const dataRoot = await getDataRoot();
+
+ const allowedDirs = [datasetRoot, trainingRoot, dataRoot];
+
+ // Security check: Ensure path is in allowed directory
+ const isAllowed = allowedDirs.some(allowedDir => filepath.startsWith(allowedDir)) && !filepath.includes('..');
+
+ if (!isAllowed) {
+ console.warn(`Access denied: ${filepath} not in ${allowedDirs.join(', ')}`);
+ return new NextResponse('Access denied', { status: 403 });
+ }
+
+ // Check if file exists
+ if (!fs.existsSync(filepath)) {
+ console.warn(`File not found: ${filepath}`);
+ return new NextResponse('File not found', { status: 404 });
+ }
+
+ // Get file info
+ const stat = fs.statSync(filepath);
+ if (!stat.isFile()) {
+ return new NextResponse('Not a file', { status: 400 });
+ }
+
+ // Determine content type
+ const ext = path.extname(filepath).toLowerCase();
+ const contentTypeMap: { [key: string]: string } = {
+ // Images
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.png': 'image/png',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.svg': 'image/svg+xml',
+ '.bmp': 'image/bmp',
+ // Videos
+ '.mp4': 'video/mp4',
+ '.avi': 'video/x-msvideo',
+ '.mov': 'video/quicktime',
+ '.mkv': 'video/x-matroska',
+ '.wmv': 'video/x-ms-wmv',
+ '.m4v': 'video/x-m4v',
+ '.flv': 'video/x-flv'
+ };
+
+ const contentType = contentTypeMap[ext] || 'application/octet-stream';
+
+ // Read file as buffer
+ const fileBuffer = fs.readFileSync(filepath);
+
+ // Return file with appropriate headers
+ return new NextResponse(fileBuffer, {
+ headers: {
+ 'Content-Type': contentType,
+ 'Content-Length': String(stat.size),
+ 'Cache-Control': 'public, max-age=86400',
+ },
+ });
+ } catch (error) {
+ console.error('Error serving image:', error);
+ return new NextResponse('Internal Server Error', { status: 500 });
+ }
+}
diff --git a/ui/src/app/api/img/caption/route.ts b/ui/src/app/api/img/caption/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..df4235f99986dedf253b45b802537b4b559b43ca
--- /dev/null
+++ b/ui/src/app/api/img/caption/route.ts
@@ -0,0 +1,29 @@
+import { NextResponse } from 'next/server';
+import fs from 'fs';
+import { getDatasetsRoot } from '@/server/settings';
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const { imgPath, caption } = body;
+ let datasetsPath = await getDatasetsRoot();
+ // make sure the dataset path is in the image path
+ if (!imgPath.startsWith(datasetsPath)) {
+ return NextResponse.json({ error: 'Invalid image path' }, { status: 400 });
+ }
+
+ // if img doesnt exist, ignore
+ if (!fs.existsSync(imgPath)) {
+ return NextResponse.json({ error: 'Image does not exist' }, { status: 404 });
+ }
+
+ // check for caption
+ const captionPath = imgPath.replace(/\.[^/.]+$/, '') + '.txt';
+ // save caption to file
+ fs.writeFileSync(captionPath, caption);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to create dataset' }, { status: 500 });
+ }
+}
diff --git a/ui/src/app/api/img/delete/route.ts b/ui/src/app/api/img/delete/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d4d968f8eab6f6b1d9c988c3fd86aee2d6c2fe4f
--- /dev/null
+++ b/ui/src/app/api/img/delete/route.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from 'next/server';
+import fs from 'fs';
+import { getDatasetsRoot } from '@/server/settings';
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const { imgPath } = body;
+ let datasetsPath = await getDatasetsRoot();
+ // make sure the dataset path is in the image path
+ if (!imgPath.startsWith(datasetsPath)) {
+ return NextResponse.json({ error: 'Invalid image path' }, { status: 400 });
+ }
+
+ // if img doesnt exist, ignore
+ if (!fs.existsSync(imgPath)) {
+ return NextResponse.json({ success: true });
+ }
+
+ // delete it and return success
+ fs.unlinkSync(imgPath);
+
+ // check for caption
+ const captionPath = imgPath.replace(/\.[^/.]+$/, '') + '.txt';
+ if (fs.existsSync(captionPath)) {
+ // delete caption file
+ fs.unlinkSync(captionPath);
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to create dataset' }, { status: 500 });
+ }
+}
diff --git a/ui/src/app/api/img/upload/route.ts b/ui/src/app/api/img/upload/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..56615bd06c4bfee9e7aef4b81a620d4c8c7cbcb7
--- /dev/null
+++ b/ui/src/app/api/img/upload/route.ts
@@ -0,0 +1,58 @@
+// src/app/api/datasets/upload/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { writeFile, mkdir } from 'fs/promises';
+import { join } from 'path';
+import { getDataRoot } from '@/server/settings';
+import {v4 as uuidv4} from 'uuid';
+
+export async function POST(request: NextRequest) {
+ try {
+ const dataRoot = await getDataRoot();
+ if (!dataRoot) {
+ return NextResponse.json({ error: 'Data root path not found' }, { status: 500 });
+ }
+ const imgRoot = join(dataRoot, 'images');
+
+
+ const formData = await request.formData();
+ const files = formData.getAll('files');
+
+ if (!files || files.length === 0) {
+ return NextResponse.json({ error: 'No files provided' }, { status: 400 });
+ }
+
+ // make it recursive if it doesn't exist
+ await mkdir(imgRoot, { recursive: true });
+ const savedFiles = await Promise.all(
+ files.map(async (file: any) => {
+ const bytes = await file.arrayBuffer();
+ const buffer = Buffer.from(bytes);
+
+ const extension = file.name.split('.').pop() || 'jpg';
+
+ // Clean filename and ensure it's unique
+ const fileName = `${uuidv4()}`; // Use UUID for unique file names
+ const filePath = join(imgRoot, `${fileName}.${extension}`);
+
+ await writeFile(filePath, buffer);
+ return filePath;
+ }),
+ );
+
+ return NextResponse.json({
+ message: 'Files uploaded successfully',
+ files: savedFiles,
+ });
+ } catch (error) {
+ console.error('Upload error:', error);
+ return NextResponse.json({ error: 'Error uploading files' }, { status: 500 });
+ }
+}
+
+// Increase payload size limit (default is 4mb)
+export const config = {
+ api: {
+ bodyParser: false,
+ responseLimit: '50mb',
+ },
+};
diff --git a/ui/src/app/api/jobs/[jobID]/delete/route.ts b/ui/src/app/api/jobs/[jobID]/delete/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..618e33f440301495c47141bff70b99b43438c4a3
--- /dev/null
+++ b/ui/src/app/api/jobs/[jobID]/delete/route.ts
@@ -0,0 +1,32 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { PrismaClient } from '@prisma/client';
+import { getTrainingFolder } from '@/server/settings';
+import path from 'path';
+import fs from 'fs';
+
+const prisma = new PrismaClient();
+
+export async function GET(request: NextRequest, { params }: { params: { jobID: string } }) {
+ const { jobID } = await params;
+
+ const job = await prisma.job.findUnique({
+ where: { id: jobID },
+ });
+
+ if (!job) {
+ return NextResponse.json({ error: 'Job not found' }, { status: 404 });
+ }
+
+ const trainingRoot = await getTrainingFolder();
+ const trainingFolder = path.join(trainingRoot, job.name);
+
+ if (fs.existsSync(trainingFolder)) {
+ fs.rmdirSync(trainingFolder, { recursive: true });
+ }
+
+ await prisma.job.delete({
+ where: { id: jobID },
+ });
+
+ return NextResponse.json(job);
+}
diff --git a/ui/src/app/api/jobs/[jobID]/files/route.ts b/ui/src/app/api/jobs/[jobID]/files/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..575df5e5a68cc8739aac16b55f2631d267b040fe
--- /dev/null
+++ b/ui/src/app/api/jobs/[jobID]/files/route.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { PrismaClient } from '@prisma/client';
+import path from 'path';
+import fs from 'fs';
+import { getTrainingFolder } from '@/server/settings';
+
+const prisma = new PrismaClient();
+
+export async function GET(request: NextRequest, { params }: { params: { jobID: string } }) {
+ const { jobID } = await params;
+
+ const job = await prisma.job.findUnique({
+ where: { id: jobID },
+ });
+
+ if (!job) {
+ return NextResponse.json({ error: 'Job not found' }, { status: 404 });
+ }
+
+ const trainingFolder = await getTrainingFolder();
+ const jobFolder = path.join(trainingFolder, job.name);
+
+ if (!fs.existsSync(jobFolder)) {
+ return NextResponse.json({ files: [] });
+ }
+
+ // find all safetensors files in the job folder
+ let files = fs
+ .readdirSync(jobFolder)
+ .filter(file => {
+ return file.endsWith('.safetensors');
+ })
+ .map(file => {
+ return path.join(jobFolder, file);
+ })
+ .sort();
+
+ // get the file size for each file
+ const fileObjects = files.map(file => {
+ const stats = fs.statSync(file);
+ return {
+ path: file,
+ size: stats.size,
+ };
+ });
+
+ return NextResponse.json({ files: fileObjects });
+}
diff --git a/ui/src/app/api/jobs/[jobID]/log/route.ts b/ui/src/app/api/jobs/[jobID]/log/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..10ccbdaac76b76ec20cead8e7f634af0d723ad8f
--- /dev/null
+++ b/ui/src/app/api/jobs/[jobID]/log/route.ts
@@ -0,0 +1,35 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { PrismaClient } from '@prisma/client';
+import path from 'path';
+import fs from 'fs';
+import { getTrainingFolder } from '@/server/settings';
+
+const prisma = new PrismaClient();
+
+export async function GET(request: NextRequest, { params }: { params: { jobID: string } }) {
+ const { jobID } = await params;
+
+ const job = await prisma.job.findUnique({
+ where: { id: jobID },
+ });
+
+ if (!job) {
+ return NextResponse.json({ error: 'Job not found' }, { status: 404 });
+ }
+
+ const trainingFolder = await getTrainingFolder();
+ const jobFolder = path.join(trainingFolder, job.name);
+ const logPath = path.join(jobFolder, 'log.txt');
+
+ if (!fs.existsSync(logPath)) {
+ return NextResponse.json({ log: '' });
+ }
+ let log = '';
+ try {
+ log = fs.readFileSync(logPath, 'utf-8');
+ } catch (error) {
+ console.error('Error reading log file:', error);
+ log = 'Error reading log file';
+ }
+ return NextResponse.json({ log: log });
+}
diff --git a/ui/src/app/api/jobs/[jobID]/samples/route.ts b/ui/src/app/api/jobs/[jobID]/samples/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2a98a6eac1a7581243aa7adfec6da5d5a40c938c
--- /dev/null
+++ b/ui/src/app/api/jobs/[jobID]/samples/route.ts
@@ -0,0 +1,40 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { PrismaClient } from '@prisma/client';
+import path from 'path';
+import fs from 'fs';
+import { getTrainingFolder } from '@/server/settings';
+
+const prisma = new PrismaClient();
+
+export async function GET(request: NextRequest, { params }: { params: { jobID: string } }) {
+ const { jobID } = await params;
+
+ const job = await prisma.job.findUnique({
+ where: { id: jobID },
+ });
+
+ if (!job) {
+ return NextResponse.json({ error: 'Job not found' }, { status: 404 });
+ }
+
+ // setup the training
+ const trainingFolder = await getTrainingFolder();
+
+ const samplesFolder = path.join(trainingFolder, job.name, 'samples');
+ if (!fs.existsSync(samplesFolder)) {
+ return NextResponse.json({ samples: [] });
+ }
+
+ // find all img (png, jpg, jpeg) files in the samples folder
+ const samples = fs
+ .readdirSync(samplesFolder)
+ .filter(file => {
+ return file.endsWith('.png') || file.endsWith('.jpg') || file.endsWith('.jpeg') || file.endsWith('.webp');
+ })
+ .map(file => {
+ return path.join(samplesFolder, file);
+ })
+ .sort();
+
+ return NextResponse.json({ samples });
+}
diff --git a/ui/src/app/api/jobs/[jobID]/start/route.ts b/ui/src/app/api/jobs/[jobID]/start/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e26c1e499373e1aa3821f2031472ec0e0727526f
--- /dev/null
+++ b/ui/src/app/api/jobs/[jobID]/start/route.ts
@@ -0,0 +1,215 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { PrismaClient } from '@prisma/client';
+import { TOOLKIT_ROOT } from '@/paths';
+import { spawn } from 'child_process';
+import path from 'path';
+import fs from 'fs';
+import os from 'os';
+import { getTrainingFolder, getHFToken } from '@/server/settings';
+const isWindows = process.platform === 'win32';
+
+const prisma = new PrismaClient();
+
+export async function GET(request: NextRequest, { params }: { params: { jobID: string } }) {
+ const { jobID } = await params;
+
+ const job = await prisma.job.findUnique({
+ where: { id: jobID },
+ });
+
+ if (!job) {
+ return NextResponse.json({ error: 'Job not found' }, { status: 404 });
+ }
+
+ // update job status to 'running'
+ await prisma.job.update({
+ where: { id: jobID },
+ data: {
+ status: 'running',
+ stop: false,
+ info: 'Starting job...',
+ },
+ });
+
+ // setup the training
+ const trainingRoot = await getTrainingFolder();
+
+ const trainingFolder = path.join(trainingRoot, job.name);
+ if (!fs.existsSync(trainingFolder)) {
+ fs.mkdirSync(trainingFolder, { recursive: true });
+ }
+
+ // make the config file
+ const configPath = path.join(trainingFolder, '.job_config.json');
+
+ //log to path
+ const logPath = path.join(trainingFolder, 'log.txt');
+
+ try {
+ // if the log path exists, move it to a folder called logs and rename it {num}_log.txt, looking for the highest num
+ // if the log path does not exist, create it
+ if (fs.existsSync(logPath)) {
+ const logsFolder = path.join(trainingFolder, 'logs');
+ if (!fs.existsSync(logsFolder)) {
+ fs.mkdirSync(logsFolder, { recursive: true });
+ }
+
+ let num = 0;
+ while (fs.existsSync(path.join(logsFolder, `${num}_log.txt`))) {
+ num++;
+ }
+
+ fs.renameSync(logPath, path.join(logsFolder, `${num}_log.txt`));
+ }
+ } catch (e) {
+ console.error('Error moving log file:', e);
+ }
+
+ // update the config dataset path
+ const jobConfig = JSON.parse(job.job_config);
+ jobConfig.config.process[0].sqlite_db_path = path.join(TOOLKIT_ROOT, 'aitk_db.db');
+
+ // write the config file
+ fs.writeFileSync(configPath, JSON.stringify(jobConfig, null, 2));
+
+ let pythonPath = 'python';
+ // use .venv or venv if it exists
+ if (fs.existsSync(path.join(TOOLKIT_ROOT, '.venv'))) {
+ if (isWindows) {
+ pythonPath = path.join(TOOLKIT_ROOT, '.venv', 'Scripts', 'python.exe');
+ } else {
+ pythonPath = path.join(TOOLKIT_ROOT, '.venv', 'bin', 'python');
+ }
+ } else if (fs.existsSync(path.join(TOOLKIT_ROOT, 'venv'))) {
+ if (isWindows) {
+ pythonPath = path.join(TOOLKIT_ROOT, 'venv', 'Scripts', 'python.exe');
+ } else {
+ pythonPath = path.join(TOOLKIT_ROOT, 'venv', 'bin', 'python');
+ }
+ }
+
+ const runFilePath = path.join(TOOLKIT_ROOT, 'run.py');
+ if (!fs.existsSync(runFilePath)) {
+ return NextResponse.json({ error: 'run.py not found' }, { status: 500 });
+ }
+
+ const additionalEnv: any = {
+ AITK_JOB_ID: jobID,
+ CUDA_VISIBLE_DEVICES: `${job.gpu_ids}`,
+ IS_AI_TOOLKIT_UI: '1'
+ };
+
+ // HF_TOKEN
+ const hfToken = await getHFToken();
+ if (hfToken && hfToken.trim() !== '') {
+ additionalEnv.HF_TOKEN = hfToken;
+ }
+
+ // Add the --log argument to the command
+ const args = [runFilePath, configPath, '--log', logPath];
+
+ try {
+ let subprocess;
+
+ if (isWindows) {
+ // For Windows, use 'cmd.exe' to open a new command window
+ subprocess = spawn('cmd.exe', ['/c', 'start', 'cmd.exe', '/k', pythonPath, ...args], {
+ env: {
+ ...process.env,
+ ...additionalEnv,
+ },
+ cwd: TOOLKIT_ROOT,
+ windowsHide: false,
+ });
+ } else {
+ // For non-Windows platforms
+ subprocess = spawn(pythonPath, args, {
+ detached: true,
+ stdio: ['ignore', 'pipe', 'pipe'], // Changed from 'ignore' to capture output
+ env: {
+ ...process.env,
+ ...additionalEnv,
+ },
+ cwd: TOOLKIT_ROOT,
+ });
+ }
+
+ // Start monitoring in the background without blocking the response
+ const monitorProcess = async () => {
+ const startTime = Date.now();
+ let errorOutput = '';
+ let stdoutput = '';
+
+ if (subprocess.stderr) {
+ subprocess.stderr.on('data', data => {
+ errorOutput += data.toString();
+ });
+ subprocess.stdout.on('data', data => {
+ stdoutput += data.toString();
+ // truncate to only get the last 500 characters
+ if (stdoutput.length > 500) {
+ stdoutput = stdoutput.substring(stdoutput.length - 500);
+ }
+ });
+ }
+
+ subprocess.on('exit', async code => {
+ const currentTime = Date.now();
+ const duration = (currentTime - startTime) / 1000;
+ console.log(`Job ${jobID} exited with code ${code} after ${duration} seconds.`);
+ // wait for 5 seconds to give it time to stop itself. It id still has a status of running in the db, update it to stopped
+ await new Promise(resolve => setTimeout(resolve, 5000));
+ const updatedJob = await prisma.job.findUnique({
+ where: { id: jobID },
+ });
+ if (updatedJob?.status === 'running') {
+ let errorString = errorOutput;
+ if (errorString.trim() === '') {
+ errorString = stdoutput;
+ }
+ await prisma.job.update({
+ where: { id: jobID },
+ data: {
+ status: 'error',
+ info: `Error launching job: ${errorString.substring(0, 500)}`,
+ },
+ });
+ }
+ });
+
+ // Wait 30 seconds before releasing the process
+ await new Promise(resolve => setTimeout(resolve, 30000));
+ // Detach the process for non-Windows systems
+ if (!isWindows && subprocess.unref) {
+ subprocess.unref();
+ }
+ };
+
+ // Start the monitoring without awaiting it
+ monitorProcess().catch(err => {
+ console.error(`Error in process monitoring for job ${jobID}:`, err);
+ });
+
+ // Return the response immediately
+ return NextResponse.json(job);
+ } catch (error: any) {
+ // Handle any exceptions during process launch
+ console.error('Error launching process:', error);
+
+ await prisma.job.update({
+ where: { id: jobID },
+ data: {
+ status: 'error',
+ info: `Error launching job: ${error?.message || 'Unknown error'}`,
+ },
+ });
+
+ return NextResponse.json(
+ {
+ error: 'Failed to launch job process',
+ details: error?.message || 'Unknown error',
+ },
+ { status: 500 },
+ );
+ }
+}
diff --git a/ui/src/app/api/jobs/[jobID]/stop/route.ts b/ui/src/app/api/jobs/[jobID]/stop/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..73b352dfc55664b1b689075727f7245589523005
--- /dev/null
+++ b/ui/src/app/api/jobs/[jobID]/stop/route.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+export async function GET(request: NextRequest, { params }: { params: { jobID: string } }) {
+ const { jobID } = await params;
+
+ const job = await prisma.job.findUnique({
+ where: { id: jobID },
+ });
+
+ // update job status to 'running'
+ await prisma.job.update({
+ where: { id: jobID },
+ data: {
+ stop: true,
+ info: 'Stopping job...',
+ },
+ });
+
+ return NextResponse.json(job);
+}
diff --git a/ui/src/app/api/jobs/route.ts b/ui/src/app/api/jobs/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8f0419b924cfa6724371712b279e89c666437eb6
--- /dev/null
+++ b/ui/src/app/api/jobs/route.ts
@@ -0,0 +1,67 @@
+import { NextResponse } from 'next/server';
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const id = searchParams.get('id');
+
+ try {
+ if (id) {
+ const job = await prisma.job.findUnique({
+ where: { id },
+ });
+ return NextResponse.json(job);
+ }
+
+ const jobs = await prisma.job.findMany({
+ orderBy: { created_at: 'desc' },
+ });
+ return NextResponse.json({ jobs: jobs });
+ } catch (error) {
+ console.error(error);
+ return NextResponse.json({ error: 'Failed to fetch training data' }, { status: 500 });
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const { id, name, job_config, gpu_ids } = body;
+
+ // Ensure gpu_ids is never null/undefined - provide default value
+ const safeGpuIds = gpu_ids || '0';
+
+ if (id) {
+ // Update existing training
+ const training = await prisma.job.update({
+ where: { id },
+ data: {
+ name,
+ gpu_ids: safeGpuIds,
+ job_config: JSON.stringify(job_config),
+ },
+ });
+ return NextResponse.json(training);
+ } else {
+ // Create new training
+ const training = await prisma.job.create({
+ data: {
+ name,
+ gpu_ids: safeGpuIds,
+ job_config: JSON.stringify(job_config),
+ },
+ });
+ return NextResponse.json(training);
+ }
+ } catch (error: any) {
+ if (error.code === 'P2002') {
+ // Handle unique constraint violation, 409=Conflict
+ return NextResponse.json({ error: 'Job name already exists' }, { status: 409 });
+ }
+ console.error(error);
+ // Handle other errors
+ return NextResponse.json({ error: 'Failed to save training data' }, { status: 500 });
+ }
+}
diff --git a/ui/src/app/api/settings/route.ts b/ui/src/app/api/settings/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..62528cdd0b6a7de39c7ade3e96ea9f0b1ec2a226
--- /dev/null
+++ b/ui/src/app/api/settings/route.ts
@@ -0,0 +1,59 @@
+import { NextResponse } from 'next/server';
+import { PrismaClient } from '@prisma/client';
+import { defaultTrainFolder, defaultDatasetsFolder } from '@/paths';
+import { flushCache } from '@/server/settings';
+
+const prisma = new PrismaClient();
+
+export async function GET() {
+ try {
+ const settings = await prisma.settings.findMany();
+ const settingsObject = settings.reduce((acc: any, setting) => {
+ acc[setting.key] = setting.value;
+ return acc;
+ }, {});
+ // if TRAINING_FOLDER is not set, use default
+ if (!settingsObject.TRAINING_FOLDER || settingsObject.TRAINING_FOLDER === '') {
+ settingsObject.TRAINING_FOLDER = defaultTrainFolder;
+ }
+ // if DATASETS_FOLDER is not set, use default
+ if (!settingsObject.DATASETS_FOLDER || settingsObject.DATASETS_FOLDER === '') {
+ settingsObject.DATASETS_FOLDER = defaultDatasetsFolder;
+ }
+ return NextResponse.json(settingsObject);
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 });
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const { HF_TOKEN, TRAINING_FOLDER, DATASETS_FOLDER } = body;
+
+ // Upsert both settings
+ await Promise.all([
+ prisma.settings.upsert({
+ where: { key: 'HF_TOKEN' },
+ update: { value: HF_TOKEN },
+ create: { key: 'HF_TOKEN', value: HF_TOKEN },
+ }),
+ prisma.settings.upsert({
+ where: { key: 'TRAINING_FOLDER' },
+ update: { value: TRAINING_FOLDER },
+ create: { key: 'TRAINING_FOLDER', value: TRAINING_FOLDER },
+ }),
+ prisma.settings.upsert({
+ where: { key: 'DATASETS_FOLDER' },
+ update: { value: DATASETS_FOLDER },
+ create: { key: 'DATASETS_FOLDER', value: DATASETS_FOLDER },
+ }),
+ ]);
+
+ flushCache();
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
+ }
+}
diff --git a/ui/src/app/api/zip/route.ts b/ui/src/app/api/zip/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fc4b946da5f6265d4d193849bf218fea41ea6e01
--- /dev/null
+++ b/ui/src/app/api/zip/route.ts
@@ -0,0 +1,78 @@
+/* eslint-disable */
+import { NextRequest, NextResponse } from 'next/server';
+import fs from 'fs';
+import fsp from 'fs/promises';
+import path from 'path';
+import archiver from 'archiver';
+import { getTrainingFolder } from '@/server/settings';
+
+export const runtime = 'nodejs'; // ensure Node APIs are available
+export const dynamic = 'force-dynamic'; // long-running, non-cached
+
+type PostBody = {
+ zipTarget: 'samples'; //only samples for now
+ jobName: string;
+};
+
+async function resolveSafe(p: string) {
+ // resolve symlinks + normalize
+ return await fsp.realpath(p);
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = (await request.json()) as PostBody;
+ if (!body || !body.jobName) {
+ return NextResponse.json({ error: 'jobName is required' }, { status: 400 });
+ }
+
+ const trainingRoot = await resolveSafe(await getTrainingFolder());
+ const folderPath = await resolveSafe(path.join(trainingRoot, body.jobName, 'samples'));
+ const outputPath = path.resolve(trainingRoot, body.jobName, 'samples.zip');
+
+ // Must be a directory
+ let stat: fs.Stats;
+ try {
+ stat = await fsp.stat(folderPath);
+ } catch {
+ return new NextResponse('Folder not found', { status: 404 });
+ }
+ if (!stat.isDirectory()) {
+ return new NextResponse('Not a directory', { status: 400 });
+ }
+
+ // delete current one if it exists
+ if (fs.existsSync(outputPath)) {
+ await fsp.unlink(outputPath);
+ }
+
+ // Create write stream & archive
+ await new Promise((resolve, reject) => {
+ const output = fs.createWriteStream(outputPath);
+ const archive = archiver('zip', { zlib: { level: 9 } });
+
+ output.on('close', () => resolve());
+ output.on('error', reject);
+ archive.on('error', reject);
+
+ archive.pipe(output);
+
+ // Add the directory contents (place them under the folder's base name in the zip)
+ const rootName = path.basename(folderPath);
+ archive.directory(folderPath, rootName);
+
+ archive.finalize().catch(reject);
+ });
+
+ // Return the absolute path so your existing /api/files/[...filePath] can serve it
+ // Example download URL (client-side): `/api/files/${encodeURIComponent(resolvedOutPath)}`
+ return NextResponse.json({
+ ok: true,
+ zipPath: outputPath,
+ fileName: path.basename(outputPath),
+ });
+ } catch (err) {
+ console.error('Zip error:', err);
+ return new NextResponse('Internal Server Error', { status: 500 });
+ }
+}
diff --git a/ui/src/app/apple-icon.png b/ui/src/app/apple-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..595cb880e5cff0ab9605c2ef76dba8ebb7e7fc62
Binary files /dev/null and b/ui/src/app/apple-icon.png differ
diff --git a/ui/src/app/dashboard/page.tsx b/ui/src/app/dashboard/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d5d749a0edd4366ca28316bd1ec29f60cc607e32
--- /dev/null
+++ b/ui/src/app/dashboard/page.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import GpuMonitor from '@/components/GPUMonitor';
+import JobsTable from '@/components/JobsTable';
+import { TopBar, MainContent } from '@/components/layout';
+import Link from 'next/link';
+import { useAuth } from '@/contexts/AuthContext';
+import HFLoginButton from '@/components/HFLoginButton';
+
+export default function Dashboard() {
+ const { status: authStatus, namespace } = useAuth();
+ const isAuthenticated = authStatus === 'authenticated';
+
+ return (
+ <>
+
+
+
Dashboard
+
+
+ {isAuthenticated ? (
+ Welcome, {namespace || 'user'}
+ ) : (
+ <>
+ Welcome, Guest
+
+
+ Settings
+
+ >
+ )}
+
+
+
+
+
+
+
Active Jobs
+
+ View All
+
+
+ {isAuthenticated ? (
+
+ ) : (
+
+ Sign in with Hugging Face or add an access token in Settings to view and manage jobs.
+
+ )}
+
+
+ >
+ );
+}
diff --git a/ui/src/app/datasets/[datasetName]/page.tsx b/ui/src/app/datasets/[datasetName]/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..776eeb525fdacf0621828734ffdd79bbd21697a8
--- /dev/null
+++ b/ui/src/app/datasets/[datasetName]/page.tsx
@@ -0,0 +1,190 @@
+'use client';
+
+import { useEffect, useState, use, useMemo } from 'react';
+import { LuImageOff, LuLoader, LuBan } from 'react-icons/lu';
+import { FaChevronLeft } from 'react-icons/fa';
+import DatasetImageCard from '@/components/DatasetImageCard';
+import { Button } from '@headlessui/react';
+import AddImagesModal, { openImagesModal } from '@/components/AddImagesModal';
+import { TopBar, MainContent } from '@/components/layout';
+import { apiClient } from '@/utils/api';
+import FullscreenDropOverlay from '@/components/FullscreenDropOverlay';
+import { useRouter } from 'next/navigation';
+import { usingBrowserDb } from '@/utils/env';
+import { hasUserDataset } from '@/utils/storage/datasetStorage';
+import { useAuth } from '@/contexts/AuthContext';
+import HFLoginButton from '@/components/HFLoginButton';
+import Link from 'next/link';
+
+export default function DatasetPage({ params }: { params: { datasetName: string } }) {
+ const [imgList, setImgList] = useState<{ img_path: string }[]>([]);
+ const usableParams = use(params as any) as { datasetName: string };
+ const datasetName = usableParams.datasetName;
+ const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
+ const router = useRouter();
+ const { status: authStatus } = useAuth();
+ const isAuthenticated = authStatus === 'authenticated';
+ const hasDatasetEntry = !usingBrowserDb || hasUserDataset(datasetName);
+ const allowAccess = hasDatasetEntry && isAuthenticated;
+
+ const refreshImageList = (dbName: string) => {
+ setStatus('loading');
+ console.log('Fetching images for dataset:', dbName);
+ apiClient
+ .post('/api/datasets/listImages', { datasetName: dbName })
+ .then((res: any) => {
+ const data = res.data;
+ console.log('Images:', data.images);
+ // sort
+ data.images.sort((a: { img_path: string }, b: { img_path: string }) => a.img_path.localeCompare(b.img_path));
+ setImgList(data.images);
+ setStatus('success');
+ })
+ .catch(error => {
+ console.error('Error fetching images:', error);
+ setStatus('error');
+ });
+ };
+ useEffect(() => {
+ if (!datasetName) {
+ return;
+ }
+
+ if (!isAuthenticated) {
+ return;
+ }
+
+ if (!hasDatasetEntry) {
+ setImgList([]);
+ setStatus('error');
+ router.replace('/datasets');
+ return;
+ }
+
+ refreshImageList(datasetName);
+ }, [datasetName, hasDatasetEntry, isAuthenticated, router]);
+
+ if (!allowAccess) {
+ return (
+ <>
+
+
+
+
+
+
Dataset: {datasetName}
+
+
+
+
+
+
You need to sign in with Hugging Face or provide a valid token to view this dataset.
+
+
+
+ Manage authentication in Settings
+
+
+
+
+ >
+ );
+ }
+
+ const PageInfoContent = useMemo(() => {
+ let icon = null;
+ let text = '';
+ let subtitle = '';
+ let showIt = false;
+ let bgColor = '';
+ let textColor = '';
+ let iconColor = '';
+
+ if (status == 'loading') {
+ icon = ;
+ text = 'Loading Images';
+ subtitle = 'Please wait while we fetch your dataset images...';
+ showIt = true;
+ bgColor = 'bg-gray-50 dark:bg-gray-800/50';
+ textColor = 'text-gray-900 dark:text-gray-100';
+ iconColor = 'text-gray-500 dark:text-gray-400';
+ }
+ if (status == 'error') {
+ icon = ;
+ text = 'Error Loading Images';
+ subtitle = 'There was a problem fetching the images. Please try refreshing the page.';
+ showIt = true;
+ bgColor = 'bg-red-50 dark:bg-red-950/20';
+ textColor = 'text-red-900 dark:text-red-100';
+ iconColor = 'text-red-600 dark:text-red-400';
+ }
+ if (status == 'success' && imgList.length === 0) {
+ icon = ;
+ text = 'No Images Found';
+ subtitle = 'This dataset is empty. Click "Add Images" to get started.';
+ showIt = true;
+ bgColor = 'bg-gray-50 dark:bg-gray-800/50';
+ textColor = 'text-gray-900 dark:text-gray-100';
+ iconColor = 'text-gray-500 dark:text-gray-400';
+ }
+
+ if (!showIt) return null;
+
+ return (
+
+
{icon}
+
{text}
+
{subtitle}
+
+ );
+ }, [status, imgList.length]);
+
+ return (
+ <>
+ {/* Fixed top bar */}
+
+
+
+
+
+
Dataset: {datasetName}
+
+
+
+
+
+
+
+ {PageInfoContent}
+ {status === 'success' && imgList.length > 0 && (
+
+ {imgList.map(img => (
+ refreshImageList(datasetName)}
+ />
+ ))}
+
+ )}
+
+
+ refreshImageList(datasetName)}
+ />
+ >
+ );
+}
diff --git a/ui/src/app/datasets/page.tsx b/ui/src/app/datasets/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..eec8310f9ba6f38f5eca345a0b6400e754241a64
--- /dev/null
+++ b/ui/src/app/datasets/page.tsx
@@ -0,0 +1,217 @@
+'use client';
+
+import { useState } from 'react';
+import { Modal } from '@/components/Modal';
+import Link from 'next/link';
+import { TextInput } from '@/components/formInputs';
+import useDatasetList from '@/hooks/useDatasetList';
+import { Button } from '@headlessui/react';
+import { FaRegTrashAlt } from 'react-icons/fa';
+import { openConfirm } from '@/components/ConfirmModal';
+import { TopBar, MainContent } from '@/components/layout';
+import UniversalTable, { TableColumn } from '@/components/UniversalTable';
+import { apiClient } from '@/utils/api';
+import { useRouter } from 'next/navigation';
+import { usingBrowserDb } from '@/utils/env';
+import { addUserDataset, removeUserDataset } from '@/utils/storage/datasetStorage';
+import { useAuth } from '@/contexts/AuthContext';
+import HFLoginButton from '@/components/HFLoginButton';
+
+export default function Datasets() {
+ const router = useRouter();
+ const { datasets, status, refreshDatasets } = useDatasetList();
+ const [newDatasetName, setNewDatasetName] = useState('');
+ const [isNewDatasetModalOpen, setIsNewDatasetModalOpen] = useState(false);
+ const { status: authStatus } = useAuth();
+ const isAuthenticated = authStatus === 'authenticated';
+
+ // Transform datasets array into rows with objects
+ const tableRows = datasets.map(dataset => ({
+ name: dataset,
+ actions: dataset, // Pass full dataset name for actions
+ }));
+
+ const columns: TableColumn[] = [
+ {
+ title: 'Dataset Name',
+ key: 'name',
+ render: row => (
+
+ {row.name}
+
+ ),
+ },
+ {
+ title: 'Actions',
+ key: 'actions',
+ className: 'w-20 text-right',
+ render: row => (
+
+ ),
+ },
+ ];
+
+ const handleDeleteDataset = (datasetName: string) => {
+ openConfirm({
+ title: 'Delete Dataset',
+ message: `Are you sure you want to delete the dataset "${datasetName}"? This action cannot be undone.`,
+ type: 'warning',
+ confirmText: 'Delete',
+ onConfirm: () => {
+ apiClient
+ .post('/api/datasets/delete', { name: datasetName })
+ .then(() => {
+ console.log('Dataset deleted:', datasetName);
+ if (usingBrowserDb) {
+ removeUserDataset(datasetName);
+ }
+ refreshDatasets();
+ })
+ .catch(error => {
+ console.error('Error deleting dataset:', error);
+ });
+ },
+ });
+ };
+
+ const handleCreateDataset = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!isAuthenticated) {
+ return;
+ }
+ try {
+ const data = await apiClient.post('/api/datasets/create', { name: newDatasetName }).then(res => res.data);
+ console.log('New dataset created:', data);
+ if (usingBrowserDb && data?.name) {
+ addUserDataset(data.name, data?.path || '');
+ }
+ refreshDatasets();
+ setNewDatasetName('');
+ setIsNewDatasetModalOpen(false);
+ } catch (error) {
+ console.error('Error creating new dataset:', error);
+ }
+ };
+
+ const openNewDatasetModal = () => {
+ if (!isAuthenticated) {
+ return;
+ }
+ openConfirm({
+ title: 'New Dataset',
+ message: 'Enter the name of the new dataset:',
+ type: 'info',
+ confirmText: 'Create',
+ inputTitle: 'Dataset Name',
+ onConfirm: async (name?: string) => {
+ if (!name) {
+ console.error('Dataset name is required.');
+ return;
+ }
+ if (!isAuthenticated) {
+ return;
+ }
+ try {
+ const data = await apiClient.post('/api/datasets/create', { name }).then(res => res.data);
+ console.log('New dataset created:', data);
+ if (usingBrowserDb && data?.name) {
+ addUserDataset(data.name, data?.path || '');
+ }
+ if (data.name) {
+ router.push(`/datasets/${data.name}`);
+ } else {
+ refreshDatasets();
+ }
+ } catch (error) {
+ console.error('Error creating new dataset:', error);
+ }
+ },
+ });
+ };
+
+ return (
+ <>
+
+
+
Datasets
+
+
+
+ {isAuthenticated ? (
+
+ ) : (
+
+ Sign in to add datasets
+
+ )}
+
+
+
+
+ {isAuthenticated ? (
+
+ ) : (
+
+
Sign in with Hugging Face or add an access token to manage datasets.
+
+
+
+ Manage authentication in Settings
+
+
+
+ )}
+
+
+ setIsNewDatasetModalOpen(false)}
+ title="New Dataset"
+ size="md"
+ >
+
+
+ >
+ );
+}
diff --git a/ui/src/app/favicon.ico b/ui/src/app/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..a20b629a5996a0b62c038bf356f1e28eab9bdb99
Binary files /dev/null and b/ui/src/app/favicon.ico differ
diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css
new file mode 100644
index 0000000000000000000000000000000000000000..890dc5bc7b9125662f38d11d758350ba5a80f744
--- /dev/null
+++ b/ui/src/app/globals.css
@@ -0,0 +1,72 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ --background: #ffffff;
+ --foreground: #171717;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --background: #0a0a0a;
+ --foreground: #ededed;
+ }
+}
+
+body {
+ color: var(--foreground);
+ background: var(--background);
+ font-family: Arial, Helvetica, sans-serif;
+}
+
+@layer components {
+ /* control */
+ .aitk-react-select-container .aitk-react-select__control {
+ @apply flex w-full h-8 min-h-0 px-0 text-sm bg-gray-800 border border-gray-700 rounded-sm hover:border-gray-600 items-center;
+ }
+
+ /* selected label */
+ .aitk-react-select-container .aitk-react-select__single-value {
+ @apply flex-1 min-w-0 truncate text-sm text-neutral-200;
+ }
+
+ /* invisible input (keeps focus & typing, never wraps) */
+ .aitk-react-select-container .aitk-react-select__input-container {
+ @apply text-neutral-200;
+ }
+
+ /* focus */
+ .aitk-react-select-container .aitk-react-select__control--is-focused {
+ @apply ring-2 ring-gray-600 border-transparent hover:border-transparent shadow-none;
+ }
+
+ /* menu */
+ .aitk-react-select-container .aitk-react-select__menu {
+ @apply bg-gray-800 border border-gray-700;
+ }
+
+ /* options */
+ .aitk-react-select-container .aitk-react-select__option {
+ @apply text-sm text-neutral-200 bg-gray-800 hover:bg-gray-700;
+ }
+
+ /* indicator separator */
+ .aitk-react-select-container .aitk-react-select__indicator-separator {
+ @apply bg-gray-600;
+ }
+
+ /* indicators */
+ .aitk-react-select-container .aitk-react-select__indicators,
+ .aitk-react-select-container .aitk-react-select__indicator {
+ @apply py-0 flex items-center;
+ }
+
+ /* placeholder */
+ .aitk-react-select-container .aitk-react-select__placeholder {
+ @apply text-sm text-neutral-200;
+ }
+}
+
+
+
diff --git a/ui/src/app/icon.png b/ui/src/app/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..8bcfbf80f1f08f9b1f6678914370f00a105a37b2
Binary files /dev/null and b/ui/src/app/icon.png differ
diff --git a/ui/src/app/icon.svg b/ui/src/app/icon.svg
new file mode 100644
index 0000000000000000000000000000000000000000..2689ae5393931a68144db7d92555343aeef0155c
--- /dev/null
+++ b/ui/src/app/icon.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/ui/src/app/jobs/[jobID]/page.tsx b/ui/src/app/jobs/[jobID]/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ae001714d36deec0f26e52f0d1542f4684a7ef7e
--- /dev/null
+++ b/ui/src/app/jobs/[jobID]/page.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+import { useState, use } from 'react';
+import { FaChevronLeft } from 'react-icons/fa';
+import { Button } from '@headlessui/react';
+import { TopBar, MainContent } from '@/components/layout';
+import useJob from '@/hooks/useJob';
+import SampleImages, {SampleImagesMenu} from '@/components/SampleImages';
+import JobOverview from '@/components/JobOverview';
+import { redirect } from 'next/navigation';
+import { useAuth } from '@/contexts/AuthContext';
+import HFLoginButton from '@/components/HFLoginButton';
+import Link from 'next/link';
+import JobActionBar from '@/components/JobActionBar';
+import JobConfigViewer from '@/components/JobConfigViewer';
+import { JobRecord } from '@/types';
+
+type PageKey = 'overview' | 'samples' | 'config';
+
+interface Page {
+ name: string;
+ value: PageKey;
+ component: React.ComponentType<{ job: JobRecord }>;
+ menuItem?: React.ComponentType<{ job?: JobRecord | null }> | null;
+ mainCss?: string;
+}
+
+const pages: Page[] = [
+ {
+ name: 'Overview',
+ value: 'overview',
+ component: JobOverview,
+ mainCss: 'pt-24',
+ },
+ {
+ name: 'Samples',
+ value: 'samples',
+ component: SampleImages,
+ menuItem: SampleImagesMenu,
+ mainCss: 'pt-24',
+ },
+ {
+ name: 'Config File',
+ value: 'config',
+ component: JobConfigViewer,
+ mainCss: 'pt-[80px] px-0 pb-0',
+ },
+];
+
+export default function JobPage({ params }: { params: { jobID: string } }) {
+ const usableParams = use(params as any) as { jobID: string };
+ const jobID = usableParams.jobID;
+ const { job, status, refreshJob } = useJob(jobID, 5000);
+ const [pageKey, setPageKey] = useState('overview');
+ const { status: authStatus } = useAuth();
+ const isAuthenticated = authStatus === 'authenticated';
+
+ const page = pages.find(p => p.value === pageKey);
+
+ if (!isAuthenticated) {
+ return (
+ <>
+
+
+
+
+
+
Job Details
+
+
+
+
+
+
Sign in with Hugging Face or add an access token to view job details.
+
+
+
+ Manage authentication in Settings
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {/* Fixed top bar */}
+
+
+
+
+
+
Job: {job?.name}
+
+
+ {job && (
+ {
+ redirect('/jobs');
+ }}
+ />
+ )}
+
+ page.value === pageKey)?.mainCss}>
+ {status === 'loading' && job == null && Loading...
}
+ {status === 'error' && job == null && Error fetching job
}
+ {job && (
+ <>
+ {pages.map(page => {
+ const Component = page.component;
+ return page.value === pageKey ? : null;
+ })}
+ >
+ )}
+
+
+ {pages.map(page => (
+
+ ))}
+ {
+ page?.menuItem && (
+ <>
+
+
+
+ >
+ )
+ }
+
+ >
+ );
+}
diff --git a/ui/src/app/jobs/new/AdvancedJob.tsx b/ui/src/app/jobs/new/AdvancedJob.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bccc4da22a57660ae23e0882f641362f1dfd4dec
--- /dev/null
+++ b/ui/src/app/jobs/new/AdvancedJob.tsx
@@ -0,0 +1,146 @@
+'use client';
+import { useEffect, useState, useRef } from 'react';
+import { JobConfig } from '@/types';
+import YAML from 'yaml';
+import Editor, { OnMount } from '@monaco-editor/react';
+import type { editor } from 'monaco-editor';
+import { SettingsData } from '@/types';
+import { migrateJobConfig } from './jobConfig';
+
+type Props = {
+ jobConfig: JobConfig;
+ setJobConfig: (value: any, key?: string) => void;
+ status: 'idle' | 'saving' | 'success' | 'error';
+ handleSubmit: (event: React.FormEvent) => void;
+ runId: string | null;
+ gpuIDs: string | null;
+ setGpuIDs: (value: string | null) => void;
+ gpuList: any;
+ datasetOptions: any;
+ settings: SettingsData;
+};
+
+const isDev = process.env.NODE_ENV === 'development';
+
+const yamlConfig: YAML.DocumentOptions &
+ YAML.SchemaOptions &
+ YAML.ParseOptions &
+ YAML.CreateNodeOptions &
+ YAML.ToStringOptions = {
+ indent: 2,
+ lineWidth: 999999999999,
+ defaultStringType: 'QUOTE_DOUBLE',
+ defaultKeyType: 'PLAIN',
+ directives: true,
+};
+
+export default function AdvancedJob({ jobConfig, setJobConfig, settings }: Props) {
+ const [editorValue, setEditorValue] = useState('');
+ const lastJobConfigUpdateStringRef = useRef('');
+ const editorRef = useRef(null);
+
+ // Track if the editor has been mounted
+ const isEditorMounted = useRef(false);
+
+ // Handler for editor mounting
+ const handleEditorDidMount: OnMount = editor => {
+ editorRef.current = editor;
+ isEditorMounted.current = true;
+
+ // Initial content setup
+ try {
+ const yamlContent = YAML.stringify(jobConfig, yamlConfig);
+ setEditorValue(yamlContent);
+ lastJobConfigUpdateStringRef.current = JSON.stringify(jobConfig);
+ } catch (e) {
+ console.warn(e);
+ }
+ };
+
+ useEffect(() => {
+ const lastUpdate = lastJobConfigUpdateStringRef.current;
+ const currentUpdate = JSON.stringify(jobConfig);
+
+ // Skip if no changes or editor not yet mounted
+ if (lastUpdate === currentUpdate || !isEditorMounted.current) {
+ return;
+ }
+
+ try {
+ // Preserve cursor position and selection
+ const editor = editorRef.current;
+ if (editor) {
+ // Save current editor state
+ const position = editor.getPosition();
+ const selection = editor.getSelection();
+ const scrollTop = editor.getScrollTop();
+
+ // Update content
+ const yamlContent = YAML.stringify(jobConfig, yamlConfig);
+
+ // Only update if the content is actually different
+ if (yamlContent !== editor.getValue()) {
+ // Set value directly on the editor model instead of using React state
+ editor.getModel()?.setValue(yamlContent);
+
+ // Restore cursor position and selection
+ if (position) editor.setPosition(position);
+ if (selection) editor.setSelection(selection);
+ editor.setScrollTop(scrollTop);
+ }
+
+ lastJobConfigUpdateStringRef.current = currentUpdate;
+ }
+ } catch (e) {
+ console.warn(e);
+ }
+ }, [jobConfig]);
+
+ const handleChange = (value: string | undefined) => {
+ if (value === undefined) return;
+
+ try {
+ const parsed = YAML.parse(value);
+ // Don't update jobConfig if the change came from the editor itself
+ // to avoid a circular update loop
+ if (JSON.stringify(parsed) !== lastJobConfigUpdateStringRef.current) {
+ lastJobConfigUpdateStringRef.current = JSON.stringify(parsed);
+
+ // We have to ensure certain things are always set
+ try {
+ parsed.config.process[0].type = 'ui_trainer';
+ parsed.config.process[0].sqlite_db_path = './aitk_db.db';
+ parsed.config.process[0].training_folder = settings.TRAINING_FOLDER;
+ parsed.config.process[0].device = 'cuda';
+ parsed.config.process[0].performance_log_every = 10;
+ } catch (e) {
+ console.warn(e);
+ }
+ migrateJobConfig(parsed);
+ setJobConfig(parsed);
+ }
+ } catch (e) {
+ // Don't update on parsing errors
+ console.warn(e);
+ }
+ };
+
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/ui/src/app/jobs/new/SimpleJob.tsx b/ui/src/app/jobs/new/SimpleJob.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..080c383de00f4858199e0937cbca92385910a598
--- /dev/null
+++ b/ui/src/app/jobs/new/SimpleJob.tsx
@@ -0,0 +1,973 @@
+'use client';
+import { useMemo, useState } from 'react';
+import { modelArchs, ModelArch, groupedModelOptions, quantizationOptions, defaultQtype } from './options';
+import { defaultDatasetConfig } from './jobConfig';
+import { GroupedSelectOption, JobConfig, SelectOption } from '@/types';
+import { objectCopy } from '@/utils/basic';
+import { TextInput, SelectInput, Checkbox, FormGroup, NumberInput } from '@/components/formInputs';
+import Card from '@/components/Card';
+import { X } from 'lucide-react';
+import AddSingleImageModal, { openAddImageModal } from '@/components/AddSingleImageModal';
+import {FlipHorizontal2, FlipVertical2} from "lucide-react";
+import HFJobsWorkflow from '@/components/HFJobsWorkflow';
+
+type Props = {
+ jobConfig: JobConfig;
+ setJobConfig: (value: any, key: string) => void;
+ status: 'idle' | 'saving' | 'success' | 'error';
+ handleSubmit: (event: React.FormEvent) => void;
+ runId: string | null;
+ gpuIDs: string | null;
+ setGpuIDs: (value: string | null) => void;
+ gpuList: any;
+ datasetOptions: any;
+ trainingBackend?: 'local' | 'hf-jobs';
+ setTrainingBackend?: (backend: 'local' | 'hf-jobs') => void;
+ hfJobSubmitted?: boolean;
+ onHFJobComplete?: (jobId: string, localJobId?: string) => void;
+ forceHFBackend?: boolean;
+};
+
+const isDev = process.env.NODE_ENV === 'development';
+
+export default function SimpleJob({
+ jobConfig,
+ setJobConfig,
+ handleSubmit,
+ status,
+ runId,
+ gpuIDs,
+ setGpuIDs,
+ gpuList,
+ datasetOptions,
+ trainingBackend: parentTrainingBackend,
+ setTrainingBackend: parentSetTrainingBackend,
+ hfJobSubmitted,
+ onHFJobComplete,
+ forceHFBackend = false,
+}: Props) {
+ const [localTrainingBackend, setLocalTrainingBackend] = useState(forceHFBackend ? 'hf-jobs' : 'local');
+ const trainingBackend = parentTrainingBackend || localTrainingBackend;
+ const setTrainingBackend = forceHFBackend
+ ? (_: 'local' | 'hf-jobs') => undefined
+ : parentSetTrainingBackend || setLocalTrainingBackend;
+ const backendOptions = forceHFBackend
+ ? [{ value: 'hf-jobs', label: 'HF Jobs (Cloud)' }]
+ : [
+ { value: 'local', label: 'Local GPU' },
+ { value: 'hf-jobs', label: 'HF Jobs (Cloud)' },
+ ];
+ const modelArch = useMemo(() => {
+ return modelArchs.find(a => a.name === jobConfig.config.process[0].model.arch) as ModelArch;
+ }, [jobConfig.config.process[0].model.arch]);
+
+ const isVideoModel = !!(modelArch?.group === 'video');
+
+ const numTopCards = useMemo(() => {
+ let count = 4; // job settings, model config, target config, save config
+ if (modelArch?.additionalSections?.includes('model.multistage')) {
+ count += 1; // add multistage card
+ }
+ if (!modelArch?.disableSections?.includes('model.quantize')) {
+ count += 1; // add quantization card
+ }
+ return count;
+
+ }, [modelArch]);
+
+ let topBarClass = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 gap-6';
+
+ if (numTopCards == 5) {
+ topBarClass = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6';
+ }
+ if (numTopCards == 6) {
+ topBarClass = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-6 gap-6';
+ }
+
+ const transformerQuantizationOptions: GroupedSelectOption[] | SelectOption[] = useMemo(() => {
+ const hasARA = modelArch?.accuracyRecoveryAdapters && Object.keys(modelArch.accuracyRecoveryAdapters).length > 0;
+ if (!hasARA) {
+ return quantizationOptions;
+ }
+ let newQuantizationOptions = [
+ {
+ label: 'Standard',
+ options: [quantizationOptions[0], quantizationOptions[1]],
+ },
+ ];
+
+ // add ARAs if they exist for the model
+ let ARAs: SelectOption[] = [];
+ if (modelArch.accuracyRecoveryAdapters) {
+ for (const [label, value] of Object.entries(modelArch.accuracyRecoveryAdapters)) {
+ ARAs.push({ value, label });
+ }
+ }
+ if (ARAs.length > 0) {
+ newQuantizationOptions.push({
+ label: 'Accuracy Recovery Adapters',
+ options: ARAs,
+ });
+ }
+
+ let additionalQuantizationOptions: SelectOption[] = [];
+ // add the quantization options if they are not already included
+ for (let i = 2; i < quantizationOptions.length; i++) {
+ const option = quantizationOptions[i];
+ additionalQuantizationOptions.push(option);
+ }
+ if (additionalQuantizationOptions.length > 0) {
+ newQuantizationOptions.push({
+ label: 'Additional Quantization Options',
+ options: additionalQuantizationOptions,
+ });
+ }
+ return newQuantizationOptions;
+ }, [modelArch]);
+
+ return (
+ <>
+
+
+ {trainingBackend === 'hf-jobs' && (
+
+ {
+ console.log('HF Job submitted:', jobId, 'Local job ID:', localJobId);
+ if (onHFJobComplete) {
+ onHFJobComplete(jobId, localJobId);
+ }
+ }}
+ />
+
+ )}
+
+
+ >
+ );
+}
diff --git a/ui/src/app/jobs/new/jobConfig.ts b/ui/src/app/jobs/new/jobConfig.ts
new file mode 100644
index 0000000000000000000000000000000000000000..df257bb985dad2eaada5d2913ab1e6347cf36ec1
--- /dev/null
+++ b/ui/src/app/jobs/new/jobConfig.ts
@@ -0,0 +1,167 @@
+import { JobConfig, DatasetConfig } from '@/types';
+
+export const defaultDatasetConfig: DatasetConfig = {
+ folder_path: '/path/to/images/folder',
+ control_path: null,
+ mask_path: null,
+ mask_min_value: 0.1,
+ default_caption: '',
+ caption_ext: 'txt',
+ caption_dropout_rate: 0.05,
+ cache_latents_to_disk: false,
+ is_reg: false,
+ network_weight: 1,
+ resolution: [512, 768, 1024],
+ controls: [],
+ shrink_video_to_frames: true,
+ num_frames: 1,
+ do_i2v: true,
+ flip_x: false,
+ flip_y: false,
+};
+
+export const defaultJobConfig: JobConfig = {
+ job: 'extension',
+ config: {
+ name: 'my_first_lora_v1',
+ process: [
+ {
+ type: 'ui_trainer',
+ training_folder: 'output',
+ sqlite_db_path: './aitk_db.db',
+ device: 'cuda',
+ trigger_word: null,
+ performance_log_every: 10,
+ network: {
+ type: 'lora',
+ linear: 32,
+ linear_alpha: 32,
+ conv: 16,
+ conv_alpha: 16,
+ lokr_full_rank: true,
+ lokr_factor: -1,
+ network_kwargs: {
+ ignore_if_contains: [],
+ },
+ },
+ save: {
+ dtype: 'bf16',
+ save_every: 250,
+ max_step_saves_to_keep: 4,
+ save_format: 'diffusers',
+ push_to_hub: false,
+ },
+ datasets: [defaultDatasetConfig],
+ train: {
+ batch_size: 1,
+ bypass_guidance_embedding: true,
+ steps: 3000,
+ gradient_accumulation: 1,
+ train_unet: true,
+ train_text_encoder: false,
+ gradient_checkpointing: true,
+ noise_scheduler: 'flowmatch',
+ optimizer: 'adamw8bit',
+ timestep_type: 'sigmoid',
+ content_or_style: 'balanced',
+ optimizer_params: {
+ weight_decay: 1e-4,
+ },
+ unload_text_encoder: false,
+ cache_text_embeddings: false,
+ lr: 0.0001,
+ ema_config: {
+ use_ema: false,
+ ema_decay: 0.99,
+ },
+ skip_first_sample: false,
+ disable_sampling: false,
+ dtype: 'bf16',
+ diff_output_preservation: false,
+ diff_output_preservation_multiplier: 1.0,
+ diff_output_preservation_class: 'person',
+ switch_boundary_every: 1,
+ },
+ model: {
+ name_or_path: 'ostris/Flex.1-alpha',
+ quantize: true,
+ qtype: 'qfloat8',
+ quantize_te: true,
+ qtype_te: 'qfloat8',
+ arch: 'flex1',
+ low_vram: false,
+ model_kwargs: {},
+ },
+ sample: {
+ sampler: 'flowmatch',
+ sample_every: 250,
+ width: 1024,
+ height: 1024,
+ samples: [
+ {
+ prompt: 'woman with red hair, playing chess at the park, bomb going off in the background'
+ },
+ {
+ prompt: 'a woman holding a coffee cup, in a beanie, sitting at a cafe',
+ },
+ {
+ prompt: 'a horse is a DJ at a night club, fish eye lens, smoke machine, lazer lights, holding a martini',
+ },
+ {
+ prompt: 'a man showing off his cool new t shirt at the beach, a shark is jumping out of the water in the background',
+ },
+ {
+ prompt: 'a bear building a log cabin in the snow covered mountains',
+ },
+ {
+ prompt: 'woman playing the guitar, on stage, singing a song, laser lights, punk rocker',
+ },
+ {
+ prompt: 'hipster man with a beard, building a chair, in a wood shop',
+ },
+ {
+ prompt: 'photo of a man, white background, medium shot, modeling clothing, studio lighting, white backdrop',
+ },
+ {
+ prompt: "a man holding a sign that says, 'this is a sign'",
+ },
+ {
+ prompt: 'a bulldog, in a post apocalyptic world, with a shotgun, in a leather jacket, in a desert, with a motorcycle',
+ },
+ ],
+ neg: '',
+ seed: 42,
+ walk_seed: true,
+ guidance_scale: 4,
+ sample_steps: 25,
+ num_frames: 1,
+ fps: 1,
+ },
+ },
+ ],
+ },
+ meta: {
+ name: '[name]',
+ version: '1.0',
+ },
+};
+
+export const migrateJobConfig = (jobConfig: JobConfig): JobConfig => {
+ // upgrade prompt strings to samples
+ if (
+ jobConfig?.config?.process &&
+ jobConfig.config.process[0]?.sample &&
+ Array.isArray(jobConfig.config.process[0].sample.prompts) &&
+ jobConfig.config.process[0].sample.prompts.length > 0
+ ) {
+ let newSamples = [];
+ for (const prompt of jobConfig.config.process[0].sample.prompts) {
+ newSamples.push({
+ prompt: prompt,
+ });
+ }
+ jobConfig.config.process[0].sample.samples = newSamples;
+ delete jobConfig.config.process[0].sample.prompts;
+ }
+ return jobConfig;
+};
diff --git a/ui/src/app/jobs/new/options.ts b/ui/src/app/jobs/new/options.ts
new file mode 100644
index 0000000000000000000000000000000000000000..71fdc9d8e767d2cbc078475d32b37e6996948199
--- /dev/null
+++ b/ui/src/app/jobs/new/options.ts
@@ -0,0 +1,441 @@
+import { GroupedSelectOption, SelectOption } from '@/types';
+
+type Control = 'depth' | 'line' | 'pose' | 'inpaint';
+
+type DisableableSections = 'model.quantize' | 'train.timestep_type' | 'network.conv';
+type AdditionalSections =
+ | 'datasets.control_path'
+ | 'datasets.do_i2v'
+ | 'sample.ctrl_img'
+ | 'datasets.num_frames'
+ | 'model.multistage'
+ | 'model.low_vram';
+type ModelGroup = 'image' | 'instruction' | 'video';
+
+export interface ModelArch {
+ name: string;
+ label: string;
+ group: ModelGroup;
+ controls?: Control[];
+ isVideoModel?: boolean;
+ defaults?: { [key: string]: any };
+ disableSections?: DisableableSections[];
+ additionalSections?: AdditionalSections[];
+ accuracyRecoveryAdapters?: { [key: string]: string };
+}
+
+const defaultNameOrPath = '';
+
+export const modelArchs: ModelArch[] = [
+ {
+ name: 'flux',
+ label: 'FLUX.1',
+ group: 'image',
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['black-forest-labs/FLUX.1-dev', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ },
+ disableSections: ['network.conv'],
+ },
+ {
+ name: 'flux_kontext',
+ label: 'FLUX.1-Kontext-dev',
+ group: 'instruction',
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['black-forest-labs/FLUX.1-Kontext-dev', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.timestep_type': ['weighted', 'sigmoid'],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['datasets.control_path', 'sample.ctrl_img'],
+ },
+ {
+ name: 'flex1',
+ label: 'Flex.1',
+ group: 'image',
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['ostris/Flex.1-alpha', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].train.bypass_guidance_embedding': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ },
+ disableSections: ['network.conv'],
+ },
+ {
+ name: 'flex2',
+ label: 'Flex.2',
+ group: 'image',
+ controls: ['depth', 'line', 'pose', 'inpaint'],
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['ostris/Flex.2-preview', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].model.model_kwargs': [
+ {
+ invert_inpaint_mask_chance: 0.2,
+ inpaint_dropout: 0.5,
+ control_dropout: 0.5,
+ inpaint_random_chance: 0.2,
+ do_random_inpainting: true,
+ random_blur_mask: true,
+ random_dialate_mask: true,
+ },
+ {},
+ ],
+ 'config.process[0].train.bypass_guidance_embedding': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ },
+ disableSections: ['network.conv'],
+ },
+ {
+ name: 'chroma',
+ label: 'Chroma',
+ group: 'image',
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['lodestones/Chroma1-Base', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ },
+ disableSections: ['network.conv'],
+ },
+ {
+ name: 'wan21:1b',
+ label: 'Wan 2.1 (1.3B)',
+ group: 'video',
+ isVideoModel: true,
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['Wan-AI/Wan2.1-T2V-1.3B-Diffusers', defaultNameOrPath],
+ 'config.process[0].model.quantize': [false, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].sample.num_frames': [41, 1],
+ 'config.process[0].sample.fps': [16, 1],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['datasets.num_frames', 'model.low_vram'],
+ },
+ {
+ name: 'wan21_i2v:14b480p',
+ label: 'Wan 2.1 I2V (14B-480P)',
+ group: 'video',
+ isVideoModel: true,
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['Wan-AI/Wan2.1-I2V-14B-480P-Diffusers', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].sample.num_frames': [41, 1],
+ 'config.process[0].sample.fps': [16, 1],
+ 'config.process[0].train.timestep_type': ['weighted', 'sigmoid'],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['sample.ctrl_img', 'datasets.num_frames', 'model.low_vram'],
+ },
+ {
+ name: 'wan21_i2v:14b',
+ label: 'Wan 2.1 I2V (14B-720P)',
+ group: 'video',
+ isVideoModel: true,
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['Wan-AI/Wan2.1-I2V-14B-720P-Diffusers', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].sample.num_frames': [41, 1],
+ 'config.process[0].sample.fps': [16, 1],
+ 'config.process[0].train.timestep_type': ['weighted', 'sigmoid'],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['sample.ctrl_img', 'datasets.num_frames', 'model.low_vram'],
+ },
+ {
+ name: 'wan21:14b',
+ label: 'Wan 2.1 (14B)',
+ group: 'video',
+ isVideoModel: true,
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['Wan-AI/Wan2.1-T2V-14B-Diffusers', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].sample.num_frames': [41, 1],
+ 'config.process[0].sample.fps': [16, 1],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['datasets.num_frames', 'model.low_vram'],
+ },
+ {
+ name: 'wan22_14b:t2v',
+ label: 'Wan 2.2 (14B)',
+ group: 'video',
+ isVideoModel: true,
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['ai-toolkit/Wan2.2-T2V-A14B-Diffusers-bf16', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].sample.num_frames': [41, 1],
+ 'config.process[0].sample.fps': [16, 1],
+ 'config.process[0].model.low_vram': [true, false],
+ 'config.process[0].train.timestep_type': ['linear', 'sigmoid'],
+ 'config.process[0].model.model_kwargs': [
+ {
+ train_high_noise: true,
+ train_low_noise: true,
+ },
+ {},
+ ],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['datasets.num_frames', 'model.low_vram', 'model.multistage'],
+ accuracyRecoveryAdapters: {
+ // '3 bit with ARA': 'uint3|ostris/accuracy_recovery_adapters/wan22_14b_t2i_torchao_uint3.safetensors',
+ '4 bit with ARA': 'uint4|ostris/accuracy_recovery_adapters/wan22_14b_t2i_torchao_uint4.safetensors',
+ },
+ },
+ {
+ name: 'wan22_14b_i2v',
+ label: 'Wan 2.2 I2V (14B)',
+ group: 'video',
+ isVideoModel: true,
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['ai-toolkit/Wan2.2-I2V-A14B-Diffusers-bf16', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].sample.num_frames': [41, 1],
+ 'config.process[0].sample.fps': [16, 1],
+ 'config.process[0].model.low_vram': [true, false],
+ 'config.process[0].train.timestep_type': ['linear', 'sigmoid'],
+ 'config.process[0].model.model_kwargs': [
+ {
+ train_high_noise: true,
+ train_low_noise: true,
+ },
+ {},
+ ],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['sample.ctrl_img', 'datasets.num_frames', 'model.low_vram', 'model.multistage'],
+ accuracyRecoveryAdapters: {
+ '4 bit with ARA': 'uint4|ostris/accuracy_recovery_adapters/wan22_14b_i2v_torchao_uint4.safetensors',
+ },
+ },
+ {
+ name: 'wan22_5b',
+ label: 'Wan 2.2 TI2V (5B)',
+ group: 'video',
+ isVideoModel: true,
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['Wan-AI/Wan2.2-TI2V-5B-Diffusers', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].model.low_vram': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].sample.num_frames': [121, 1],
+ 'config.process[0].sample.fps': [24, 1],
+ 'config.process[0].sample.width': [768, 1024],
+ 'config.process[0].sample.height': [768, 1024],
+ 'config.process[0].train.timestep_type': ['weighted', 'sigmoid'],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['sample.ctrl_img', 'datasets.num_frames', 'model.low_vram', 'datasets.do_i2v'],
+ },
+ {
+ name: 'lumina2',
+ label: 'Lumina2',
+ group: 'image',
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['Alpha-VLLM/Lumina-Image-2.0', defaultNameOrPath],
+ 'config.process[0].model.quantize': [false, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ },
+ disableSections: ['network.conv'],
+ },
+ {
+ name: 'qwen_image',
+ label: 'Qwen-Image',
+ group: 'image',
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['Qwen/Qwen-Image', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].model.low_vram': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.timestep_type': ['weighted', 'sigmoid'],
+ 'config.process[0].model.qtype': ['qfloat8', 'qfloat8'],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['model.low_vram'],
+ accuracyRecoveryAdapters: {
+ '3 bit with ARA': 'uint3|ostris/accuracy_recovery_adapters/qwen_image_torchao_uint3.safetensors',
+ },
+ },
+ {
+ name: 'qwen_image_edit',
+ label: 'Qwen-Image-Edit',
+ group: 'instruction',
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['Qwen/Qwen-Image-Edit', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].model.low_vram': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.timestep_type': ['weighted', 'sigmoid'],
+ 'config.process[0].model.qtype': ['qfloat8', 'qfloat8'],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['datasets.control_path', 'sample.ctrl_img', 'model.low_vram'],
+ accuracyRecoveryAdapters: {
+ '3 bit with ARA': 'uint3|ostris/accuracy_recovery_adapters/qwen_image_edit_torchao_uint3.safetensors',
+ },
+ },
+ {
+ name: 'hidream',
+ label: 'HiDream',
+ group: 'image',
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['HiDream-ai/HiDream-I1-Full', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.lr': [0.0002, 0.0001],
+ 'config.process[0].train.timestep_type': ['shift', 'sigmoid'],
+ 'config.process[0].network.network_kwargs.ignore_if_contains': [['ff_i.experts', 'ff_i.gate'], []],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['model.low_vram'],
+ },
+ {
+ name: 'hidream_e1',
+ label: 'HiDream E1',
+ group: 'instruction',
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['HiDream-ai/HiDream-E1-1', defaultNameOrPath],
+ 'config.process[0].model.quantize': [true, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.lr': [0.0001, 0.0001],
+ 'config.process[0].train.timestep_type': ['weighted', 'sigmoid'],
+ 'config.process[0].network.network_kwargs.ignore_if_contains': [['ff_i.experts', 'ff_i.gate'], []],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['datasets.control_path', 'sample.ctrl_img', 'model.low_vram'],
+ },
+ {
+ name: 'sdxl',
+ label: 'SDXL',
+ group: 'image',
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['stabilityai/stable-diffusion-xl-base-1.0', defaultNameOrPath],
+ 'config.process[0].model.quantize': [false, false],
+ 'config.process[0].model.quantize_te': [false, false],
+ 'config.process[0].sample.sampler': ['ddpm', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['ddpm', 'flowmatch'],
+ 'config.process[0].sample.guidance_scale': [6, 4],
+ },
+ disableSections: ['model.quantize', 'train.timestep_type'],
+ },
+ {
+ name: 'sd15',
+ label: 'SD 1.5',
+ group: 'image',
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['stable-diffusion-v1-5/stable-diffusion-v1-5', defaultNameOrPath],
+ 'config.process[0].sample.sampler': ['ddpm', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['ddpm', 'flowmatch'],
+ 'config.process[0].sample.width': [512, 1024],
+ 'config.process[0].sample.height': [512, 1024],
+ 'config.process[0].sample.guidance_scale': [6, 4],
+ },
+ disableSections: ['model.quantize', 'train.timestep_type'],
+ },
+ {
+ name: 'omnigen2',
+ label: 'OmniGen2',
+ group: 'image',
+ defaults: {
+ // default updates when [selected, unselected] in the UI
+ 'config.process[0].model.name_or_path': ['OmniGen2/OmniGen2', defaultNameOrPath],
+ 'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
+ 'config.process[0].model.quantize': [false, false],
+ 'config.process[0].model.quantize_te': [true, false],
+ },
+ disableSections: ['network.conv'],
+ additionalSections: ['datasets.control_path', 'sample.ctrl_img'],
+ },
+].sort((a, b) => {
+ // Sort by label, case-insensitive
+ return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
+}) as any;
+
+export const groupedModelOptions: GroupedSelectOption[] = modelArchs.reduce((acc, arch) => {
+ const group = acc.find(g => g.label === arch.group);
+ if (group) {
+ group.options.push({ value: arch.name, label: arch.label });
+ } else {
+ acc.push({
+ label: arch.group,
+ options: [{ value: arch.name, label: arch.label }],
+ });
+ }
+ return acc;
+}, [] as GroupedSelectOption[]);
+
+export const quantizationOptions: SelectOption[] = [
+ { value: '', label: '- NONE -' },
+ { value: 'qfloat8', label: 'float8 (default)' },
+ { value: 'uint8', label: '8 bit' },
+ { value: 'uint7', label: '7 bit' },
+ { value: 'uint6', label: '6 bit' },
+ { value: 'uint5', label: '5 bit' },
+ { value: 'uint4', label: '4 bit' },
+ { value: 'uint3', label: '3 bit' },
+ { value: 'uint2', label: '2 bit' },
+];
+
+export const defaultQtype = 'qfloat8';
diff --git a/ui/src/app/jobs/new/page.tsx b/ui/src/app/jobs/new/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1da413490f5703ceb577dd5bb29502a9e3970045
--- /dev/null
+++ b/ui/src/app/jobs/new/page.tsx
@@ -0,0 +1,306 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useSearchParams, useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { defaultJobConfig, defaultDatasetConfig, migrateJobConfig } from './jobConfig';
+import { JobConfig } from '@/types';
+import { objectCopy } from '@/utils/basic';
+import { useNestedState } from '@/utils/hooks';
+import { SelectInput } from '@/components/formInputs';
+import useSettings from '@/hooks/useSettings';
+import useGPUInfo from '@/hooks/useGPUInfo';
+import useDatasetList from '@/hooks/useDatasetList';
+import path from 'path';
+import { TopBar, MainContent } from '@/components/layout';
+import { Button } from '@headlessui/react';
+import { FaChevronLeft } from 'react-icons/fa';
+import SimpleJob from './SimpleJob';
+import AdvancedJob from './AdvancedJob';
+import ErrorBoundary from '@/components/ErrorBoundary';
+import { getJob, upsertJob } from '@/utils/storage/jobStorage';
+import { usingBrowserDb } from '@/utils/env';
+import { getUserDatasetPath, updateUserDatasetPath } from '@/utils/storage/datasetStorage';
+import { apiClient } from '@/utils/api';
+import { useAuth } from '@/contexts/AuthContext';
+import HFLoginButton from '@/components/HFLoginButton';
+
+const isDev = process.env.NODE_ENV === 'development';
+
+export default function TrainingForm() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const runId = searchParams.get('id');
+ const { status: authStatus } = useAuth();
+ const isAuthenticated = authStatus === 'authenticated';
+ const [gpuIDs, setGpuIDs] = useState(null);
+ const { settings, isSettingsLoaded } = useSettings();
+ const { gpuList, isGPUInfoLoaded } = useGPUInfo();
+ const { datasets, status: datasetFetchStatus } = useDatasetList();
+ const [datasetOptions, setDatasetOptions] = useState<{ value: string; label: string }[]>([]);
+ const [showAdvancedView, setShowAdvancedView] = useState(false);
+
+ const [jobConfig, setJobConfig] = useNestedState(objectCopy(defaultJobConfig));
+ const [status, setStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle');
+
+ // Track HF Jobs backend state
+ const [trainingBackend, setTrainingBackend] = useState<'local' | 'hf-jobs'>(
+ usingBrowserDb ? 'hf-jobs' : 'local',
+ );
+ const [hfJobSubmitted, setHfJobSubmitted] = useState(false);
+
+ useEffect(() => {
+ if (!isSettingsLoaded || !isAuthenticated) return;
+ if (datasetFetchStatus !== 'success') return;
+
+ let isMounted = true;
+
+ const buildDatasetOptions = async () => {
+ const options = await Promise.all(
+ datasets.map(async name => {
+ let datasetPath = settings.DATASETS_FOLDER ? path.join(settings.DATASETS_FOLDER, name) : '';
+
+ if (usingBrowserDb) {
+ const storedPath = getUserDatasetPath(name);
+ if (storedPath) {
+ datasetPath = storedPath;
+ } else {
+ try {
+ const response = await apiClient
+ .post('/api/datasets/create', { name })
+ .then(res => res.data);
+ if (response?.path) {
+ datasetPath = response.path;
+ updateUserDatasetPath(name, datasetPath);
+ }
+ } catch (err) {
+ console.error('Error resolving dataset path:', err);
+ }
+ }
+ }
+
+ if (!datasetPath) {
+ datasetPath = name;
+ }
+
+ return { value: datasetPath, label: name };
+ }),
+ );
+
+ if (!isMounted) {
+ return;
+ }
+
+ setDatasetOptions(options);
+ const defaultDatasetPath = defaultDatasetConfig.folder_path;
+
+ for (let i = 0; i < jobConfig.config.process[0].datasets.length; i++) {
+ const dataset = jobConfig.config.process[0].datasets[i];
+ if (dataset.folder_path === defaultDatasetPath) {
+ if (options.length > 0) {
+ setJobConfig(options[0].value, `config.process[0].datasets[${i}].folder_path`);
+ }
+ }
+ }
+ };
+
+ buildDatasetOptions();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [datasets, settings, isSettingsLoaded, datasetFetchStatus]);
+
+ useEffect(() => {
+ if (runId) {
+ getJob(runId)
+ .then(data => {
+ if (!data) {
+ throw new Error('Job not found');
+ }
+ setGpuIDs(data.gpu_ids);
+ const parsedJobConfig = migrateJobConfig(JSON.parse(data.job_config));
+ setJobConfig(parsedJobConfig);
+
+ if (parsedJobConfig.is_hf_job) {
+ setTrainingBackend('hf-jobs');
+ setHfJobSubmitted(true);
+ }
+ })
+ .catch(error => console.error('Error fetching training:', error));
+ }
+ }, [runId]);
+
+ useEffect(() => {
+ if (isGPUInfoLoaded) {
+ if (gpuIDs === null && gpuList.length > 0) {
+ setGpuIDs(`${gpuList[0].index}`);
+ }
+ }
+ }, [gpuList, isGPUInfoLoaded]);
+
+ useEffect(() => {
+ if (isSettingsLoaded) {
+ setJobConfig(settings.TRAINING_FOLDER, 'config.process[0].training_folder');
+ }
+ }, [settings, isSettingsLoaded]);
+
+ const saveJob = async () => {
+ if (!isAuthenticated) return;
+ if (status === 'saving') return;
+ setStatus('saving');
+
+ try {
+ const savedJob = await upsertJob({
+ id: runId || undefined,
+ name: jobConfig.config.name,
+ gpu_ids: gpuIDs,
+ job_config: {
+ ...jobConfig,
+ is_hf_job: trainingBackend === 'hf-jobs',
+ hf_job_submitted: hfJobSubmitted,
+ training_backend: trainingBackend,
+ },
+ status: trainingBackend === 'hf-jobs' ? (hfJobSubmitted ? 'submitted' : 'stopped') : undefined,
+ });
+
+ setStatus('success');
+ router.push(`/jobs/${savedJob.id}`);
+ } catch (error: any) {
+ console.log('Error saving training:', error);
+ if (error?.code === 'P2002') {
+ alert('Training name already exists. Please choose a different name.');
+ } else {
+ alert('Failed to save job. Please try again.');
+ }
+ } finally {
+ setTimeout(() => {
+ setStatus('idle');
+ }, 2000);
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ saveJob();
+ };
+
+ return (
+ <>
+
+
+
+
+
+
{runId ? 'Edit Training Job' : 'New Training Job'}
+
+
+ {showAdvancedView && isAuthenticated && (
+ <>
+
+ setGpuIDs(value)}
+ options={gpuList.map((gpu: any) => ({ value: `${gpu.index}`, label: `GPU #${gpu.index}` }))}
+ />
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+ {!isAuthenticated ? (
+
+
+
You need to sign in with Hugging Face or provide a valid access token before creating or editing jobs.
+
+
+
+ Manage authentication in Settings
+
+
+
+
+ ) : showAdvancedView ? (
+
+ ) : (
+
+
+ Advanced job detected. Please switch to advanced view to continue.
+
+ }
+ >
+ {
+ setHfJobSubmitted(true);
+ // Redirect to the job detail page
+ if (localJobId) {
+ router.push(`/jobs/${localJobId}`);
+ }
+ }}
+ forceHFBackend={usingBrowserDb}
+ />
+
+
+
+
+ )}
+ >
+ );
+}
+ useEffect(() => {
+ if (!isAuthenticated) {
+ setDatasetOptions([]);
+ }
+ }, [isAuthenticated]);
diff --git a/ui/src/app/jobs/page.tsx b/ui/src/app/jobs/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a29dd77c3a5463069f683f20f903add3b343fe40
--- /dev/null
+++ b/ui/src/app/jobs/page.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import JobsTable from '@/components/JobsTable';
+import { TopBar, MainContent } from '@/components/layout';
+import Link from 'next/link';
+import { useAuth } from '@/contexts/AuthContext';
+import HFLoginButton from '@/components/HFLoginButton';
+
+export default function Dashboard() {
+ const { status: authStatus } = useAuth();
+ const isAuthenticated = authStatus === 'authenticated';
+
+ return (
+ <>
+
+
+
Training Jobs
+
+
+
+ {isAuthenticated ? (
+
+ New Training Job
+
+ ) : (
+
+ Sign in to create jobs
+
+ )}
+
+
+
+ {isAuthenticated ? (
+
+ ) : (
+
+
Sign in with Hugging Face or add a personal access token to view and manage training jobs.
+
+
+
+ Manage tokens in Settings
+
+
+
+ )}
+
+ >
+ );
+}
diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b3ce381e88faf2bbd71b8cb67f61662d6bead943
--- /dev/null
+++ b/ui/src/app/layout.tsx
@@ -0,0 +1,50 @@
+import type { Metadata } from 'next';
+import { Inter } from 'next/font/google';
+import './globals.css';
+import Sidebar from '@/components/Sidebar';
+import { ThemeProvider } from '@/components/ThemeProvider';
+import ConfirmModal from '@/components/ConfirmModal';
+import SampleImageModal from '@/components/SampleImageModal';
+import { Suspense } from 'react';
+import AuthWrapper from '@/components/AuthWrapper';
+import DocModal from '@/components/DocModal';
+import { AuthProvider } from '@/contexts/AuthContext';
+
+export const dynamic = 'force-dynamic';
+
+const inter = Inter({ subsets: ['latin'] });
+
+export const metadata: Metadata = {
+ title: 'Ostris - AI Toolkit',
+ description: 'A toolkit for building AI things.',
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ // Check if the AI_TOOLKIT_AUTH environment variable is set
+ const authRequired = process.env.AI_TOOLKIT_AUTH ? true : false;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/app/manifest.json b/ui/src/app/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..ced3ca5d79e5ec230be33c6f0e0907fb419c5588
--- /dev/null
+++ b/ui/src/app/manifest.json
@@ -0,0 +1,21 @@
+{
+ "name": "AI Toolkit",
+ "short_name": "AIToolkit",
+ "icons": [
+ {
+ "src": "/web-app-manifest-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/web-app-manifest-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "theme_color": "#000000",
+ "background_color": "#000000",
+ "display": "standalone"
+}
\ No newline at end of file
diff --git a/ui/src/app/page.tsx b/ui/src/app/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f889cb6122cb25d33bccef074ef51a6b26b692c9
--- /dev/null
+++ b/ui/src/app/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from 'next/navigation';
+
+export default function Home() {
+ redirect('/dashboard');
+}
diff --git a/ui/src/app/settings/page.tsx b/ui/src/app/settings/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..25fc6bd922360cda4452a119810529a9c132b695
--- /dev/null
+++ b/ui/src/app/settings/page.tsx
@@ -0,0 +1,264 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import useSettings from '@/hooks/useSettings';
+import { TopBar, MainContent } from '@/components/layout';
+import { persistSettings } from '@/utils/storage/settingsStorage';
+import { useAuth } from '@/contexts/AuthContext';
+import HFLoginButton from '@/components/HFLoginButton';
+import { useMemo } from 'react';
+import Link from 'next/link';
+
+export default function Settings() {
+ const { settings, setSettings } = useSettings();
+ const { status: authStatus, namespace, oauthAvailable, loginWithOAuth, logout, setManualToken, error: authError, token: authToken } = useAuth();
+ const [status, setStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle');
+ const [manualToken, setManualTokenInput] = useState(settings.HF_TOKEN || '');
+ const isAuthenticated = authStatus === 'authenticated';
+
+ useEffect(() => {
+ setManualTokenInput(settings.HF_TOKEN || '');
+ }, [settings.HF_TOKEN]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setStatus('saving');
+
+ persistSettings(settings)
+ .then(() => {
+ setStatus('success');
+ })
+ .catch(error => {
+ console.error('Error saving settings:', error);
+ setStatus('error');
+ })
+ .finally(() => {
+ setTimeout(() => setStatus('idle'), 2000);
+ });
+ };
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setSettings(prev => ({ ...prev, [name]: value }));
+ };
+
+ const handleManualSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ await setManualToken(manualToken);
+ };
+
+ const authDescription = useMemo(() => {
+ if (authStatus === 'checking') {
+ return 'Checking your Hugging Face session…';
+ }
+ if (isAuthenticated) {
+ return `Connected as ${namespace}`;
+ }
+ return 'Sign in to use Hugging Face Jobs or submit your own access token.';
+ }, [authStatus, isAuthenticated, namespace]);
+
+ return (
+ <>
+
+
+
Settings
+
+
+
+ {isAuthenticated ? (
+ Welcome, {namespace || 'user'}
+ ) : (
+ Authenticate to unlock training features
+ )}
+
+
+
+
+
+
+
+
Sign in with Hugging Face
+
{authDescription}
+
+ {isAuthenticated && (
+
Authenticated
+ )}
+
+
+ {isAuthenticated ? (
+
+ ) : (
+ <>
+
+ {!oauthAvailable && (
+
+ OAuth is unavailable. Set HF_OAUTH_CLIENT_ID/SECRET on the server.
+
+ )}
+ >
+ )}
+
+ {!isAuthenticated && authError && (
+
{authError}
+ )}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/ui/src/components/AddImagesModal.tsx b/ui/src/components/AddImagesModal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ff91a8836dcfe7dc67a9ee237d7d5a1b16941cf2
--- /dev/null
+++ b/ui/src/components/AddImagesModal.tsx
@@ -0,0 +1,152 @@
+'use client';
+import { createGlobalState } from 'react-global-hooks';
+import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react';
+import { FaUpload } from 'react-icons/fa';
+import { useCallback, useState } from 'react';
+import { useDropzone } from 'react-dropzone';
+import { apiClient } from '@/utils/api';
+
+export interface AddImagesModalState {
+ datasetName: string;
+ onComplete?: () => void;
+}
+
+export const addImagesModalState = createGlobalState(null);
+
+export const openImagesModal = (datasetName: string, onComplete: () => void) => {
+ addImagesModalState.set({ datasetName, onComplete });
+};
+
+export default function AddImagesModal() {
+ const [addImagesModalInfo, setAddImagesModalInfo] = addImagesModalState.use();
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [isUploading, setIsUploading] = useState(false);
+ const open = addImagesModalInfo !== null;
+
+ const onCancel = () => {
+ if (!isUploading) {
+ setAddImagesModalInfo(null);
+ }
+ };
+
+ const onDone = () => {
+ if (addImagesModalInfo?.onComplete && !isUploading) {
+ addImagesModalInfo.onComplete();
+ setAddImagesModalInfo(null);
+ }
+ };
+
+ const onDrop = useCallback(
+ async (acceptedFiles: File[]) => {
+ if (acceptedFiles.length === 0) return;
+
+ setIsUploading(true);
+ setUploadProgress(0);
+
+ const formData = new FormData();
+ acceptedFiles.forEach(file => {
+ formData.append('files', file);
+ });
+ formData.append('datasetName', addImagesModalInfo?.datasetName || '');
+
+ try {
+ await apiClient.post(`/api/datasets/upload`, formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ onUploadProgress: progressEvent => {
+ const percentCompleted = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 100));
+ setUploadProgress(percentCompleted);
+ },
+ timeout: 0, // Disable timeout
+ });
+
+ onDone();
+ } catch (error) {
+ console.error('Upload failed:', error);
+ } finally {
+ setIsUploading(false);
+ setUploadProgress(0);
+ }
+ },
+ [addImagesModalInfo],
+ );
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ accept: {
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
+ 'video/*': ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'],
+ 'text/*': ['.txt'],
+ },
+ multiple: true,
+ });
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/AddSingleImageModal.tsx b/ui/src/components/AddSingleImageModal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ba32ef9dff916b5f6e605909f5d328dfce49783a
--- /dev/null
+++ b/ui/src/components/AddSingleImageModal.tsx
@@ -0,0 +1,141 @@
+'use client';
+import { createGlobalState } from 'react-global-hooks';
+import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react';
+import { FaUpload } from 'react-icons/fa';
+import { useCallback, useState } from 'react';
+import { useDropzone } from 'react-dropzone';
+import { apiClient } from '@/utils/api';
+
+export interface AddSingleImageModalState {
+
+ onComplete?: (imagePath: string|null) => void;
+}
+
+export const addSingleImageModalState = createGlobalState(null);
+
+export const openAddImageModal = (onComplete: (imagePath: string|null) => void) => {
+ addSingleImageModalState.set({onComplete });
+};
+
+export default function AddSingleImageModal() {
+ const [addSingleImageModalInfo, setAddSingleImageModalInfo] = addSingleImageModalState.use();
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [isUploading, setIsUploading] = useState(false);
+ const open = addSingleImageModalInfo !== null;
+
+ const onCancel = () => {
+ if (!isUploading) {
+ setAddSingleImageModalInfo(null);
+ }
+ };
+
+ const onDone = (imagePath: string|null) => {
+ if (addSingleImageModalInfo?.onComplete && !isUploading) {
+ addSingleImageModalInfo.onComplete(imagePath);
+ setAddSingleImageModalInfo(null);
+ }
+ };
+
+ const onDrop = useCallback(
+ async (acceptedFiles: File[]) => {
+ if (acceptedFiles.length === 0) return;
+
+ setIsUploading(true);
+ setUploadProgress(0);
+
+ const formData = new FormData();
+ acceptedFiles.forEach(file => {
+ formData.append('files', file);
+ });
+
+ try {
+ const resp = await apiClient.post(`/api/img/upload`, formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ onUploadProgress: progressEvent => {
+ const percentCompleted = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 100));
+ setUploadProgress(percentCompleted);
+ },
+ timeout: 0, // Disable timeout
+ });
+ console.log('Upload successful:', resp.data);
+
+ onDone(resp.data.files[0] || null);
+ } catch (error) {
+ console.error('Upload failed:', error);
+ } finally {
+ setIsUploading(false);
+ setUploadProgress(0);
+ }
+ },
+ [addSingleImageModalInfo],
+ );
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ accept: {
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
+ },
+ multiple: false,
+ });
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/AuthWrapper.tsx b/ui/src/components/AuthWrapper.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bdf287a8dca4aa022b852680a13c8c3b0bb33926
--- /dev/null
+++ b/ui/src/components/AuthWrapper.tsx
@@ -0,0 +1,166 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+import { apiClient, isAuthorizedState } from '@/utils/api';
+import { createGlobalState } from 'react-global-hooks';
+
+interface AuthWrapperProps {
+ authRequired: boolean;
+ children: React.ReactNode | React.ReactNode[];
+}
+
+export default function AuthWrapper({ authRequired, children }: AuthWrapperProps) {
+ const [token, setToken] = useState('');
+ // start with true, and deauth if needed
+ const [isAuthorizedGlobal, setIsAuthorized] = isAuthorizedState.use();
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [isBrowser, setIsBrowser] = useState(false);
+ const inputRef = useRef(null);
+
+ const isAuthorized = authRequired ? isAuthorizedGlobal : true;
+
+ // Set isBrowser to true when component mounts
+ useEffect(() => {
+ setIsBrowser(true);
+ // Get token from localStorage only after component has mounted
+ const storedToken = localStorage.getItem('AI_TOOLKIT_AUTH') || '';
+ setToken(storedToken);
+ checkAuth();
+ }, []);
+
+ // auto focus on input when not authorized
+ useEffect(() => {
+ if (isAuthorized) {
+ return;
+ }
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, 100);
+ }, [isAuthorized]);
+
+ const checkAuth = async () => {
+ // always get current stored token here to avoid state race conditions
+ const currentToken = localStorage.getItem('AI_TOOLKIT_AUTH') || '';
+ if (!authRequired || isLoading || currentToken === '') {
+ return;
+ }
+ setIsLoading(true);
+ setError('');
+ try {
+ const response = await apiClient.get('/api/auth');
+ if (response.data.isAuthenticated) {
+ setIsAuthorized(true);
+ } else {
+ setIsAuthorized(false);
+ setError('Invalid token. Please try again.');
+ }
+ } catch (err) {
+ setIsAuthorized(false);
+ console.log(err);
+ setError('Invalid token. Please try again.');
+ }
+ setIsLoading(false);
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+
+ if (!token.trim()) {
+ setError('Please enter your token');
+ return;
+ }
+
+ if (isBrowser) {
+ localStorage.setItem('AI_TOOLKIT_AUTH', token);
+ checkAuth();
+ }
+ };
+
+ if (isAuthorized) {
+ return <>{children}>;
+ }
+
+ return (
+
+ {/* Left side - decorative or brand area */}
+
+
+ {/* Replace with your own logo */}
+
+

+
+
+
AI Toolkit
+
+
+ {/* Right side - login form */}
+
+
+
+ {/* Mobile logo */}
+
+

+
+
+
+
AI Toolkit
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/Card.tsx b/ui/src/components/Card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..13c7409b8be089a104eb6613664a188cb35d78d7
--- /dev/null
+++ b/ui/src/components/Card.tsx
@@ -0,0 +1,15 @@
+interface CardProps {
+ title?: string;
+ children?: React.ReactNode;
+}
+
+const Card: React.FC = ({ title, children }) => {
+ return (
+
+ {title && {title}
}
+ {children ? children : null}
+
+ );
+};
+
+export default Card;
diff --git a/ui/src/components/ConfirmModal.tsx b/ui/src/components/ConfirmModal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6ecea8136accffeb9f312afb0130d2988ef485d3
--- /dev/null
+++ b/ui/src/components/ConfirmModal.tsx
@@ -0,0 +1,201 @@
+'use client';
+import { useRef } from 'react';
+import { useState, useEffect } from 'react';
+import { createGlobalState } from 'react-global-hooks';
+import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react';
+import { FaExclamationTriangle, FaInfo } from 'react-icons/fa';
+import { TextInput } from './formInputs';
+import React from 'react';
+import { useFromNull } from '@/hooks/useFromNull';
+import classNames from 'classnames';
+
+export interface ConfirmState {
+ title: string;
+ message?: string;
+ confirmText?: string;
+ type?: 'danger' | 'warning' | 'info';
+ inputTitle?: string;
+ onConfirm?: (value?: string) => void | Promise;
+ onCancel?: () => void;
+}
+
+export const confirmstate = createGlobalState(null);
+
+export const openConfirm = (confirmProps: ConfirmState) => {
+ confirmstate.set(confirmProps);
+};
+
+export default function ConfirmModal() {
+ const [confirm, setConfirm] = confirmstate.use();
+ const [isOpen, setIsOpen] = useState(false);
+ const [inputValue, setInputValue] = useState('');
+ const inputRef = useRef(null);
+
+ useFromNull(() => {
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, 100);
+ }, [confirm]);
+
+ useEffect(() => {
+ if (confirm) {
+ setIsOpen(true);
+ setInputValue('');
+ }
+ }, [confirm]);
+
+ useEffect(() => {
+ if (!isOpen) {
+ // use timeout to allow the dialog to close before resetting the state
+ setTimeout(() => {
+ setConfirm(null);
+ }, 500);
+ }
+ }, [isOpen]);
+
+ const onCancel = () => {
+ if (confirm?.onCancel) {
+ confirm.onCancel();
+ }
+ setIsOpen(false);
+ };
+
+ const onConfirm = () => {
+ if (confirm?.onConfirm) {
+ confirm.onConfirm(inputValue);
+ }
+ setIsOpen(false);
+ };
+
+ let Icon = FaExclamationTriangle;
+ let color = confirm?.type || 'danger';
+
+ // Use conditional rendering for icon
+ if (color === 'info') {
+ Icon = FaInfo;
+ }
+
+ // Color mapping for background colors
+ const getBgColor = () => {
+ switch (color) {
+ case 'danger':
+ return 'bg-red-500';
+ case 'warning':
+ return 'bg-yellow-500';
+ case 'info':
+ return 'bg-blue-500';
+ default:
+ return 'bg-red-500';
+ }
+ };
+
+ // Color mapping for text colors
+ const getTextColor = () => {
+ switch (color) {
+ case 'danger':
+ return 'text-red-950';
+ case 'warning':
+ return 'text-yellow-950';
+ case 'info':
+ return 'text-blue-950';
+ default:
+ return 'text-red-950';
+ }
+ };
+
+ // Color mapping for titles
+ const getTitleColor = () => {
+ switch (color) {
+ case 'danger':
+ return 'text-red-500';
+ case 'warning':
+ return 'text-yellow-500';
+ case 'info':
+ return 'text-blue-500';
+ default:
+ return 'text-red-500';
+ }
+ };
+
+ // Button background color mapping
+ const getButtonBgColor = () => {
+ switch (color) {
+ case 'danger':
+ return 'bg-red-700 hover:bg-red-500';
+ case 'warning':
+ return 'bg-yellow-700 hover:bg-yellow-500';
+ case 'info':
+ return 'bg-blue-700 hover:bg-blue-500';
+ default:
+ return 'bg-red-700 hover:bg-red-500';
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/DatasetImageCard.tsx b/ui/src/components/DatasetImageCard.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7eb562b5cd6edb7906f7e9e55507223ac5141878
--- /dev/null
+++ b/ui/src/components/DatasetImageCard.tsx
@@ -0,0 +1,231 @@
+import React, { useRef, useEffect, useState, ReactNode, KeyboardEvent } from 'react';
+import { FaTrashAlt, FaEye, FaEyeSlash } from 'react-icons/fa';
+import { openConfirm } from './ConfirmModal';
+import classNames from 'classnames';
+import { apiClient } from '@/utils/api';
+import { isVideo } from '@/utils/basic';
+
+interface DatasetImageCardProps {
+ imageUrl: string;
+ alt: string;
+ children?: ReactNode;
+ className?: string;
+ onDelete?: () => void;
+}
+
+const DatasetImageCard: React.FC = ({
+ imageUrl,
+ alt,
+ children,
+ className = '',
+ onDelete = () => {},
+}) => {
+ const cardRef = useRef(null);
+ const [isVisible, setIsVisible] = useState(false);
+ const [inViewport, setInViewport] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+ const [isCaptionLoaded, setIsCaptionLoaded] = useState(false);
+ const [caption, setCaption] = useState('');
+ const [savedCaption, setSavedCaption] = useState('');
+ const isGettingCaption = useRef(false);
+
+ const fetchCaption = async () => {
+ if (isGettingCaption.current || isCaptionLoaded) return;
+ isGettingCaption.current = true;
+ apiClient
+ .post(`/api/caption/get`, { imgPath: imageUrl })
+ .then(res => res.data)
+ .then(data => {
+ console.log('Caption fetched:', data);
+
+ setCaption(data || '');
+ setSavedCaption(data || '');
+ setIsCaptionLoaded(true);
+ })
+ .catch(error => {
+ console.error('Error fetching caption:', error);
+ })
+ .finally(() => {
+ isGettingCaption.current = false;
+ });
+ };
+
+ const saveCaption = () => {
+ const trimmedCaption = caption.trim();
+ if (trimmedCaption === savedCaption) return;
+ apiClient
+ .post('/api/img/caption', { imgPath: imageUrl, caption: trimmedCaption })
+ .then(res => res.data)
+ .then(data => {
+ console.log('Caption saved:', data);
+ setSavedCaption(trimmedCaption);
+ })
+ .catch(error => {
+ console.error('Error saving caption:', error);
+ });
+ };
+
+ // Only fetch caption when the component is both in viewport and visible
+ useEffect(() => {
+ if (inViewport && isVisible) {
+ fetchCaption();
+ }
+ }, [inViewport, isVisible]);
+
+ useEffect(() => {
+ // Create intersection observer to check viewport visibility
+ const observer = new IntersectionObserver(
+ entries => {
+ if (entries[0].isIntersecting) {
+ setInViewport(true);
+ // Initialize isVisible to true when first coming into view
+ if (!isVisible) {
+ setIsVisible(true);
+ }
+ } else {
+ setInViewport(false);
+ }
+ },
+ { threshold: 0.1 },
+ );
+
+ if (cardRef.current) {
+ observer.observe(cardRef.current);
+ }
+
+ return () => {
+ observer.disconnect();
+ };
+ }, []);
+
+ const toggleVisibility = (): void => {
+ setIsVisible(prev => !prev);
+ if (!isVisible && !isCaptionLoaded) {
+ fetchCaption();
+ }
+ };
+
+ const handleLoad = (): void => {
+ setLoaded(true);
+ };
+
+ const handleKeyDown = (e: KeyboardEvent): void => {
+ // If Enter is pressed without Shift, prevent default behavior and save
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ saveCaption();
+ }
+ };
+
+ const isCaptionCurrent = caption.trim() === savedCaption;
+
+ const isItAVideo = isVideo(imageUrl);
+
+ return (
+
+ {/* Square image container */}
+
+
+ {inViewport && isVisible && (
+ <>
+ {isItAVideo ? (
+
+ ) : (
+
}`})
+ )}
+ >
+ )}
+ {!isVisible && (
+
+
+
+ )}
+ {children &&
{children}
}
+
+
+
+
+ {inViewport && isVisible && (
+
+ {imageUrl}
+
+ )}
+
+
+ {inViewport && isVisible && isCaptionLoaded && (
+
+ )}
+ {(!inViewport || !isVisible) && isCaptionLoaded && (
+
+ {isVisible ? 'Scroll into view to edit caption' : 'Show content to edit caption'}
+
+ )}
+ {!isCaptionLoaded && (
+
Loading caption...
+ )}
+
+
+ );
+};
+
+export default DatasetImageCard;
diff --git a/ui/src/components/DocModal.tsx b/ui/src/components/DocModal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bfdd6bf4c339a1522668372831a0c13b050111ab
--- /dev/null
+++ b/ui/src/components/DocModal.tsx
@@ -0,0 +1,59 @@
+'use client';
+import { createGlobalState } from 'react-global-hooks';
+import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react';
+import React from 'react';
+import { ConfigDoc } from '@/types';
+
+export const docState = createGlobalState(null);
+
+export const openDoc = (doc: ConfigDoc) => {
+ docState.set({ ...doc });
+};
+
+export default function DocModal() {
+ const [doc, setDoc] = docState.use();
+ const isOpen = !!doc;
+
+ const onClose = () => {
+ setDoc(null);
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/ErrorBoundary.tsx b/ui/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7d1a6eca9091eb1e0a5c49d514ab305ef95c4b2c
--- /dev/null
+++ b/ui/src/components/ErrorBoundary.tsx
@@ -0,0 +1,41 @@
+// ErrorBoundary.tsx
+'use client';
+
+import React, { ReactNode, ErrorInfo, Component } from 'react';
+
+interface ErrorBoundaryProps {
+ children: ReactNode;
+ fallback?: ReactNode;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+}
+
+class ErrorBoundary extends Component {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(_: Error): ErrorBoundaryState {
+ // Update state so the next render will show the fallback UI
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
+ // You can log the error to an error reporting service
+ console.error("Error caught by ErrorBoundary:", error, errorInfo);
+ }
+
+ render(): ReactNode {
+ if (this.state.hasError) {
+ // You can render any custom fallback UI
+ return this.props.fallback || Something went wrong.
;
+ }
+
+ return this.props.children;
+ }
+}
+
+export default ErrorBoundary;
\ No newline at end of file
diff --git a/ui/src/components/FilesWidget.tsx b/ui/src/components/FilesWidget.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b73dabf096af4e7566be2507fb95ef659268f643
--- /dev/null
+++ b/ui/src/components/FilesWidget.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import useFilesList from '@/hooks/useFilesList';
+import Link from 'next/link';
+import { Loader2, AlertCircle, Download, Box, Brain } from 'lucide-react';
+
+export default function FilesWidget({ jobID }: { jobID: string }) {
+ const { files, status, refreshFiles } = useFilesList(jobID, 5000);
+
+ const cleanSize = (size: number) => {
+ if (size < 1024) {
+ return `${size} B`;
+ } else if (size < 1024 * 1024) {
+ return `${(size / 1024).toFixed(1)} KB`;
+ } else if (size < 1024 * 1024 * 1024) {
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
+ } else {
+ return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
+ }
+ };
+
+ return (
+
+
+
+
+
Checkpoints
+ {files.length}
+
+
+
+
+ {status === 'loading' && (
+
+
+
+ )}
+
+ {status === 'error' && (
+
+
+
Error loading checkpoints
+
+ )}
+
+ {['success', 'refreshing'].includes(status) && (
+
+ )}
+
+ {['success', 'refreshing'].includes(status) && files.length === 0 && (
+
No checkpoints available
+ )}
+
+
+ );
+}
diff --git a/ui/src/components/FullscreenDropOverlay.tsx b/ui/src/components/FullscreenDropOverlay.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..425b7aef6a94d784cb21d6ccedcc8b99bd9c5375
--- /dev/null
+++ b/ui/src/components/FullscreenDropOverlay.tsx
@@ -0,0 +1,182 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useDropzone } from 'react-dropzone';
+import { FaUpload } from 'react-icons/fa';
+import { apiClient } from '@/utils/api';
+
+type AcceptMap = {
+ [mime: string]: string[];
+};
+
+interface FullscreenDropOverlayProps {
+ datasetName: string; // where to upload
+ onComplete?: () => void; // called after successful upload
+ accept?: AcceptMap; // optional override
+ multiple?: boolean; // default true
+}
+
+export default function FullscreenDropOverlay({
+ datasetName,
+ onComplete,
+ accept,
+ multiple = true,
+}: FullscreenDropOverlayProps) {
+ const [visible, setVisible] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const dragDepthRef = useRef(0); // drag-enter/leave tracking
+
+ // Only show the overlay for real file drags (not text, images from page, etc)
+ const isFileDrag = (e: DragEvent) => {
+ const types = e?.dataTransfer?.types;
+ return !!types && Array.from(types).includes('Files');
+ };
+
+ // Window-level drag listeners to toggle visibility
+ useEffect(() => {
+ const onDragEnter = (e: DragEvent) => {
+ if (!isFileDrag(e)) return;
+ dragDepthRef.current += 1;
+ setVisible(true);
+ e.preventDefault();
+ };
+ const onDragOver = (e: DragEvent) => {
+ if (!isFileDrag(e)) return;
+ // Must preventDefault to allow dropping in the browser
+ e.preventDefault();
+ if (!visible) setVisible(true);
+ };
+ const onDragLeave = (e: DragEvent) => {
+ if (!isFileDrag(e)) return;
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
+ if (dragDepthRef.current === 0 && !isUploading) {
+ setVisible(false);
+ }
+ };
+ const onDrop = (e: DragEvent) => {
+ if (!isFileDrag(e)) return;
+ // Prevent browser from opening the file
+ e.preventDefault();
+ dragDepthRef.current = 0;
+ // We do NOT hide here; the dropzone onDrop will handle workflow visibility.
+ };
+
+ window.addEventListener('dragenter', onDragEnter);
+ window.addEventListener('dragover', onDragOver);
+ window.addEventListener('dragleave', onDragLeave);
+ window.addEventListener('drop', onDrop);
+
+ return () => {
+ window.removeEventListener('dragenter', onDragEnter);
+ window.removeEventListener('dragover', onDragOver);
+ window.removeEventListener('dragleave', onDragLeave);
+ window.removeEventListener('drop', onDrop);
+ };
+ }, [visible, isUploading]);
+
+ const onDrop = useCallback(
+ async (acceptedFiles: File[]) => {
+ if (acceptedFiles.length === 0) {
+ // no accepted files; hide overlay cleanly
+ setVisible(false);
+ return;
+ }
+
+ setIsUploading(true);
+ setUploadProgress(0);
+
+ const formData = new FormData();
+ acceptedFiles.forEach(file => formData.append('files', file));
+ formData.append('datasetName', datasetName || '');
+
+ try {
+ await apiClient.post(`/api/datasets/upload`, formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ onUploadProgress: pe => {
+ const percent = Math.round(((pe.loaded || 0) * 100) / (pe.total || pe.loaded || 1));
+ setUploadProgress(percent);
+ },
+ timeout: 0,
+ });
+ onComplete?.();
+ } catch (err) {
+ console.error('Upload failed:', err);
+ } finally {
+ setIsUploading(false);
+ setUploadProgress(0);
+ setVisible(false);
+ }
+ },
+ [datasetName, onComplete],
+ );
+
+ const dropAccept = useMemo(
+ () =>
+ accept || {
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
+ 'video/*': ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'],
+ 'text/*': ['.txt'],
+ },
+ [accept],
+ );
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ accept: dropAccept,
+ multiple,
+ noClick: true,
+ noKeyboard: true,
+ // Prevent "folder opens" by browser if someone drags outside the overlay mid-drop:
+ preventDropOnDocument: true,
+ });
+
+ return (
+
+ {/* Fullscreen capture layer */}
+
+
+ {/* Backdrop: keep it subtle so context remains visible */}
+
+
+ {/* Center drop target UI */}
+
+
+
+
+ {!isUploading ? (
+ <>
+
Drop files to upload
+
+ Destination: {datasetName || 'unknown'}
+
+
Images, videos, or .txt supported
+ >
+ ) : (
+ <>
+
Uploading… {uploadProgress}%
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/ui/src/components/GPUMonitor.tsx b/ui/src/components/GPUMonitor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9c900035931241f9fd99e739b8d99cdd50089f53
--- /dev/null
+++ b/ui/src/components/GPUMonitor.tsx
@@ -0,0 +1,155 @@
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import { GPUApiResponse } from '@/types';
+import Loading from '@/components/Loading';
+import GPUWidget from '@/components/GPUWidget';
+import { apiClient } from '@/utils/api';
+import { usingBrowserDb } from '@/utils/env';
+
+const GpuMonitor: React.FC = () => {
+ const [gpuData, setGpuData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [lastUpdated, setLastUpdated] = useState(null);
+ const isFetchingGpuRef = useRef(false);
+
+ useEffect(() => {
+ if (usingBrowserDb) {
+ setLoading(false);
+ setGpuData(null);
+ setError(null);
+ setLastUpdated(null);
+ return;
+ }
+
+ const fetchGpuInfo = async () => {
+ if (isFetchingGpuRef.current) {
+ return;
+ }
+ setLoading(true);
+ isFetchingGpuRef.current = true;
+ apiClient
+ .get('/api/gpu')
+ .then(res => res.data)
+ .then(data => {
+ setGpuData(data);
+ setLastUpdated(new Date());
+ setError(null);
+ })
+ .catch(err => {
+ setError(`Failed to fetch GPU data: ${err instanceof Error ? err.message : String(err)}`);
+ })
+ .finally(() => {
+ isFetchingGpuRef.current = false;
+ setLoading(false);
+ });
+ };
+
+ fetchGpuInfo();
+ const intervalId = setInterval(fetchGpuInfo, 1000);
+ return () => clearInterval(intervalId);
+ }, []);
+
+ const getGridClasses = (gpuCount: number): string => {
+ switch (gpuCount) {
+ case 1:
+ return 'grid-cols-1';
+ case 2:
+ return 'grid-cols-2';
+ case 3:
+ return 'grid-cols-3';
+ case 4:
+ return 'grid-cols-4';
+ case 5:
+ case 6:
+ return 'grid-cols-3';
+ case 7:
+ case 8:
+ return 'grid-cols-4';
+ case 9:
+ case 10:
+ return 'grid-cols-5';
+ default:
+ return 'grid-cols-3';
+ }
+ };
+
+ const content = useMemo(() => {
+ if (usingBrowserDb) {
+ return (
+
+ Cloud Mode
+
+ This AI Toolkit instance runs training through Hugging Face Jobs. GPU telemetry is unavailable in this mode.
+
+
+ );
+ }
+
+ if (loading && !gpuData) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+ Error!
+ {error}
+
+ );
+ }
+
+ if (!gpuData) {
+ return (
+
+ No GPU data available.
+
+ );
+ }
+
+ if (!gpuData.hasNvidiaSmi) {
+ return (
+
+
No NVIDIA GPUs detected!
+
nvidia-smi is not available on this system.
+ {gpuData.error &&
{gpuData.error}
}
+
+ );
+ }
+
+ if (gpuData.gpus.length === 0) {
+ return (
+
+ No GPUs found, but nvidia-smi is available.
+
+ );
+ }
+
+ const gridClass = getGridClasses(gpuData?.gpus?.length || 1);
+
+ return (
+
+ {gpuData.gpus.map((gpu, idx) => (
+
+ ))}
+
+ );
+ }, [loading, gpuData, error, usingBrowserDb]);
+
+ return (
+
+
+
GPU Monitor
+ {usingBrowserDb ? (
+
Hugging Face Jobs (cloud mode)
+ ) : (
+
+ Last updated: {lastUpdated ? lastUpdated.toLocaleTimeString() : '—'}
+
+ )}
+
+ {content}
+
+ );
+};
+
+export default GpuMonitor;
diff --git a/ui/src/components/GPUWidget.tsx b/ui/src/components/GPUWidget.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e21b019041f69c809dab98444f41d5b9b4194b92
--- /dev/null
+++ b/ui/src/components/GPUWidget.tsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import { GpuInfo } from '@/types';
+import { ChevronRight, Thermometer, Zap, Clock, HardDrive, Fan, Cpu } from 'lucide-react';
+
+interface GPUWidgetProps {
+ gpu: GpuInfo;
+}
+
+export default function GPUWidget({ gpu }: GPUWidgetProps) {
+ const formatMemory = (mb: number): string => {
+ return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb} MB`;
+ };
+
+ const getUtilizationColor = (value: number): string => {
+ return value < 30 ? 'bg-emerald-500' : value < 70 ? 'bg-amber-500' : 'bg-rose-500';
+ };
+
+ const getTemperatureColor = (temp: number): string => {
+ return temp < 50 ? 'text-emerald-500' : temp < 80 ? 'text-amber-500' : 'text-rose-500';
+ };
+
+ return (
+
+
+
+
{gpu.name}
+ #{gpu.index}
+
+
+
+
+ {/* Temperature, Fan, and Utilization Section */}
+
+
+
+
+
+
Temperature
+
{gpu.temperature}°C
+
+
+
+
+
+
Fan Speed
+
{gpu.fan.speed}%
+
+
+
+
+
+
+
GPU Load
+
{gpu.utilization.gpu}%
+
+
+
+
+
Memory
+
+ {((gpu.memory.used / gpu.memory.total) * 100).toFixed(1)}%
+
+
+
+
+ {formatMemory(gpu.memory.used)} / {formatMemory(gpu.memory.total)}
+
+
+
+
+ {/* Power and Clocks Section */}
+
+
+
+
+
Clock Speed
+
{gpu.clocks.graphics} MHz
+
+
+
+
+
+
Power Draw
+
+ {gpu.power.draw?.toFixed(1)}W
+ / {gpu.power.limit?.toFixed(1) || ' ? '}W
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/HFJobStatus.tsx b/ui/src/components/HFJobStatus.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..15632a162fffe6e83599f55b8e63148cc92f77a0
--- /dev/null
+++ b/ui/src/components/HFJobStatus.tsx
@@ -0,0 +1,125 @@
+'use client';
+
+import { useHFJobStatus } from '@/hooks/useHFJobStatus';
+import { ExternalLink, RefreshCw } from 'lucide-react';
+import { Button } from '@headlessui/react';
+
+interface HFJobStatusProps {
+ hfJobId: string;
+ hfJobUrl?: string;
+}
+
+export default function HFJobStatus({ hfJobId, hfJobUrl }: HFJobStatusProps) {
+ const { status, loading, error } = useHFJobStatus(hfJobId);
+
+ if (error) {
+ return (
+
+
Status Error
+ {hfJobUrl && (
+
+
+
+ )}
+
+ );
+ }
+
+ if (loading && !status) {
+ return (
+
+
+ Checking...
+
+ );
+ }
+
+ if (!status) {
+ return (
+
+
Unknown
+ {hfJobUrl && (
+
+
+
+ )}
+
+ );
+ }
+
+ const getStatusColor = (statusStage: string) => {
+ switch (statusStage.toUpperCase()) {
+ case 'RUNNING':
+ return 'text-blue-400';
+ case 'COMPLETED':
+ case 'SUCCESS':
+ return 'text-green-400';
+ case 'FAILED':
+ case 'ERROR':
+ return 'text-red-400';
+ case 'PENDING':
+ case 'QUEUED':
+ return 'text-yellow-400';
+ case 'CANCELLED':
+ case 'STOPPED':
+ return 'text-gray-400';
+ default:
+ return 'text-gray-400';
+ }
+ };
+
+ const getStatusLabel = (statusStage: string) => {
+ switch (statusStage.toUpperCase()) {
+ case 'RUNNING':
+ return 'Running';
+ case 'COMPLETED':
+ return 'Completed';
+ case 'FAILED':
+ return 'Failed';
+ case 'PENDING':
+ return 'Pending';
+ case 'QUEUED':
+ return 'Queued';
+ case 'CANCELLED':
+ return 'Cancelled';
+ case 'STOPPED':
+ return 'Stopped';
+ default:
+ return statusStage;
+ }
+ };
+
+ return (
+
+
+ {loading && }
+
+ {getStatusLabel(status.status)}
+
+
+ {hfJobUrl && (
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/ui/src/components/HFJobsWorkflow.tsx b/ui/src/components/HFJobsWorkflow.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..da3673878f35bd11118a5e49a234f45f552664fd
--- /dev/null
+++ b/ui/src/components/HFJobsWorkflow.tsx
@@ -0,0 +1,485 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@headlessui/react';
+import { SelectInput, TextInput, Checkbox } from '@/components/formInputs';
+import Card from '@/components/Card';
+import { apiClient } from '@/utils/api';
+import { JobConfig } from '@/types';
+import useSettings from '@/hooks/useSettings';
+import { upsertJob } from '@/utils/storage/jobStorage';
+import { useAuth } from '@/contexts/AuthContext';
+
+interface HFJobsWorkflowProps {
+ jobConfig: JobConfig;
+ onComplete: (jobId: string, localJobId?: string) => void;
+}
+
+type Step = 'validate' | 'upload' | 'submit' | 'complete';
+
+export default function HFJobsWorkflow({ jobConfig, onComplete }: HFJobsWorkflowProps) {
+ const { settings } = useSettings();
+ const { token: authToken } = useAuth();
+ const [defaultNamespace, setDefaultNamespace] = useState('');
+ const [currentStep, setCurrentStep] = useState('validate');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Form state
+ const [datasetSource, setDatasetSource] = useState<'upload' | 'existing'>('upload');
+ const [datasetName, setDatasetName] = useState(`${jobConfig.config.name}-dataset`);
+ const [existingDatasetId, setExistingDatasetId] = useState('');
+ const [hardware, setHardware] = useState(settings.HF_JOBS_DEFAULT_HARDWARE || 'a100-large');
+ const [namespace, setNamespace] = useState(settings.HF_JOBS_NAMESPACE || '');
+ const [autoUpload, setAutoUpload] = useState(true);
+
+ // Progress state
+ const [validationResult, setValidationResult] = useState(null);
+ const [uploadResult, setUploadResult] = useState(null);
+ const [jobResult, setJobResult] = useState(null);
+
+ const validateToken = async () => {
+ setLoading(true);
+ setError(null);
+
+ const effectiveToken = authToken || settings.HF_TOKEN;
+
+ try {
+ if (!effectiveToken) {
+ throw new Error('A valid Hugging Face token is required to continue.');
+ }
+
+ const response = await apiClient.post('/api/hf-hub', {
+ action: 'whoami',
+ token: effectiveToken,
+ });
+
+ if (response.data.user) {
+ setValidationResult(response.data.user);
+ const resolvedName = response.data.user.name || '';
+ setDefaultNamespace(resolvedName);
+ if (!namespace) {
+ setNamespace(resolvedName);
+ }
+ setCurrentStep('upload');
+ }
+ } catch (err: any) {
+ setError(err.response?.data?.error || 'Failed to validate token');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const uploadDataset = async () => {
+ setLoading(true);
+ setError(null);
+
+ const effectiveToken = authToken || settings.HF_TOKEN;
+
+ try {
+ if (!effectiveToken) {
+ throw new Error('A valid Hugging Face token is required to continue.');
+ }
+
+ const resolvedNamespace = namespace || defaultNamespace;
+ if (!resolvedNamespace) {
+ throw new Error('Unable to determine a namespace. Validate your HF token or set a namespace in Settings.');
+ }
+
+ if (datasetSource === 'existing') {
+ // Use existing dataset - just validate it exists
+ if (!existingDatasetId) {
+ throw new Error('Please enter a dataset ID');
+ }
+
+ // Validate dataset exists
+ const validateResponse = await apiClient.post('/api/hf-hub', {
+ action: 'validateDataset',
+ token: effectiveToken,
+ datasetId: existingDatasetId,
+ });
+
+ if (validateResponse.data.exists) {
+ setUploadResult({
+ repoId: existingDatasetId,
+ url: `https://huggingface.co/datasets/${existingDatasetId}`,
+ existing: true,
+ });
+ setCurrentStep('submit');
+ } else {
+ throw new Error(`Dataset ${existingDatasetId} not found or not accessible`);
+ }
+ } else {
+ if (!resolvedNamespace) {
+ throw new Error('Unable to determine a namespace. Validate your HF token or set a namespace in Settings.');
+ }
+ // Upload new dataset
+ // First, create the dataset repository
+ const createResponse = await apiClient.post('/api/hf-hub', {
+ action: 'createDataset',
+ token: effectiveToken,
+ namespace: resolvedNamespace,
+ datasetName,
+ });
+
+ if (!createResponse.data.success) {
+ throw new Error('Failed to create dataset repository');
+ }
+
+ // Get dataset path from first dataset in config
+ const datasetPath = jobConfig.config.process[0].datasets[0]?.folder_path;
+ if (!datasetPath || datasetPath.trim() === '' || datasetPath === datasetName) {
+ throw new Error('Dataset path could not be resolved. Please ensure the dataset folder exists on the host.');
+ }
+
+ // Upload dataset files
+ const uploadResponse = await apiClient.post('/api/hf-hub', {
+ action: 'uploadDataset',
+ token: effectiveToken,
+ namespace: resolvedNamespace,
+ datasetName,
+ datasetPath,
+ });
+
+ if (uploadResponse.data.success) {
+ setUploadResult({
+ repoId: uploadResponse.data.repoId,
+ url: `https://huggingface.co/datasets/${uploadResponse.data.repoId}`,
+ existing: false,
+ });
+ setCurrentStep('submit');
+ }
+ }
+ } catch (err: any) {
+ setError(err.response?.data?.error || 'Failed to process dataset');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const submitJob = async () => {
+ setLoading(true);
+ setError(null);
+
+ const effectiveToken = authToken || settings.HF_TOKEN;
+
+ try {
+ const resolvedNamespace = namespace || defaultNamespace;
+ if (!resolvedNamespace) {
+ throw new Error('Unable to determine a namespace. Validate your HF token or set a namespace in Settings.');
+ }
+ if (!effectiveToken) {
+ throw new Error('A valid Hugging Face token is required to continue.');
+ }
+ const datasetRepo =
+ uploadResult?.repoId ||
+ (datasetSource === 'existing'
+ ? existingDatasetId
+ : `${resolvedNamespace}/${datasetName}`);
+
+ const response = await apiClient.post('/api/hf-jobs', {
+ action: 'submitJob',
+ token: effectiveToken,
+ hardware,
+ namespace: resolvedNamespace,
+ jobConfig,
+ datasetRepo,
+ });
+
+ if (response.data.success) {
+ const hfJobId = response.data.jobId;
+
+ // Save job to local database for tracking
+ let localJobId = undefined;
+ try {
+ const savedJob = await upsertJob({
+ name: `${jobConfig.config.name}-hf-cloud`,
+ gpu_ids: hardware,
+ job_config: {
+ ...jobConfig,
+ hf_job_id: hfJobId,
+ hf_job_url:
+ hfJobId !== 'unknown' && resolvedNamespace
+ ? `https://huggingface.co/jobs/${resolvedNamespace}/${hfJobId}`
+ : null,
+ dataset_repo: datasetRepo,
+ hardware,
+ is_hf_job: true,
+ training_backend: 'hf-jobs',
+ hf_job_submitted: true,
+ },
+ info: response.data.message || 'HF Job submitted',
+ status: 'submitted',
+ });
+
+ localJobId = savedJob.id;
+ console.log('Saved HF Job to local storage:', savedJob);
+ } catch (localSaveError: any) {
+ console.warn('Failed to save HF Job locally:', localSaveError);
+ // Don't fail the whole process if local save fails
+ }
+
+ setJobResult({
+ jobId: hfJobId,
+ message: response.data.message,
+ localJobId: localJobId,
+ });
+ setCurrentStep('complete');
+ onComplete(hfJobId, localJobId);
+ }
+ } catch (err: any) {
+ setError(err.response?.data?.error || 'Failed to submit job');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const renderStepContent = () => {
+ switch (currentStep) {
+ case 'validate':
+ return (
+
+
+
+ First, let's validate your Hugging Face token and get your username for dataset uploads.
+
+
+ {validationResult && (
+
+
+ ✓ Token valid! Logged in as: {validationResult.name}
+
+
+ )}
+
+
+
+
+ );
+
+ case 'upload':
+ return (
+
+
+
+ Choose whether to upload a new dataset or use an existing one from HF Hub.
+
+
+
setDatasetSource(value as 'upload' | 'existing')}
+ options={[
+ { value: 'upload', label: 'Upload New Dataset' },
+ { value: 'existing', label: 'Use Existing HF Dataset' }
+ ]}
+ />
+
+ {datasetSource === 'upload' ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+
+
+ Enter the full dataset ID (namespace/name) from HuggingFace Hub
+
+ >
+ )}
+
+ {uploadResult && (
+
+
+ ✓ Dataset {uploadResult.existing ? 'validated' : 'uploaded'} successfully!
+
+
+ {uploadResult.existing ? 'Using dataset:' : 'View at:'} {uploadResult.repoId}
+
+
+ )}
+
+
+
+
+ );
+
+ case 'submit':
+ return (
+
+
+
+ Configure and submit your training job to HF Jobs.
+
+
+
+
+
+
+ {jobResult && (
+
+
+ ✓ Job submitted successfully!
+
+
+ Job ID: {jobResult.jobId}
+
+
+ )}
+
+
+
+
+ );
+
+ case 'complete':
+ return (
+
+
+
+
🎉 Training job submitted!
+
+ Your training job has been submitted to Hugging Face Jobs and is now running in the cloud.
+
+
+
+
+
+
Next steps:
+
+ - Monitor your job progress using:
hf jobs logs {jobResult?.jobId}
+ - The trained model will be uploaded to:
{namespace}/{jobConfig.config.name}-lora
+ - You'll receive notifications when training completes
+
+
+
+
+ );
+
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {/* Progress indicator */}
+
+ {(['validate', 'upload', 'submit', 'complete'] as Step[]).map((step, index) => (
+
+
+ {index + 1}
+
+ {index < 3 && (
+
+ )}
+
+ ))}
+
+
+ {/* Error display */}
+ {error && (
+
+ )}
+
+ {/* Current step content */}
+ {renderStepContent()}
+
+ );
+}
diff --git a/ui/src/components/HFLoginButton.tsx b/ui/src/components/HFLoginButton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6df1b4974e7288a3c1e0f493d5573a8e919d7328
--- /dev/null
+++ b/ui/src/components/HFLoginButton.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import classNames from 'classnames';
+import { useAuth } from '@/contexts/AuthContext';
+
+interface Props {
+ className?: string;
+ size?: 'sm' | 'md' | 'lg';
+}
+
+const BUTTON_ASSETS: Record<'sm' | 'md' | 'lg', string> = {
+ sm: 'https://huggingface.co/datasets/huggingface/badges/resolve/main/sign-in-with-huggingface-sm-dark.svg',
+ md: 'https://huggingface.co/datasets/huggingface/badges/resolve/main/sign-in-with-huggingface-md-dark.svg',
+ lg: 'https://huggingface.co/datasets/huggingface/badges/resolve/main/sign-in-with-huggingface-lg-dark.svg',
+};
+
+export default function HFLoginButton({ className, size = 'md' }: Props) {
+ const { loginWithOAuth, oauthAvailable, status } = useAuth();
+
+ if (!oauthAvailable) {
+ return null;
+ }
+
+ const disabled = status === 'checking';
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/JobActionBar.tsx b/ui/src/components/JobActionBar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..552ce2ec817f80bad8c8fc5ab6a0a4e5f5401ec1
--- /dev/null
+++ b/ui/src/components/JobActionBar.tsx
@@ -0,0 +1,129 @@
+import Link from 'next/link';
+import { Eye, Trash2, Pen, Play, Pause, ExternalLink, Upload } from 'lucide-react';
+import { Button } from '@headlessui/react';
+import { openConfirm } from '@/components/ConfirmModal';
+import { startJob, stopJob, deleteJob, getAvaliableJobActions } from '@/utils/jobs';
+import { JobConfig, JobRecord } from '@/types';
+
+interface JobActionBarProps {
+ job: JobRecord;
+ onRefresh?: () => void;
+ afterDelete?: () => void;
+ hideView?: boolean;
+ className?: string;
+}
+
+export default function JobActionBar({ job, onRefresh, afterDelete, className, hideView }: JobActionBarProps) {
+ const { canStart, canStop, canDelete, canEdit } = getAvaliableJobActions(job);
+
+ // Check if this is an HF Job and extract monitoring URL
+ let jobConfig: JobConfig | null = null;
+ let hfJobUrl: string | null = null;
+ let isHFJob = false;
+ let hfJobSubmitted = false;
+ try {
+ jobConfig = JSON.parse(job.job_config);
+ isHFJob = jobConfig?.is_hf_job || false;
+ hfJobSubmitted = !!jobConfig?.hf_job_id;
+ hfJobUrl = jobConfig?.hf_job_url;
+ } catch (e) {
+ // Ignore parsing errors
+ }
+
+ if (!afterDelete) afterDelete = onRefresh;
+
+ return (
+
+ {isHFJob && !hfJobSubmitted && (
+
+
+
+ )}
+ {canStart && !isHFJob && (
+
+ )}
+ {canStop && !isHFJob && (
+
+ )}
+ {!hideView && (
+
+
+
+ )}
+ {hfJobUrl && (
+
+
+
+ )}
+ {canEdit && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/ui/src/components/JobConfigViewer.tsx b/ui/src/components/JobConfigViewer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a19152329633cbd817a225d8a754a9930ce0b14f
--- /dev/null
+++ b/ui/src/components/JobConfigViewer.tsx
@@ -0,0 +1,49 @@
+'use client';
+import { useEffect, useState } from 'react';
+import YAML from 'yaml';
+import Editor from '@monaco-editor/react';
+
+import { JobRecord } from '@/types';
+
+interface Props {
+ job: JobRecord;
+}
+
+const yamlConfig: YAML.DocumentOptions &
+ YAML.SchemaOptions &
+ YAML.ParseOptions &
+ YAML.CreateNodeOptions &
+ YAML.ToStringOptions = {
+ indent: 2,
+ lineWidth: 999999999999,
+ defaultStringType: 'QUOTE_DOUBLE',
+ defaultKeyType: 'PLAIN',
+ directives: true,
+};
+
+export default function JobConfigViewer({ job }: Props) {
+ const [editorValue, setEditorValue] = useState('');
+ useEffect(() => {
+ if (job?.job_config) {
+ const yamlContent = YAML.stringify(JSON.parse(job.job_config), yamlConfig);
+ setEditorValue(yamlContent);
+ }
+ }, [job]);
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/ui/src/components/JobOverview.tsx b/ui/src/components/JobOverview.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6f5af83eefeccf9d8cc7f4ae86fc93838202edd4
--- /dev/null
+++ b/ui/src/components/JobOverview.tsx
@@ -0,0 +1,248 @@
+import useGPUInfo from '@/hooks/useGPUInfo';
+import GPUWidget from '@/components/GPUWidget';
+import FilesWidget from '@/components/FilesWidget';
+import { getTotalSteps } from '@/utils/jobs';
+import { Cpu, HardDrive, Info, Gauge, Cloud, ExternalLink } from 'lucide-react';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import useJobLog from '@/hooks/useJobLog';
+import { JobConfig, JobRecord } from '@/types';
+import HFJobStatus from './HFJobStatus';
+
+interface JobOverviewProps {
+ job: JobRecord;
+}
+
+export default function JobOverview({ job }: JobOverviewProps) {
+ // Parse job config to check if it's an HF Job
+ const jobConfig = useMemo(() => {
+ try {
+ return JSON.parse(job.job_config) as JobConfig;
+ } catch (e) {
+ return null;
+ }
+ }, [job.job_config]);
+
+ const isHFJob = jobConfig?.is_hf_job || false;
+ const hfJobSubmitted = !!jobConfig?.hf_job_id;
+
+ const gpuIds = useMemo(() => {
+ // For HF Jobs, don't parse GPU IDs as they're hardware names
+ if (isHFJob) return [];
+ return job.gpu_ids.split(',').map(id => parseInt(id));
+ }, [job.gpu_ids, isHFJob]);
+
+ const { log, setLog, status: statusLog, refresh: refreshLog } = useJobLog(job.id, 2000);
+ const logRef = useRef(null);
+ // Track whether we should auto-scroll to bottom
+ const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
+
+ const { gpuList, isGPUInfoLoaded } = useGPUInfo(gpuIds, 5000);
+ const totalSteps = getTotalSteps(job);
+ const progress = (job.step / totalSteps) * 100;
+ const isStopping = job.stop && job.status === 'running';
+
+ const logLines: string[] = useMemo(() => {
+ // split at line breaks on \n or \r\n but not \r
+ let splits: string[] = log.split(/\n|\r\n/);
+
+ splits = splits.map(line => {
+ return line.split(/\r/).pop();
+ }) as string[];
+
+ // only return last 100 lines max
+ const maxLines = 1000;
+ if (splits.length > maxLines) {
+ splits = splits.slice(splits.length - maxLines);
+ }
+
+ return splits;
+ }, [log]);
+
+ // Handle scroll events to determine if user has scrolled away from bottom
+ const handleScroll = () => {
+ if (logRef.current) {
+ const { scrollTop, scrollHeight, clientHeight } = logRef.current;
+ // Consider "at bottom" if within 10 pixels of the bottom
+ const isAtBottom = scrollHeight - scrollTop - clientHeight < 10;
+ setIsScrolledToBottom(isAtBottom);
+ }
+ };
+
+ // Auto-scroll to bottom only if we were already at the bottom
+ useEffect(() => {
+ if (logRef.current && isScrolledToBottom) {
+ logRef.current.scrollTop = logRef.current.scrollHeight;
+ }
+ }, [log, isScrolledToBottom]);
+
+ const getStatusColor = (status: string) => {
+ switch (status.toLowerCase()) {
+ case 'running':
+ return 'bg-emerald-500/10 text-emerald-500';
+ case 'stopping':
+ return 'bg-amber-500/10 text-amber-500';
+ case 'stopped':
+ return 'bg-gray-500/10 text-gray-400';
+ case 'completed':
+ return 'bg-blue-500/10 text-blue-500';
+ case 'error':
+ return 'bg-rose-500/10 text-rose-500';
+ default:
+ return 'bg-gray-500/10 text-gray-400';
+ }
+ };
+
+ let status = job.status;
+ if (isStopping) {
+ status = 'stopping';
+ }
+
+ return (
+
+ {/* Job Information Panel */}
+
+
+
+ {job.info}
+
+ {isHFJob && hfJobSubmitted && jobConfig?.hf_job_id ? (
+
+ ) : isHFJob && !hfJobSubmitted ? (
+
+ Pending Submission
+
+ ) : (
+
+ {job.status}
+
+ )}
+
+
+
+ {/* Progress Bar */}
+ {isHFJob && !hfJobSubmitted ? (
+
+
+ Status
+ Ready for cloud submission
+
+
+ ) : isHFJob ? (
+
+
+ Cloud Training
+
+ Running on {jobConfig?.hardware || job.gpu_ids}
+
+
+
+ ) : (
+
+
+ Progress
+
+ Step {job.step} of {totalSteps}
+
+
+
+
+ )}
+
+ {/* Job Info Grid */}
+
+
+
+
+
Job Name
+
{job.name}
+
+
+
+
+ {isHFJob ? (
+
+ ) : (
+
+ )}
+
+
+ {isHFJob ? 'Hardware' : 'Assigned GPUs'}
+
+
+ {isHFJob ? (jobConfig?.hardware || job.gpu_ids) : `GPUs: ${job.gpu_ids}`}
+
+
+
+
+
+
+
+
Speed
+
{job.speed_string == '' ? '?' : job.speed_string}
+
+
+
+
+ {/* Log - Now using flex-grow to fill remaining space */}
+
+ {isHFJob && hfJobSubmitted && jobConfig?.hf_job_url ? (
+
+
+
+ This job is running on HF Jobs. View logs and monitor progress on the HuggingFace platform.
+
+
+
+ View on HF Jobs
+
+
+ ) : isHFJob && !hfJobSubmitted ? (
+
+
+
+ This HF Job is ready for submission. Edit the job clicking on the pen on the top right.
+
+
+ ) : (
+
+ {statusLog === 'loading' && 'Loading log...'}
+ {statusLog === 'error' && 'Error loading log'}
+ {['success', 'refreshing'].includes(statusLog) && (
+
+ {logLines.map((line, index) => {
+ return
{line};
+ })}
+
+ )}
+
+ )}
+
+
+
+
+ {/* GPU Widget Panel */}
+
+ {!isHFJob && (
+
{isGPUInfoLoaded && gpuList.length > 0 && }
+ )}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/JobsTable.tsx b/ui/src/components/JobsTable.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..24b658ebc8d1ac99ee6458e412449fdd9a4fa315
--- /dev/null
+++ b/ui/src/components/JobsTable.tsx
@@ -0,0 +1,139 @@
+import useJobsList from '@/hooks/useJobsList';
+import Link from 'next/link';
+import UniversalTable, { TableColumn } from '@/components/UniversalTable';
+import { JobConfig, JobRecord } from '@/types';
+import JobActionBar from './JobActionBar';
+import HFJobStatus from './HFJobStatus';
+
+interface JobsTableProps {
+ onlyActive?: boolean;
+}
+
+export default function JobsTable({ onlyActive = false }: JobsTableProps) {
+ const { jobs, status, refreshJobs } = useJobsList(onlyActive);
+ const isLoading = status === 'loading';
+
+ const columns: TableColumn[] = [
+ {
+ title: 'Name',
+ key: 'name',
+ render: (row: JobRecord) => {
+ const jobConfig: JobConfig = JSON.parse(row.job_config);
+ const isHFJob = jobConfig.is_hf_job;
+
+ return (
+
+
+ {row.name}
+
+ {isHFJob && (
+
+ HF Cloud
+
+ )}
+
+ );
+ },
+ },
+ {
+ title: 'Steps',
+ key: 'steps',
+ render: (row: JobRecord) => {
+ const jobConfig: JobConfig = JSON.parse(row.job_config);
+ const isHFJob = jobConfig.is_hf_job;
+
+ if (isHFJob) {
+ return (
+
+ Cloud Training
+
+ );
+ }
+
+ const totalSteps = jobConfig.config.process[0].train.steps;
+
+ return (
+
+
+ {row.step} / {totalSteps}
+
+
+
+ );
+ },
+ },
+ {
+ title: 'GPU',
+ key: 'gpu_ids',
+ render: (row: JobRecord) => {
+ const jobConfig: JobConfig = JSON.parse(row.job_config);
+ const isHFJob = jobConfig.is_hf_job;
+
+ if (isHFJob) {
+ return (
+
+ {jobConfig.hardware || row.gpu_ids}
+
+ );
+ }
+
+ return {row.gpu_ids};
+ },
+ },
+ {
+ title: 'Status',
+ key: 'status',
+ render: (row: JobRecord) => {
+ const jobConfig: JobConfig = JSON.parse(row.job_config);
+ const isHFJob = jobConfig.is_hf_job;
+
+ if (isHFJob) {
+ if (jobConfig.hf_job_id) {
+ // HF Job that has been submitted
+ return (
+
+ );
+ } else {
+ // HF Job that hasn't been submitted yet
+ return (
+
+ Pending Submission
+
+ );
+ }
+ }
+
+ // Local job status
+ let statusClass = 'text-gray-400';
+ if (row.status === 'completed') statusClass = 'text-green-400';
+ if (row.status === 'failed') statusClass = 'text-red-400';
+ if (row.status === 'running') statusClass = 'text-blue-400';
+
+ return {row.status};
+ },
+ },
+ {
+ title: 'Info',
+ key: 'info',
+ className: 'truncate max-w-xs',
+ },
+ {
+ title: 'Actions',
+ key: 'actions',
+ className: 'text-right',
+ render: (row: JobRecord) => {
+ return ;
+ },
+ },
+ ];
+
+ return ;
+}
diff --git a/ui/src/components/Loading.tsx b/ui/src/components/Loading.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..141a3103360973b7176ac135bed546281c264534
--- /dev/null
+++ b/ui/src/components/Loading.tsx
@@ -0,0 +1,7 @@
+export default function Loading() {
+ return (
+
+ );
+}
diff --git a/ui/src/components/Modal.tsx b/ui/src/components/Modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..68dbf9d5f2ed838711ab845f9a6305daeaf73e1b
--- /dev/null
+++ b/ui/src/components/Modal.tsx
@@ -0,0 +1,110 @@
+import React, { Fragment, useEffect } from 'react';
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title?: string;
+ children: React.ReactNode;
+ showCloseButton?: boolean;
+ size?: 'sm' | 'md' | 'lg' | 'xl';
+ closeOnOverlayClick?: boolean;
+}
+
+export const Modal: React.FC = ({
+ isOpen,
+ onClose,
+ title,
+ children,
+ showCloseButton = true,
+ size = 'md',
+ closeOnOverlayClick = true,
+}) => {
+ // Close on ESC key press
+ useEffect(() => {
+ const handleEscKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && isOpen) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener('keydown', handleEscKey);
+ // Prevent body scrolling when modal is open
+ document.body.style.overflow = 'hidden';
+ }
+
+ return () => {
+ document.removeEventListener('keydown', handleEscKey);
+ document.body.style.overflow = 'auto';
+ };
+ }, [isOpen, onClose]);
+
+ // Handle overlay click
+ const handleOverlayClick = (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget && closeOnOverlayClick) {
+ onClose();
+ }
+ };
+
+ if (!isOpen) return null;
+
+ // Size mapping
+ const sizeClasses = {
+ sm: 'max-w-md',
+ md: 'max-w-lg',
+ lg: 'max-w-2xl',
+ xl: 'max-w-4xl',
+ };
+
+ return (
+
+ {/* Modal backdrop */}
+
+ {/* Modal panel */}
+
e.stopPropagation()}
+ >
+ {/* Modal header */}
+ {(title || showCloseButton) && (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {showCloseButton && (
+
+ )}
+
+ )}
+
+ {/* Modal content */}
+
{children}
+
+
+
+ );
+};
diff --git a/ui/src/components/SampleImageCard.tsx b/ui/src/components/SampleImageCard.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..39ba84ab5ab823e67430aa5d2b0129e5bebd5a93
--- /dev/null
+++ b/ui/src/components/SampleImageCard.tsx
@@ -0,0 +1,92 @@
+import React, { useRef, useEffect, useState, ReactNode } from 'react';
+import { sampleImageModalState } from '@/components/SampleImageModal';
+import { isVideo } from '@/utils/basic';
+
+interface SampleImageCardProps {
+ imageUrl: string;
+ alt: string;
+ numSamples: number;
+ sampleImages: string[];
+ children?: ReactNode;
+ className?: string;
+ onDelete?: () => void;
+}
+
+const SampleImageCard: React.FC = ({
+ imageUrl,
+ alt,
+ numSamples,
+ sampleImages,
+ children,
+ className = '',
+}) => {
+ const cardRef = useRef(null);
+ const [isVisible, setIsVisible] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+
+ useEffect(() => {
+ // Create intersection observer to check visibility
+ const observer = new IntersectionObserver(
+ entries => {
+ if (entries[0].isIntersecting) {
+ setIsVisible(true);
+ observer.disconnect();
+ }
+ },
+ { threshold: 0.1 },
+ );
+
+ if (cardRef.current) {
+ observer.observe(cardRef.current);
+ }
+
+ return () => {
+ observer.disconnect();
+ };
+ }, []);
+
+ const handleLoad = (): void => {
+ setLoaded(true);
+ };
+
+ return (
+
+ {/* Square image container */}
+
sampleImageModalState.set({ imgPath: imageUrl, numSamples, sampleImages })}
+ >
+
+ {isVisible && (
+ <>
+ {isVideo(imageUrl) ? (
+
+ ) : (
+
}`})
+ )}
+ >
+ )}
+ {children &&
{children}
}
+
+
+
+ );
+};
+
+export default SampleImageCard;
diff --git a/ui/src/components/SampleImageModal.tsx b/ui/src/components/SampleImageModal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a98734e073449906918124dd7fb6ceb84a6ced23
--- /dev/null
+++ b/ui/src/components/SampleImageModal.tsx
@@ -0,0 +1,190 @@
+'use client';
+import { useState, useEffect, useMemo } from 'react';
+import { createGlobalState } from 'react-global-hooks';
+import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react';
+
+export interface SampleImageModalState {
+ imgPath: string;
+ numSamples: number;
+ sampleImages: string[];
+}
+
+export const sampleImageModalState = createGlobalState(null);
+
+export const openSampleImage = (sampleImageProps: SampleImageModalState) => {
+ sampleImageModalState.set(sampleImageProps);
+};
+
+export default function SampleImageModal() {
+ const [imageModal, setImageModal] = sampleImageModalState.use();
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ if (imageModal) {
+ setIsOpen(true);
+ }
+ }, [imageModal]);
+
+ useEffect(() => {
+ if (!isOpen) {
+ // use timeout to allow the dialog to close before resetting the state
+ setTimeout(() => {
+ setImageModal(null);
+ }, 500);
+ }
+ }, [isOpen]);
+
+ const onCancel = () => {
+ setIsOpen(false);
+ };
+
+ const imgInfo = useMemo(() => {
+ const ii = {
+ filename: '',
+ step: 0,
+ promptIdx: 0,
+ };
+ if (imageModal?.imgPath) {
+ const filename = imageModal.imgPath.split('/').pop();
+ if (!filename) return ii;
+ // filename is ___.
+ ii.filename = filename as string;
+ const parts = filename
+ .split('.')[0]
+ .split('_')
+ .filter(p => p !== '');
+ if (parts.length === 3) {
+ ii.step = parseInt(parts[1]);
+ ii.promptIdx = parseInt(parts[2]);
+ }
+ }
+ return ii;
+ }, [imageModal]);
+
+ const handleArrowUp = () => {
+ if (!imageModal) return;
+ console.log('Arrow Up pressed');
+ // Change image to same sample but up one step
+ const currentIdx = imageModal.sampleImages.findIndex(img => img === imageModal.imgPath);
+ if (currentIdx === -1) return;
+ const nextIdx = currentIdx - imageModal.numSamples;
+ if (nextIdx < 0) return;
+ openSampleImage({
+ imgPath: imageModal.sampleImages[nextIdx],
+ numSamples: imageModal.numSamples,
+ sampleImages: imageModal.sampleImages,
+ });
+ };
+
+ const handleArrowDown = () => {
+ if (!imageModal) return;
+ console.log('Arrow Down pressed');
+ // Change image to same sample but down one step
+ const currentIdx = imageModal.sampleImages.findIndex(img => img === imageModal.imgPath);
+ if (currentIdx === -1) return;
+ const nextIdx = currentIdx + imageModal.numSamples;
+ if (nextIdx >= imageModal.sampleImages.length) return;
+ openSampleImage({
+ imgPath: imageModal.sampleImages[nextIdx],
+ numSamples: imageModal.numSamples,
+ sampleImages: imageModal.sampleImages,
+ });
+ };
+
+ const handleArrowLeft = () => {
+ if (!imageModal) return;
+ if (imgInfo.promptIdx === 0) return;
+ console.log('Arrow Left pressed');
+ // go to previous sample
+ const currentIdx = imageModal.sampleImages.findIndex(img => img === imageModal.imgPath);
+ if (currentIdx === -1) return;
+ const minIdx = currentIdx - imgInfo.promptIdx;
+ const nextIdx = currentIdx - 1;
+ if (nextIdx < minIdx) return;
+ openSampleImage({
+ imgPath: imageModal.sampleImages[nextIdx],
+ numSamples: imageModal.numSamples,
+ sampleImages: imageModal.sampleImages,
+ });
+ };
+
+ const handleArrowRight = () => {
+ if (!imageModal) return;
+ console.log('Arrow Right pressed');
+ // go to next sample
+ const currentIdx = imageModal.sampleImages.findIndex(img => img === imageModal.imgPath);
+ if (currentIdx === -1) return;
+ const stepMinIdx = currentIdx - imgInfo.promptIdx;
+ const maxIdx = stepMinIdx + imageModal.numSamples - 1;
+ const nextIdx = currentIdx + 1;
+ if (nextIdx > maxIdx) return;
+ if (nextIdx >= imageModal.sampleImages.length) return;
+ openSampleImage({
+ imgPath: imageModal.sampleImages[nextIdx],
+ numSamples: imageModal.numSamples,
+ sampleImages: imageModal.sampleImages,
+ });
+ };
+
+ // Handle keyboard events
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (!isOpen) return;
+
+ switch (event.key) {
+ case 'Escape':
+ onCancel();
+ break;
+ case 'ArrowUp':
+ handleArrowUp();
+ break;
+ case 'ArrowDown':
+ handleArrowDown();
+ break;
+ case 'ArrowLeft':
+ handleArrowLeft();
+ break;
+ case 'ArrowRight':
+ handleArrowRight();
+ break;
+ default:
+ break;
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [isOpen, imageModal, imgInfo]);
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/SampleImages.tsx b/ui/src/components/SampleImages.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8b07c641a949a7cdecdb2a8e6ba456a54da17e0c
--- /dev/null
+++ b/ui/src/components/SampleImages.tsx
@@ -0,0 +1,245 @@
+import { useMemo, useState } from 'react';
+import useSampleImages from '@/hooks/useSampleImages';
+import SampleImageCard from './SampleImageCard';
+import { JobConfig, JobRecord } from '@/types';
+import { LuImageOff, LuLoader, LuBan } from 'react-icons/lu';
+import { Button } from '@headlessui/react';
+import { FaDownload } from 'react-icons/fa';
+import { apiClient } from '@/utils/api';
+import classNames from 'classnames';
+
+interface SampleImagesMenuProps {
+ job?: JobRecord | null;
+}
+
+export const SampleImagesMenu = ({ job }: SampleImagesMenuProps) => {
+ const [isZipping, setIsZipping] = useState(false);
+
+ const downloadZip = async () => {
+ if (isZipping) return;
+ setIsZipping(true);
+
+ try {
+ const res = await apiClient.post('/api/zip', {
+ zipTarget: 'samples',
+ jobName: job?.name,
+ });
+
+ const zipPath = res.data.zipPath; // e.g. /mnt/Train2/out/ui/.../samples.zip
+ if (!zipPath) throw new Error('No zipPath in response');
+
+ const downloadPath = `/api/files/${encodeURIComponent(zipPath)}`;
+ const a = document.createElement('a');
+ a.href = downloadPath;
+ // optional: suggest filename (browser may ignore if server sets Content-Disposition)
+ a.download = 'samples.zip';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ } catch (err) {
+ console.error('Error downloading zip:', err);
+ } finally {
+ setIsZipping(false);
+ }
+ };
+ return (
+
+ );
+};
+
+interface SampleImagesProps {
+ job: JobRecord;
+}
+
+export default function SampleImages({ job }: SampleImagesProps) {
+ const { sampleImages, status, refreshSampleImages } = useSampleImages(job.id, 5000);
+ const numSamples = useMemo(() => {
+ if (job?.job_config) {
+ const jobConfig = JSON.parse(job.job_config) as JobConfig;
+ const sampleConfig = jobConfig.config.process[0].sample;
+ if (sampleConfig.prompts) {
+ return sampleConfig.prompts.length;
+ } else {
+ return sampleConfig.samples.length;
+ }
+ }
+ return 10;
+ }, [job]);
+
+ const PageInfoContent = useMemo(() => {
+ let icon = null;
+ let text = '';
+ let subtitle = '';
+ let showIt = false;
+ let bgColor = '';
+ let textColor = '';
+ let iconColor = '';
+
+ if (sampleImages.length > 0) return null;
+
+ if (status == 'loading') {
+ icon = ;
+ text = 'Loading Samples';
+ subtitle = 'Please wait while we fetch your samples...';
+ showIt = true;
+ bgColor = 'bg-gray-50 dark:bg-gray-800/50';
+ textColor = 'text-gray-900 dark:text-gray-100';
+ iconColor = 'text-gray-500 dark:text-gray-400';
+ }
+ if (status == 'error') {
+ icon = ;
+ text = 'Error Loading Samples';
+ subtitle = 'There was a problem fetching the samples.';
+ showIt = true;
+ bgColor = 'bg-red-50 dark:bg-red-950/20';
+ textColor = 'text-red-900 dark:text-red-100';
+ iconColor = 'text-red-600 dark:text-red-400';
+ }
+ if (status == 'success' && sampleImages.length === 0) {
+ icon = ;
+ text = 'No Samples Found';
+ subtitle = 'No samples have been generated yet';
+ showIt = true;
+ bgColor = 'bg-gray-50 dark:bg-gray-800/50';
+ textColor = 'text-gray-900 dark:text-gray-100';
+ iconColor = 'text-gray-500 dark:text-gray-400';
+ }
+
+ if (!showIt) return null;
+
+ return (
+
+
{icon}
+
{text}
+
{subtitle}
+
+ );
+ }, [status, sampleImages.length]);
+
+ // Use direct Tailwind class without string interpolation
+ // This way Tailwind can properly generate the class
+ // I hate this, but it's the only way to make it work
+ const gridColsClass = useMemo(() => {
+ const cols = Math.min(numSamples, 40);
+
+ switch (cols) {
+ case 1:
+ return 'grid-cols-1';
+ case 2:
+ return 'grid-cols-2';
+ case 3:
+ return 'grid-cols-3';
+ case 4:
+ return 'grid-cols-4';
+ case 5:
+ return 'grid-cols-5';
+ case 6:
+ return 'grid-cols-6';
+ case 7:
+ return 'grid-cols-7';
+ case 8:
+ return 'grid-cols-8';
+ case 9:
+ return 'grid-cols-9';
+ case 10:
+ return 'grid-cols-10';
+ case 11:
+ return 'grid-cols-11';
+ case 12:
+ return 'grid-cols-12';
+ case 13:
+ return 'grid-cols-13';
+ case 14:
+ return 'grid-cols-14';
+ case 15:
+ return 'grid-cols-15';
+ case 16:
+ return 'grid-cols-16';
+ case 17:
+ return 'grid-cols-17';
+ case 18:
+ return 'grid-cols-18';
+ case 19:
+ return 'grid-cols-19';
+ case 20:
+ return 'grid-cols-20';
+ case 21:
+ return 'grid-cols-21';
+ case 22:
+ return 'grid-cols-22';
+ case 23:
+ return 'grid-cols-23';
+ case 24:
+ return 'grid-cols-24';
+ case 25:
+ return 'grid-cols-25';
+ case 26:
+ return 'grid-cols-26';
+ case 27:
+ return 'grid-cols-27';
+ case 28:
+ return 'grid-cols-28';
+ case 29:
+ return 'grid-cols-29';
+ case 30:
+ return 'grid-cols-30';
+ case 31:
+ return 'grid-cols-31';
+ case 32:
+ return 'grid-cols-32';
+ case 33:
+ return 'grid-cols-33';
+ case 34:
+ return 'grid-cols-34';
+ case 35:
+ return 'grid-cols-35';
+ case 36:
+ return 'grid-cols-36';
+ case 37:
+ return 'grid-cols-37';
+ case 38:
+ return 'grid-cols-38';
+ case 39:
+ return 'grid-cols-39';
+ case 40:
+ return 'grid-cols-40';
+ default:
+ return 'grid-cols-1';
+ }
+ }, [numSamples]);
+
+ return (
+
+
+ {PageInfoContent}
+ {sampleImages && (
+
+ {sampleImages.map((sample: string) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..762085a0950270a7670b3a1a936efdb3c6c03ca6
--- /dev/null
+++ b/ui/src/components/Sidebar.tsx
@@ -0,0 +1,121 @@
+'use client';
+
+import Link from 'next/link';
+import { Home, Settings, BrainCircuit, Images, Plus } from 'lucide-react';
+import { FaXTwitter, FaDiscord, FaYoutube } from 'react-icons/fa6';
+import { useAuth } from '@/contexts/AuthContext';
+
+const Sidebar = () => {
+ const { status: authStatus, namespace } = useAuth();
+ const isAuthenticated = authStatus === 'authenticated';
+ const guarded = new Set(['New Job', 'Training Jobs', 'Datasets']);
+
+ const navigation = [
+ { name: 'Dashboard', href: '/dashboard', icon: Home },
+ { name: 'New Job', href: '/jobs/new', icon: Plus },
+ { name: 'Training Jobs', href: '/jobs', icon: BrainCircuit },
+ { name: 'Datasets', href: '/datasets', icon: Images },
+ { name: 'Settings', href: '/settings', icon: Settings },
+ ];
+
+ const socialsBoxClass = 'flex flex-col items-center justify-center p-1 hover:bg-gray-800 rounded-lg transition-colors';
+ const socialIconClass = 'w-5 h-5 text-gray-400 hover:text-white';
+
+ return (
+
+
+
+
+ Ostris
+ AI-Toolkit
+
+ {isAuthenticated && (
+
Welcome, {namespace || 'user'}
+ )}
+
+
+
+
+ Support AI-Toolkit
+
+
+ {/* Social links grid */}
+
+
+ );
+};
+
+export default Sidebar;
diff --git a/ui/src/components/ThemeProvider.tsx b/ui/src/components/ThemeProvider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3834f889e49e9db6cb7d1ce3438af8e9a7ab6c5b
--- /dev/null
+++ b/ui/src/components/ThemeProvider.tsx
@@ -0,0 +1,11 @@
+'use client';
+
+import { createContext, useContext, useEffect, useState } from 'react';
+
+const ThemeContext = createContext({ isDark: true });
+
+export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
+ const [isDark, setIsDark] = useState(true);
+
+ return {children};
+};
diff --git a/ui/src/components/UniversalTable.tsx b/ui/src/components/UniversalTable.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b86b9bc85627f36341b5f628e41c6c9e543c9a0e
--- /dev/null
+++ b/ui/src/components/UniversalTable.tsx
@@ -0,0 +1,72 @@
+import Loading from './Loading';
+import classNames from 'classnames';
+
+export interface TableColumn {
+ title: string;
+ key: string;
+ render?: (row: any) => React.ReactNode;
+ className?: string;
+}
+
+interface TableRow {
+ [key: string]: any;
+}
+
+interface TableProps {
+ columns: TableColumn[];
+ rows: TableRow[];
+ isLoading: boolean;
+ onRefresh: () => void;
+}
+
+export default function UniversalTable({ columns, rows, isLoading, onRefresh = () => {} }: TableProps) {
+ return (
+
+ {isLoading ? (
+
+
+
+ ) : rows.length === 0 ? (
+
+
Empty
+
+
+ ) : (
+
+
+
+
+ {columns.map(column => (
+ |
+ {column.title}
+ |
+ ))}
+
+
+
+ {rows?.map((row, index) => {
+ // Style for alternating rows
+ const rowClass = index % 2 === 0 ? 'bg-gray-900' : 'bg-gray-800';
+
+ return (
+
+ {columns.map(column => (
+ |
+ {column.render ? column.render(row) : row[column.key]}
+ |
+ ))}
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
diff --git a/ui/src/components/formInputs.tsx b/ui/src/components/formInputs.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5626a7b4162e61b1b90f7500c42e2a27c65be1e4
--- /dev/null
+++ b/ui/src/components/formInputs.tsx
@@ -0,0 +1,298 @@
+'use client';
+
+import React, { forwardRef } from 'react';
+import classNames from 'classnames';
+import dynamic from 'next/dynamic';
+import { CircleHelp } from 'lucide-react';
+import { getDoc } from '@/docs';
+import { openDoc } from '@/components/DocModal';
+import { ConfigDoc, GroupedSelectOption, SelectOption } from '@/types';
+
+const Select = dynamic(() => import('react-select'), { ssr: false });
+
+const labelClasses = 'block text-xs mb-1 mt-2 text-gray-300';
+const inputClasses =
+ 'w-full text-sm px-3 py-1 bg-gray-800 border border-gray-700 rounded-sm focus:ring-2 focus:ring-gray-600 focus:border-transparent';
+
+export interface InputProps {
+ label?: string;
+ docKey?: string | null;
+ doc?: ConfigDoc | null;
+ className?: string;
+ placeholder?: string;
+ required?: boolean;
+}
+
+export interface TextInputProps extends InputProps {
+ value: string;
+ onChange: (value: string) => void;
+ type?: 'text' | 'password';
+ disabled?: boolean;
+}
+
+export const TextInput = forwardRef((props: TextInputProps, ref) => {
+ const { label, value, onChange, placeholder, required, disabled, type = 'text', className, docKey = null } = props;
+ let { doc } = props;
+ if (!doc && docKey) {
+ doc = getDoc(docKey);
+ }
+ return (
+
+ {label && (
+
+ )}
+
{
+ if (!disabled) onChange(e.target.value);
+ }}
+ className={`${inputClasses} ${disabled ? 'opacity-30 cursor-not-allowed' : ''}`}
+ placeholder={placeholder}
+ required={required}
+ disabled={disabled}
+ />
+
+ );
+});
+
+// 👇 Helpful for debugging
+TextInput.displayName = 'TextInput';
+
+export interface NumberInputProps extends InputProps {
+ value: number;
+ onChange: (value: number) => void;
+ min?: number;
+ max?: number;
+}
+
+export const NumberInput = (props: NumberInputProps) => {
+ const { label, value, onChange, placeholder, required, min, max, docKey = null } = props;
+ let { doc } = props;
+ if (!doc && docKey) {
+ doc = getDoc(docKey);
+ }
+
+ // Add controlled internal state to properly handle partial inputs
+ const [inputValue, setInputValue] = React.useState(value ?? '');
+
+ // Sync internal state with prop value
+ React.useEffect(() => {
+ setInputValue(value ?? '');
+ }, [value]);
+
+ return (
+
+ {label && (
+
+ )}
+
{
+ const rawValue = e.target.value;
+
+ // Update the input display with the raw value
+ setInputValue(rawValue);
+
+ // Handle empty or partial inputs
+ if (rawValue === '' || rawValue === '-') {
+ // For empty or partial negative input, don't call onChange yet
+ return;
+ }
+
+ const numValue = Number(rawValue);
+
+ // Only apply constraints and call onChange when we have a valid number
+ if (!isNaN(numValue)) {
+ let constrainedValue = numValue;
+
+ // Apply min/max constraints if they exist
+ if (min !== undefined && constrainedValue < min) {
+ constrainedValue = min;
+ }
+ if (max !== undefined && constrainedValue > max) {
+ constrainedValue = max;
+ }
+
+ onChange(constrainedValue);
+ }
+ }}
+ className={inputClasses}
+ placeholder={placeholder}
+ required={required}
+ min={min}
+ max={max}
+ step="any"
+ />
+
+ );
+};
+
+export interface SelectInputProps extends InputProps {
+ value: string;
+ disabled?: boolean;
+ onChange: (value: string) => void;
+ options: GroupedSelectOption[] | SelectOption[];
+}
+
+export const SelectInput = (props: SelectInputProps) => {
+ const { label, value, onChange, options, docKey = null } = props;
+ let { doc } = props;
+ if (!doc && docKey) {
+ doc = getDoc(docKey);
+ }
+ let selectedOption: SelectOption | undefined;
+ if (options && options.length > 0) {
+ // see if grouped options
+ if ('options' in options[0]) {
+ selectedOption = (options as GroupedSelectOption[])
+ .flatMap(group => group.options)
+ .find(opt => opt.value === value);
+ } else {
+ selectedOption = (options as SelectOption[]).find(opt => opt.value === value);
+ }
+ }
+ return (
+
+ {label && (
+
+ )}
+
+ );
+};
+
+export interface CheckboxProps {
+ label?: string | React.ReactNode;
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ className?: string;
+ required?: boolean;
+ disabled?: boolean;
+ docKey?: string | null;
+ doc?: ConfigDoc | null;
+}
+
+export const Checkbox = (props: CheckboxProps) => {
+ const { label, checked, onChange, required, disabled } = props;
+ let { doc } = props;
+ if (!doc && props.docKey) {
+ doc = getDoc(props.docKey);
+ }
+
+ const id = React.useId();
+
+ return (
+
+
+ {label && (
+ <>
+
+ {doc && (
+
openDoc(doc)}>
+
+
+ )}
+ >
+ )}
+
+ );
+};
+
+interface FormGroupProps {
+ label?: string;
+ className?: string;
+ docKey?: string | null;
+ doc?: ConfigDoc | null;
+ children: React.ReactNode;
+}
+
+export const FormGroup: React.FC = props => {
+ const { label, className, children, docKey = null } = props;
+ let { doc } = props;
+ if (!doc && docKey) {
+ doc = getDoc(docKey);
+ }
+ return (
+
+ {label && (
+
+ )}
+
{children}
+
+ );
+};
diff --git a/ui/src/components/layout.tsx b/ui/src/components/layout.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c5cbef55c424afeeb8b384ce3c147078d21a3e7b
--- /dev/null
+++ b/ui/src/components/layout.tsx
@@ -0,0 +1,27 @@
+import classNames from 'classnames';
+
+interface Props {
+ className?: string;
+ children?: React.ReactNode;
+}
+
+export const TopBar: React.FC = ({ children, className }) => {
+ return (
+
+ {children ? children : null}
+
+ );
+};
+
+export const MainContent: React.FC = ({ children, className }) => {
+ return (
+
+ {children ? children : null}
+
+ );
+};
diff --git a/ui/src/contexts/AuthContext.tsx b/ui/src/contexts/AuthContext.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ded4364506388d41db41707e1029bd7418d2dc02
--- /dev/null
+++ b/ui/src/contexts/AuthContext.tsx
@@ -0,0 +1,274 @@
+'use client';
+
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { loadSettings, persistSettings } from '@/utils/storage/settingsStorage';
+import { oauthClientId } from '@/utils/env';
+
+type AuthMethod = 'oauth' | 'manual';
+
+interface StoredAuthState {
+ token: string;
+ namespace: string;
+ method: AuthMethod;
+}
+
+export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated' | 'error';
+
+interface AuthContextValue {
+ status: AuthStatus;
+ token: string | null;
+ namespace: string | null;
+ method: AuthMethod | null;
+ error: string | null;
+ oauthAvailable: boolean;
+ loginWithOAuth: () => void;
+ setManualToken: (token: string) => Promise;
+ logout: () => void;
+}
+
+const STORAGE_KEY = 'HF_AUTH_STATE';
+
+const defaultValue: AuthContextValue = {
+ status: 'checking',
+ token: null,
+ namespace: null,
+ method: null,
+ error: null,
+ oauthAvailable: Boolean(oauthClientId),
+ loginWithOAuth: () => {},
+ setManualToken: async () => {},
+ logout: () => {},
+};
+
+const AuthContext = createContext(defaultValue);
+
+async function validateToken(token: string) {
+ const res = await fetch('/api/auth/hf/validate', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ token }),
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data?.error || 'Failed to validate token');
+ }
+
+ return res.json();
+}
+
+async function syncTokenWithSettings(token: string) {
+ try {
+ const current = await loadSettings();
+ if (current.HF_TOKEN === token) {
+ return;
+ }
+ current.HF_TOKEN = token;
+ await persistSettings(current);
+ } catch (error) {
+ console.warn('Failed to persist HF token to settings:', error);
+ }
+}
+
+async function clearTokenFromSettings() {
+ try {
+ const current = await loadSettings();
+ if (current.HF_TOKEN !== '') {
+ current.HF_TOKEN = '';
+ await persistSettings(current);
+ }
+ } catch (error) {
+ console.warn('Failed to clear HF token from settings:', error);
+ }
+}
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [status, setStatus] = useState('checking');
+ const [token, setToken] = useState(null);
+ const [namespace, setNamespace] = useState(null);
+ const [method, setMethod] = useState(null);
+ const [error, setError] = useState(null);
+
+ const oauthAvailable = Boolean(oauthClientId);
+
+ const applyAuthState = useCallback(async ({ token: nextToken, namespace: nextNamespace, method: nextMethod }: StoredAuthState) => {
+ setToken(nextToken);
+ setNamespace(nextNamespace);
+ setMethod(nextMethod);
+ setStatus('authenticated');
+ setError(null);
+
+ if (typeof window !== 'undefined') {
+ window.localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ token: nextToken,
+ namespace: nextNamespace,
+ method: nextMethod,
+ }),
+ );
+ }
+
+ syncTokenWithSettings(nextToken).catch(err => {
+ console.warn('Failed to sync HF token with settings:', err);
+ });
+ }, []);
+
+ const clearAuthState = useCallback(async () => {
+ setToken(null);
+ setNamespace(null);
+ setMethod(null);
+ setStatus('unauthenticated');
+ setError(null);
+
+ if (typeof window !== 'undefined') {
+ window.localStorage.removeItem(STORAGE_KEY);
+ }
+
+ clearTokenFromSettings().catch(err => {
+ console.warn('Failed to clear HF token from settings:', err);
+ });
+ }, []);
+
+ // Restore stored token on mount
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const restore = async () => {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ setStatus('unauthenticated');
+ return;
+ }
+
+ try {
+ const stored: StoredAuthState = JSON.parse(raw);
+ if (!stored?.token) {
+ setStatus('unauthenticated');
+ return;
+ }
+ setStatus('checking');
+ const data = await validateToken(stored.token);
+ await applyAuthState({
+ token: stored.token,
+ namespace: data?.name || data?.preferred_username || stored.namespace || 'user',
+ method: stored.method || 'manual',
+ });
+ } catch (err) {
+ console.warn('Stored HF token invalid:', err);
+ await clearAuthState();
+ }
+ };
+
+ restore();
+ }, [applyAuthState, clearAuthState]);
+
+ const setManualToken = useCallback(
+ async (manualToken: string) => {
+ if (!manualToken) {
+ setError('Please provide a token');
+ setStatus('error');
+ return;
+ }
+ setStatus('checking');
+ setError(null);
+ try {
+ const data = await validateToken(manualToken);
+ await applyAuthState({
+ token: manualToken,
+ namespace: data?.name || data?.preferred_username || 'user',
+ method: 'manual',
+ });
+ } catch (err: any) {
+ setError(err?.message || 'Failed to validate token');
+ setStatus('error');
+ }
+ },
+ [applyAuthState],
+ );
+
+ const loginWithOAuth = useCallback(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ if (!oauthAvailable) {
+ setError('OAuth is not available on this deployment.');
+ setStatus('error');
+ return;
+ }
+ setStatus('checking');
+ setError(null);
+
+ const width = 540;
+ const height = 720;
+ const left = window.screenX + (window.outerWidth - width) / 2;
+ const top = window.screenY + (window.outerHeight - height) / 2;
+
+ window.open(
+ '/api/auth/hf/login',
+ 'hf-oauth-window',
+ `width=${width},height=${height},left=${left},top=${top},resizable,scrollbars=yes,status=1`,
+ );
+ }, []);
+
+ const logout = useCallback(() => {
+ clearAuthState();
+ }, [clearAuthState]);
+
+ // Listen for OAuth completion messages
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const handler = async (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) {
+ return;
+ }
+
+ const { type, payload } = event.data || {};
+
+ if (type === 'HF_OAUTH_SUCCESS') {
+ await applyAuthState({
+ token: payload?.token,
+ namespace: payload?.namespace || 'user',
+ method: 'oauth',
+ });
+ return;
+ }
+
+ if (type === 'HF_OAUTH_ERROR') {
+ setStatus('error');
+ setError(payload?.message || 'OAuth flow failed');
+ }
+ };
+
+ window.addEventListener('message', handler);
+ return () => window.removeEventListener('message', handler);
+ }, [applyAuthState]);
+
+ const value = useMemo(
+ () => ({
+ status,
+ token,
+ namespace,
+ method,
+ error,
+ oauthAvailable,
+ loginWithOAuth,
+ setManualToken,
+ logout,
+ }),
+ [status, token, namespace, method, error, oauthAvailable, loginWithOAuth, setManualToken, logout],
+ );
+
+ return {children};
+}
+
+export function useAuth() {
+ return useContext(AuthContext);
+}
diff --git a/ui/src/docs.tsx b/ui/src/docs.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6b4144887228086914a535f07ce419ee0d819e12
--- /dev/null
+++ b/ui/src/docs.tsx
@@ -0,0 +1,167 @@
+import React from 'react';
+import { ConfigDoc } from '@/types';
+
+const docs: { [key: string]: ConfigDoc } = {
+ 'config.name': {
+ title: 'Training Name',
+ description: (
+ <>
+ The name of the training job. This name will be used to identify the job in the system and will the the filename
+ of the final model. It must be unique and can only contain alphanumeric characters, underscores, and dashes. No
+ spaces or special characters are allowed.
+ >
+ ),
+ },
+ gpuids: {
+ title: 'GPU ID',
+ description: (
+ <>
+ This is the GPU that will be used for training. Only one GPU can be used per job at a time via the UI currently.
+ However, you can start multiple jobs in parallel, each using a different GPU.
+ >
+ ),
+ },
+ 'config.process[0].trigger_word': {
+ title: 'Trigger Word',
+ description: (
+ <>
+ Optional: This will be the word or token used to trigger your concept or character.
+
+
+ When using a trigger word, If your captions do not contain the trigger word, it will be added automatically the
+ beginning of the caption. If you do not have captions, the caption will become just the trigger word. If you
+ want to have variable trigger words in your captions to put it in different spots, you can use the{' '}
+ {'[trigger]'} placeholder in your captions. This will be automatically replaced with your trigger
+ word.
+
+
+ Trigger words will not automatically be added to your test prompts, so you will need to either add your trigger
+ word manually or use the
+ {'[trigger]'} placeholder in your test prompts as well.
+ >
+ ),
+ },
+ 'config.process[0].model.name_or_path': {
+ title: 'Name or Path',
+ description: (
+ <>
+ The name of a diffusers repo on Huggingface or the local path to the base model you want to train from. The
+ folder needs to be in diffusers format for most models. For some models, such as SDXL and SD1, you can put the
+ path to an all in one safetensors checkpoint here.
+ >
+ ),
+ },
+ 'datasets.control_path': {
+ title: 'Control Dataset',
+ description: (
+ <>
+ The control dataset needs to have files that match the filenames of your training dataset. They should be
+ matching file pairs. These images are fed as control/input images during training.
+ >
+ ),
+ },
+ 'datasets.num_frames': {
+ title: 'Number of Frames',
+ description: (
+ <>
+ This sets the number of frames to shrink videos to for a video dataset. If this dataset is images, set this to 1
+ for one frame. If your dataset is only videos, frames will be extracted evenly spaced from the videos in the
+ dataset.
+
+
+ It is best to trim your videos to the proper length before training. Wan is 16 frames a second. Doing 81 frames
+ will result in a 5 second video. So you would want all of your videos trimmed to around 5 seconds for best
+ results.
+
+
+ Example: Setting this to 81 and having 2 videos in your dataset, one is 2 seconds and one is 90 seconds long,
+ will result in 81 evenly spaced frames for each video making the 2 second video appear slow and the 90second
+ video appear very fast.
+ >
+ ),
+ },
+ 'datasets.do_i2v': {
+ title: 'Do I2V',
+ description: (
+ <>
+ For video models that can handle both I2V (Image to Video) and T2V (Text to Video), this option sets this
+ dataset to be trained as an I2V dataset. This means that the first frame will be extracted from the video and
+ used as the start image for the video. If this option is not set, the dataset will be treated as a T2V dataset.
+ >
+ ),
+ },
+ 'datasets.flip': {
+ title: 'Flip X and Flip Y',
+ description: (
+ <>
+ You can augment your dataset on the fly by flipping the x (horizontal) and/or y (vertical) axis. Flipping a single axis will effectively double your dataset.
+ It will result it training on normal images, and the flipped versions of the images. This can be very helpful, but keep in mind it can also
+ be destructive. There is no reason to train people upside down, and flipping a face can confuse the model as a person's right side does not
+ look identical to their left side. For text, obviously flipping text is not a good idea.
+
+
+ Control images for a dataset will also be flipped to match the images, so they will always match on the pixel level.
+ >
+ ),
+ },
+ 'train.unload_text_encoder': {
+ title: 'Unload Text Encoder',
+ description: (
+ <>
+ Unloading text encoder will cache the trigger word and the sample prompts and unload the text encoder from the
+ GPU. Captions in for the dataset will be ignored
+ >
+ ),
+ },
+ 'train.cache_text_embeddings': {
+ title: 'Cache Text Embeddings',
+ description: (
+ <>
+ (experimental)
+
+ Caching text embeddings will process and cache all the text embeddings from the text encoder to the disk. The
+ text encoder will be unloaded from the GPU. This does not work with things that dynamically change the prompt
+ such as trigger words, caption dropout, etc.
+ >
+ ),
+ },
+ 'model.multistage': {
+ title: 'Stages to Train',
+ description: (
+ <>
+ Some models have multi stage networks that are trained and used separately in the denoising process. Most
+ common, is to have 2 stages. One for high noise and one for low noise. You can choose to train both stages at
+ once or train them separately. If trained at the same time, The trainer will alternate between training each
+ model every so many steps and will output 2 different LoRAs. If you choose to train only one stage, the
+ trainer will only train that stage and output a single LoRA.
+ >
+ ),
+ },
+ 'train.switch_boundary_every': {
+ title: 'Switch Boundary Every',
+ description: (
+ <>
+ When training a model with multiple stages, this setting controls how often the trainer will switch between
+ training each stage.
+
+
+ For low vram settings, the model not being trained will be unloaded from the gpu to save memory. This takes some
+ time to do, so it is recommended to alternate less often when using low vram. A setting like 10 or 20 is
+ recommended for low vram settings.
+
+
+ The swap happens at the batch level, meaning it will swap between a gradient accumulation steps. To train both
+ stages in a single step, set them to switch every 1 step and set gradient accumulation to 2.
+ >
+ ),
+ },
+};
+
+export const getDoc = (key: string | null | undefined): ConfigDoc | null => {
+ if (key && key in docs) {
+ return docs[key];
+ }
+ return null;
+};
+
+export default docs;
diff --git a/ui/src/hooks/useDatasetList.tsx b/ui/src/hooks/useDatasetList.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..76b7f6eff4f57d1705f06eaa6e5037f0407f3a5e
--- /dev/null
+++ b/ui/src/hooks/useDatasetList.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { apiClient } from '@/utils/api';
+import { usingBrowserDb } from '@/utils/env';
+import { listUserDatasetEntries } from '@/utils/storage/datasetStorage';
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function useDatasetList() {
+ const [datasets, setDatasets] = useState([]);
+ const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
+ const { status: authStatus } = useAuth();
+
+ const refreshDatasets = () => {
+ if (authStatus !== 'authenticated') {
+ setDatasets([]);
+ setStatus('idle');
+ return;
+ }
+ setStatus('loading');
+ if (usingBrowserDb) {
+ const entries = listUserDatasetEntries();
+ entries.sort((a, b) => a.name.localeCompare(b.name));
+ setDatasets(entries.map(entry => entry.name));
+ setStatus('success');
+ return;
+ }
+
+ apiClient
+ .get('/api/datasets/list')
+ .then(res => res.data)
+ .then(data => {
+ console.log('Datasets:', data);
+ data.sort((a: string, b: string) => a.localeCompare(b));
+ setDatasets(data);
+ setStatus('success');
+ })
+ .catch(error => {
+ console.error('Error fetching datasets:', error);
+ setStatus('error');
+ });
+ };
+ useEffect(() => {
+ if (authStatus !== 'authenticated') {
+ setDatasets([]);
+ setStatus('idle');
+ return;
+ }
+ refreshDatasets();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [authStatus]);
+
+ return { datasets, setDatasets, status, refreshDatasets };
+}
diff --git a/ui/src/hooks/useFilesList.tsx b/ui/src/hooks/useFilesList.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c38223903e18c08ea8ed6adc01883e78b278d984
--- /dev/null
+++ b/ui/src/hooks/useFilesList.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import { useEffect, useState, useRef } from 'react';
+import { apiClient } from '@/utils/api';
+import { usingBrowserDb } from '@/utils/env';
+import { useAuth } from '@/contexts/AuthContext';
+
+interface FileObject {
+ path: string;
+ size: number;
+}
+
+export default function useFilesList(jobID: string, reloadInterval: null | number = null) {
+ const [files, setFiles] = useState([]);
+ const didInitialLoadRef = useRef(false);
+ const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error' | 'refreshing'>('idle');
+ const { status: authStatus } = useAuth();
+
+ const refreshFiles = () => {
+ let loadStatus: 'loading' | 'refreshing' = 'loading';
+ if (didInitialLoadRef.current) {
+ loadStatus = 'refreshing';
+ }
+ setStatus(loadStatus);
+ if (usingBrowserDb) {
+ setFiles([]);
+ setStatus('success');
+ didInitialLoadRef.current = true;
+ return;
+ }
+ if (authStatus !== 'authenticated') {
+ setFiles([]);
+ setStatus('idle');
+ return;
+ }
+ apiClient
+ .get(`/api/jobs/${jobID}/files`)
+ .then(res => res.data)
+ .then(data => {
+ console.log('Fetched files:', data);
+ if (data.files) {
+ setFiles(data.files);
+ }
+ setStatus('success');
+ didInitialLoadRef.current = true;
+ })
+ .catch(error => {
+ console.error('Error fetching datasets:', error);
+ setStatus('error');
+ });
+ };
+
+ useEffect(() => {
+ if (authStatus !== 'authenticated') {
+ setFiles([]);
+ setStatus('idle');
+ return;
+ }
+ refreshFiles();
+
+ if (reloadInterval) {
+ const interval = setInterval(() => {
+ refreshFiles();
+ }, reloadInterval);
+
+ return () => {
+ clearInterval(interval);
+ };
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [jobID, authStatus]);
+
+ return { files, setFiles, status, refreshFiles };
+}
diff --git a/ui/src/hooks/useFromNull.tsx b/ui/src/hooks/useFromNull.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..faf2c5b9b52c1e1c52d8f09a2746a13a10e7ba55
--- /dev/null
+++ b/ui/src/hooks/useFromNull.tsx
@@ -0,0 +1,17 @@
+import { useEffect, useRef } from 'react';
+
+export function useFromNull(effect: () => void | (() => void), deps: Array) {
+ const prevDepsRef = useRef<(any | null | undefined)[]>([]);
+
+ useEffect(() => {
+ const shouldRun = deps.some((dep, i) => prevDepsRef.current[i] == null && dep != null);
+
+ if (shouldRun) {
+ const cleanup = effect();
+ prevDepsRef.current = deps;
+ return cleanup;
+ }
+
+ prevDepsRef.current = deps;
+ }, deps);
+}
diff --git a/ui/src/hooks/useGPUInfo.tsx b/ui/src/hooks/useGPUInfo.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b8f604053b4821dea2d77d7316fd2c562b94ddf4
--- /dev/null
+++ b/ui/src/hooks/useGPUInfo.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { GPUApiResponse, GpuInfo } from '@/types';
+import { useEffect, useState } from 'react';
+import { apiClient } from '@/utils/api';
+
+export default function useGPUInfo(gpuIds: null | number[] = null, reloadInterval: null | number = null) {
+ const [gpuList, setGpuList] = useState([]);
+ const [isGPUInfoLoaded, setIsLoaded] = useState(false);
+ const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
+
+ const fetchGpuInfo = async () => {
+ setStatus('loading');
+ try {
+ const data: GPUApiResponse = await apiClient.get('/api/gpu').then(res => res.data);
+ let gpus = data.gpus.sort((a, b) => a.index - b.index);
+ if (gpuIds) {
+ gpus = gpus.filter(gpu => gpuIds.includes(gpu.index));
+ }
+ setGpuList(gpus);
+ setStatus('success');
+ } catch (err) {
+ console.error(`Failed to fetch GPU data: ${err instanceof Error ? err.message : String(err)}`);
+ setStatus('error');
+ } finally {
+ setIsLoaded(true);
+ }
+ };
+
+ useEffect(() => {
+ // Fetch immediately on component mount
+ fetchGpuInfo();
+
+ // Set up interval if specified
+ if (reloadInterval) {
+ const interval = setInterval(() => {
+ fetchGpuInfo();
+ }, reloadInterval);
+
+ // Cleanup interval on unmount
+ return () => {
+ clearInterval(interval);
+ };
+ }
+ }, [gpuIds, reloadInterval]); // Added dependencies
+
+ return { gpuList, setGpuList, isGPUInfoLoaded, status, refreshGpuInfo: fetchGpuInfo };
+}
diff --git a/ui/src/hooks/useHFJobStatus.tsx b/ui/src/hooks/useHFJobStatus.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..245e7c628d900b0c959c96ed9591c2c49b622692
--- /dev/null
+++ b/ui/src/hooks/useHFJobStatus.tsx
@@ -0,0 +1,63 @@
+import { useState, useEffect } from 'react';
+import { apiClient } from '@/utils/api';
+import useSettings from './useSettings';
+import { useAuth } from '@/contexts/AuthContext';
+
+interface HFJobStatus {
+ id: string;
+ status: string;
+ message: string | null;
+ created_at: string;
+ flavor: string;
+ url: string;
+}
+
+export function useHFJobStatus(hfJobId: string | null, refreshInterval = 30000) {
+ const [status, setStatus] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const { settings } = useSettings();
+ const { token: authToken } = useAuth();
+ const token = authToken || settings.HF_TOKEN;
+
+ useEffect(() => {
+ if (!hfJobId || !token) return;
+
+ const fetchStatus = async () => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const response = await apiClient.post('/api/hf-jobs', {
+ action: 'checkStatus',
+ token,
+ jobConfig: { hf_job_id: hfJobId },
+ });
+
+ if (response.data.status) {
+ setStatus(response.data.status);
+ }
+ } catch (err: any) {
+ setError(err.response?.data?.error || 'Failed to fetch status');
+ console.error('HF Job status fetch error:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Initial fetch
+ fetchStatus();
+
+ // Set up periodic refresh for running jobs
+ const interval = setInterval(fetchStatus, refreshInterval);
+
+ return () => clearInterval(interval);
+ }, [hfJobId, token, refreshInterval]);
+
+ return { status, loading, error, refetch: () => {
+ if (hfJobId && token) {
+ setError(null);
+ // Trigger immediate refetch by setting a new effect dependency
+ }
+ }};
+}
diff --git a/ui/src/hooks/useJob.tsx b/ui/src/hooks/useJob.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9c2f8535cc3113a668abbcf547332c6a4f4ecbfb
--- /dev/null
+++ b/ui/src/hooks/useJob.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { JobRecord } from '@/types';
+import { getJob } from '@/utils/storage/jobStorage';
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function useJob(jobID: string, reloadInterval: null | number = null) {
+ const [job, setJob] = useState(null);
+ const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
+ const { status: authStatus } = useAuth();
+
+ const refreshJob = () => {
+ if (authStatus !== 'authenticated') {
+ setJob(null);
+ setStatus('idle');
+ return;
+ }
+ setStatus('loading');
+ getJob(jobID)
+ .then(data => {
+ if (data) {
+ setJob(data);
+ setStatus('success');
+ } else {
+ setStatus('error');
+ }
+ })
+ .catch(error => {
+ console.error('Error fetching job:', error);
+ setStatus('error');
+ });
+ };
+
+ useEffect(() => {
+ if (authStatus !== 'authenticated') {
+ setJob(null);
+ setStatus('idle');
+ return;
+ }
+ refreshJob();
+
+ if (reloadInterval) {
+ const interval = setInterval(() => {
+ refreshJob();
+ }, reloadInterval);
+
+ return () => {
+ clearInterval(interval);
+ };
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [jobID, authStatus]);
+
+ return { job, setJob, status, refreshJob };
+}
diff --git a/ui/src/hooks/useJobLog.tsx b/ui/src/hooks/useJobLog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f391ddbff6c82fa7cb6b6e8a0b88b6ce06b5d2a6
--- /dev/null
+++ b/ui/src/hooks/useJobLog.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { useEffect, useState, useRef } from 'react';
+import { apiClient } from '@/utils/api';
+import { usingBrowserDb } from '@/utils/env';
+import { useAuth } from '@/contexts/AuthContext';
+
+interface FileObject {
+ path: string;
+ size: number;
+}
+
+const clean = (text: string): string => {
+ // remove \x1B[A\x1B[A
+ text = text.replace(/\x1B\[A/g, '');
+ return text;
+};
+
+export default function useJobLog(jobID: string, reloadInterval: null | number = null) {
+ const [log, setLog] = useState('');
+ const didInitialLoadRef = useRef(false);
+ const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error' | 'refreshing'>('idle');
+ const { status: authStatus } = useAuth();
+
+ const refresh = () => {
+ let loadStatus: 'loading' | 'refreshing' = 'loading';
+ if (didInitialLoadRef.current) {
+ loadStatus = 'refreshing';
+ }
+ setStatus(loadStatus);
+ if (usingBrowserDb) {
+ setLog('');
+ setStatus('success');
+ didInitialLoadRef.current = true;
+ return;
+ }
+ if (authStatus !== 'authenticated') {
+ setLog('');
+ setStatus('idle');
+ return;
+ }
+ apiClient
+ .get(`/api/jobs/${jobID}/log`)
+ .then(res => res.data)
+ .then(data => {
+ if (data.log) {
+ let cleanLog = clean(data.log);
+ setLog(cleanLog);
+ }
+ setStatus('success');
+ didInitialLoadRef.current = true;
+ })
+ .catch(error => {
+ console.error('Error fetching log:', error);
+ setStatus('error');
+ });
+ };
+
+ useEffect(() => {
+ if (authStatus !== 'authenticated') {
+ setLog('');
+ setStatus('idle');
+ return;
+ }
+ refresh();
+
+ if (reloadInterval) {
+ const interval = setInterval(() => {
+ refresh();
+ }, reloadInterval);
+
+ return () => {
+ clearInterval(interval);
+ };
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [jobID, authStatus]);
+
+ return { log, setLog, status, refresh };
+}
diff --git a/ui/src/hooks/useJobsList.tsx b/ui/src/hooks/useJobsList.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..64e7f475ccebd01f722da8a05baffefbf032d24d
--- /dev/null
+++ b/ui/src/hooks/useJobsList.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { JobRecord } from '@/types';
+import { listJobs } from '@/utils/storage/jobStorage';
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function useJobsList(onlyActive = false) {
+ const [jobs, setJobs] = useState([]);
+ const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
+ const { status: authStatus } = useAuth();
+
+ const refreshJobs = () => {
+ if (authStatus !== 'authenticated') {
+ setJobs([]);
+ setStatus('idle');
+ return;
+ }
+ setStatus('loading');
+ listJobs()
+ .then(data => {
+ let items = data;
+ if (onlyActive) {
+ items = items.filter(job => {
+ if (job.status === 'running') {
+ return true;
+ }
+ try {
+ const jobConfig = JSON.parse(job.job_config);
+ return Boolean(jobConfig?.is_hf_job && jobConfig?.hf_job_id);
+ } catch (error) {
+ return false;
+ }
+ });
+ }
+ setJobs(items);
+ setStatus('success');
+ })
+ .catch(error => {
+ console.error('Error fetching jobs:', error);
+ setStatus('error');
+ });
+ };
+ useEffect(() => {
+ if (authStatus !== 'authenticated') {
+ setJobs([]);
+ return;
+ }
+ refreshJobs();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [authStatus]);
+
+ return { jobs, setJobs, status, refreshJobs };
+}
diff --git a/ui/src/hooks/useSampleImages.tsx b/ui/src/hooks/useSampleImages.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..69971a29f858d88b2c476507f08db537a47be87e
--- /dev/null
+++ b/ui/src/hooks/useSampleImages.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { apiClient } from '@/utils/api';
+import { usingBrowserDb } from '@/utils/env';
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function useSampleImages(jobID: string, reloadInterval: null | number = null) {
+ const [sampleImages, setSampleImages] = useState([]);
+ const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
+ const { status: authStatus } = useAuth();
+
+ const refreshSampleImages = () => {
+ setStatus('loading');
+ if (usingBrowserDb) {
+ setSampleImages([]);
+ setStatus('success');
+ return;
+ }
+ if (authStatus !== 'authenticated') {
+ setSampleImages([]);
+ setStatus('idle');
+ return;
+ }
+ apiClient
+ .get(`/api/jobs/${jobID}/samples`)
+ .then(res => res.data)
+ .then(data => {
+ console.log('Fetched sample images:', data);
+ if (data.samples) {
+ setSampleImages(data.samples);
+ }
+ setStatus('success');
+ })
+ .catch(error => {
+ console.error('Error fetching datasets:', error);
+ setStatus('error');
+ });
+ };
+
+ useEffect(() => {
+ if (authStatus !== 'authenticated') {
+ setSampleImages([]);
+ setStatus('idle');
+ return;
+ }
+ refreshSampleImages();
+
+ if (reloadInterval) {
+ const interval = setInterval(() => {
+ refreshSampleImages();
+ }, reloadInterval);
+
+ return () => {
+ clearInterval(interval);
+ };
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [jobID, authStatus]);
+
+ return { sampleImages, setSampleImages, status, refreshSampleImages };
+}
diff --git a/ui/src/hooks/useSettings.tsx b/ui/src/hooks/useSettings.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..17a5d8796853dda23a81263e05c28fa105bc3888
--- /dev/null
+++ b/ui/src/hooks/useSettings.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { SettingsData } from '@/types';
+import { DEFAULT_SETTINGS, loadSettings } from '@/utils/storage/settingsStorage';
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function useSettings() {
+ const [settings, setSettings] = useState(DEFAULT_SETTINGS);
+ const [isSettingsLoaded, setIsLoaded] = useState(false);
+ const { token: authToken } = useAuth();
+
+ useEffect(() => {
+ let isMounted = true;
+
+ loadSettings()
+ .then(data => {
+ if (isMounted) {
+ setSettings(data);
+ setIsLoaded(true);
+ }
+ })
+ .catch(error => {
+ console.error('Error fetching settings:', error);
+ if (isMounted) {
+ setIsLoaded(true);
+ }
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!isSettingsLoaded) {
+ return;
+ }
+ setSettings(prev => {
+ const nextToken = authToken || '';
+ if (prev.HF_TOKEN === nextToken) {
+ return prev;
+ }
+ return { ...prev, HF_TOKEN: nextToken };
+ });
+ }, [authToken, isSettingsLoaded]);
+
+ return { settings, setSettings, isSettingsLoaded };
+}
diff --git a/ui/src/middleware.ts b/ui/src/middleware.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bf198d1e504c2524bafb4e525d7581cb652608b3
--- /dev/null
+++ b/ui/src/middleware.ts
@@ -0,0 +1,49 @@
+// middleware.ts (at the root of your project)
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+
+// if route starts with these, approve
+const publicRoutes = ['/api/img/', '/api/files/'];
+
+export function middleware(request: NextRequest) {
+ // check env var for AI_TOOLKIT_AUTH, if not set, approve all requests
+ // if it is set make sure bearer token matches
+ const tokenToUse = process.env.AI_TOOLKIT_AUTH || null;
+ if (!tokenToUse) {
+ return NextResponse.next();
+ }
+
+ // Get the token from the headers
+ const token = request.headers.get('Authorization')?.split(' ')[1];
+
+ // allow public routes to pass through
+ if (publicRoutes.some(route => request.nextUrl.pathname.startsWith(route))) {
+ return NextResponse.next();
+ }
+
+ // Check if the route should be protected
+ // This will apply to all API routes that start with /api/
+ if (request.nextUrl.pathname.startsWith('/api/')) {
+ if (!token || token !== tokenToUse) {
+ // Return a JSON response with 401 Unauthorized
+ return new NextResponse(JSON.stringify({ error: 'Unauthorized' }), {
+ status: 401,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ // For authorized users, continue
+ return NextResponse.next();
+ }
+
+ // For non-API routes, just continue
+ return NextResponse.next();
+}
+
+// Configure which paths this middleware will run on
+export const config = {
+ matcher: [
+ // Apply to all API routes
+ '/api/:path*',
+ ],
+};
diff --git a/ui/src/paths.ts b/ui/src/paths.ts
new file mode 100644
index 0000000000000000000000000000000000000000..92311f434e8be345ceb4a2d8dcd54d1863e9f6de
--- /dev/null
+++ b/ui/src/paths.ts
@@ -0,0 +1,5 @@
+import path from 'path';
+export const TOOLKIT_ROOT = path.resolve('@', '..', '..');
+export const defaultTrainFolder = path.join(TOOLKIT_ROOT, 'output');
+export const defaultDatasetsFolder = path.join(TOOLKIT_ROOT, 'datasets');
+export const defaultDataRoot = path.join(TOOLKIT_ROOT, 'data');
diff --git a/ui/src/server/settings.ts b/ui/src/server/settings.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9c6abe68be42cd26010c81dc7b65d0c2049c9720
--- /dev/null
+++ b/ui/src/server/settings.ts
@@ -0,0 +1,87 @@
+import { PrismaClient } from '@prisma/client';
+import { defaultDatasetsFolder, defaultDataRoot } from '@/paths';
+import { defaultTrainFolder } from '@/paths';
+import NodeCache from 'node-cache';
+
+const myCache = new NodeCache();
+const prisma = new PrismaClient();
+
+export const flushCache = () => {
+ myCache.flushAll();
+};
+
+export const getDatasetsRoot = async () => {
+ const key = 'DATASETS_FOLDER';
+ let datasetsPath = myCache.get(key) as string;
+ if (datasetsPath) {
+ return datasetsPath;
+ }
+ let row = await prisma.settings.findFirst({
+ where: {
+ key: 'DATASETS_FOLDER',
+ },
+ });
+ datasetsPath = defaultDatasetsFolder;
+ if (row?.value && row.value !== '') {
+ datasetsPath = row.value;
+ }
+ myCache.set(key, datasetsPath);
+ return datasetsPath as string;
+};
+
+export const getTrainingFolder = async () => {
+ const key = 'TRAINING_FOLDER';
+ let trainingRoot = myCache.get(key) as string;
+ if (trainingRoot) {
+ return trainingRoot;
+ }
+ let row = await prisma.settings.findFirst({
+ where: {
+ key: key,
+ },
+ });
+ trainingRoot = defaultTrainFolder;
+ if (row?.value && row.value !== '') {
+ trainingRoot = row.value;
+ }
+ myCache.set(key, trainingRoot);
+ return trainingRoot as string;
+};
+
+export const getHFToken = async () => {
+ const key = 'HF_TOKEN';
+ let token = myCache.get(key) as string;
+ if (token) {
+ return token;
+ }
+ let row = await prisma.settings.findFirst({
+ where: {
+ key: key,
+ },
+ });
+ token = '';
+ if (row?.value && row.value !== '') {
+ token = row.value;
+ }
+ myCache.set(key, token);
+ return token;
+};
+
+export const getDataRoot = async () => {
+ const key = 'DATA_ROOT';
+ let dataRoot = myCache.get(key) as string;
+ if (dataRoot) {
+ return dataRoot;
+ }
+ let row = await prisma.settings.findFirst({
+ where: {
+ key: key,
+ },
+ });
+ dataRoot = defaultDataRoot;
+ if (row?.value && row.value !== '') {
+ dataRoot = row.value;
+ }
+ myCache.set(key, dataRoot);
+ return dataRoot;
+};
diff --git a/ui/src/types.ts b/ui/src/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..db9c49cc89e3867085d42f178e87c6b40fa0cda0
--- /dev/null
+++ b/ui/src/types.ts
@@ -0,0 +1,250 @@
+/**
+ * GPU API response
+ */
+
+export interface GpuUtilization {
+ gpu: number;
+ memory: number;
+}
+
+export interface GpuMemory {
+ total: number;
+ free: number;
+ used: number;
+}
+
+export interface GpuPower {
+ draw: number;
+ limit: number;
+}
+
+export interface GpuClocks {
+ graphics: number;
+ memory: number;
+}
+
+export interface GpuFan {
+ speed: number;
+}
+
+export interface GpuInfo {
+ index: number;
+ name: string;
+ driverVersion: string;
+ temperature: number;
+ utilization: GpuUtilization;
+ memory: GpuMemory;
+ power: GpuPower;
+ clocks: GpuClocks;
+ fan: GpuFan;
+}
+
+export interface GPUApiResponse {
+ hasNvidiaSmi: boolean;
+ gpus: GpuInfo[];
+ error?: string;
+}
+
+/**
+ * Training configuration
+ */
+
+export interface NetworkConfig {
+ type: string;
+ linear: number;
+ linear_alpha: number;
+ conv: number;
+ conv_alpha: number;
+ lokr_full_rank: boolean;
+ lokr_factor: number;
+ network_kwargs: {
+ ignore_if_contains: string[];
+ };
+}
+
+export interface SaveConfig {
+ dtype: string;
+ save_every: number;
+ max_step_saves_to_keep: number;
+ save_format: string;
+ push_to_hub: boolean;
+}
+
+export interface DatasetConfig {
+ folder_path: string;
+ mask_path: string | null;
+ mask_min_value: number;
+ default_caption: string;
+ caption_ext: string;
+ caption_dropout_rate: number;
+ shuffle_tokens?: boolean;
+ is_reg: boolean;
+ network_weight: number;
+ cache_latents_to_disk?: boolean;
+ resolution: number[];
+ controls: string[];
+ control_path: string | null;
+ num_frames: number;
+ shrink_video_to_frames: boolean;
+ do_i2v: boolean;
+ flip_x: boolean;
+ flip_y: boolean;
+}
+
+export interface EMAConfig {
+ use_ema: boolean;
+ ema_decay: number;
+}
+
+export interface TrainConfig {
+ batch_size: number;
+ bypass_guidance_embedding?: boolean;
+ steps: number;
+ gradient_accumulation: number;
+ train_unet: boolean;
+ train_text_encoder: boolean;
+ gradient_checkpointing: boolean;
+ noise_scheduler: string;
+ timestep_type: string;
+ content_or_style: string;
+ optimizer: string;
+ lr: number;
+ ema_config?: EMAConfig;
+ dtype: string;
+ unload_text_encoder: boolean;
+ cache_text_embeddings: boolean;
+ optimizer_params: {
+ weight_decay: number;
+ };
+ skip_first_sample: boolean;
+ disable_sampling: boolean;
+ diff_output_preservation: boolean;
+ diff_output_preservation_multiplier: number;
+ diff_output_preservation_class: string;
+ switch_boundary_every: number;
+}
+
+export interface QuantizeKwargsConfig {
+ exclude: string[];
+}
+
+export interface ModelConfig {
+ name_or_path: string;
+ quantize: boolean;
+ quantize_te: boolean;
+ qtype: string;
+ qtype_te: string;
+ quantize_kwargs?: QuantizeKwargsConfig;
+ arch: string;
+ low_vram: boolean;
+ model_kwargs: { [key: string]: any };
+}
+
+export interface SampleItem {
+ prompt: string;
+ width?: number
+ height?: number;
+ neg?: string;
+ seed?: number;
+ guidance_scale?: number;
+ sample_steps?: number;
+ fps?: number;
+ num_frames?: number;
+ ctrl_img?: string | null;
+ ctrl_idx?: number;
+}
+
+export interface SampleConfig {
+ sampler: string;
+ sample_every: number;
+ width: number;
+ height: number;
+ prompts?: string[];
+ samples: SampleItem[];
+ neg: string;
+ seed: number;
+ walk_seed: boolean;
+ guidance_scale: number;
+ sample_steps: number;
+ num_frames: number;
+ fps: number;
+}
+
+export interface ProcessConfig {
+ type: 'ui_trainer';
+ sqlite_db_path?: string;
+ training_folder: string;
+ performance_log_every: number;
+ trigger_word: string | null;
+ device: string;
+ network?: NetworkConfig;
+ save: SaveConfig;
+ datasets: DatasetConfig[];
+ train: TrainConfig;
+ model: ModelConfig;
+ sample: SampleConfig;
+}
+
+export interface ConfigObject {
+ name: string;
+ process: ProcessConfig[];
+}
+
+export interface MetaConfig {
+ name: string;
+ version: string;
+}
+
+export interface JobConfig {
+ job: string;
+ config: ConfigObject;
+ meta: MetaConfig;
+}
+
+export interface ConfigDoc {
+ title: string;
+ description: React.ReactNode;
+}
+
+export interface SelectOption {
+ readonly value: string;
+ readonly label: string;
+}
+export interface GroupedSelectOption {
+ readonly label: string;
+ readonly options: SelectOption[];
+}
+
+export interface SettingsData {
+ HF_TOKEN: string;
+ TRAINING_FOLDER: string;
+ DATASETS_FOLDER: string;
+ HF_JOBS_NAMESPACE: string;
+ HF_JOBS_DEFAULT_HARDWARE: string;
+}
+
+export interface JobRecord {
+ id: string;
+ name: string;
+ gpu_ids: string;
+ job_config: string;
+ status: string;
+ stop: boolean;
+ step: number;
+ info: string;
+ speed_string: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface JobUpsertPayload {
+ id?: string;
+ name: string;
+ gpu_ids?: string | null;
+ job_config: unknown;
+ status?: string;
+ stop?: boolean;
+ step?: number;
+ info?: string;
+ speed_string?: string;
+}
diff --git a/ui/src/utils/api.ts b/ui/src/utils/api.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5bf3716e82222f56a822319e6323c42534f7b7f2
--- /dev/null
+++ b/ui/src/utils/api.ts
@@ -0,0 +1,31 @@
+import axios from 'axios';
+import { createGlobalState } from 'react-global-hooks';
+
+export const isAuthorizedState = createGlobalState(false);
+
+export const apiClient = axios.create();
+
+// Add a request interceptor to add token from localStorage
+apiClient.interceptors.request.use(config => {
+ const token = localStorage.getItem('AI_TOOLKIT_AUTH');
+ if (token) {
+ config.headers['Authorization'] = `Bearer ${token}`;
+ }
+ return config;
+});
+
+// Add a response interceptor to handle 401 errors
+apiClient.interceptors.response.use(
+ response => response, // Return successful responses as-is
+ error => {
+ // Check if the error is a 401 Unauthorized
+ if (error.response && error.response.status === 401) {
+ // Clear the auth token from localStorage
+ localStorage.removeItem('AI_TOOLKIT_AUTH');
+ isAuthorizedState.set(false);
+ }
+
+ // Reject the promise with the error so calling code can still catch it
+ return Promise.reject(error);
+ },
+);
diff --git a/ui/src/utils/basic.ts b/ui/src/utils/basic.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ac8042ce50af1753cc4da19546e9980941926de6
--- /dev/null
+++ b/ui/src/utils/basic.ts
@@ -0,0 +1,11 @@
+export const objectCopy = (obj: T): T => {
+ return JSON.parse(JSON.stringify(obj)) as T;
+};
+
+export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+export const imgExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'];
+export const videoExtensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'];
+
+export const isVideo = (filePath: string) => videoExtensions.includes(filePath.toLowerCase().slice(-4));
+export const isImage = (filePath: string) => imgExtensions.includes(filePath.toLowerCase().slice(-4));
diff --git a/ui/src/utils/env.ts b/ui/src/utils/env.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7b94c762e9705e4f95633d9b37a301154fbb0757
--- /dev/null
+++ b/ui/src/utils/env.ts
@@ -0,0 +1,9 @@
+export type DatabaseMode = 'server' | 'browser';
+
+const rawMode = (process.env.NEXT_PUBLIC_DB_MODE || '').toLowerCase();
+
+export const DB_MODE: DatabaseMode = rawMode === 'browser' ? 'browser' : 'server';
+export const usingBrowserDb = DB_MODE === 'browser';
+export const usingServerDb = DB_MODE === 'server';
+
+export const oauthClientId = process.env.NEXT_PUBLIC_HF_OAUTH_CLIENT_ID || '';
diff --git a/ui/src/utils/hooks.tsx b/ui/src/utils/hooks.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f96af34473057db03fd2c9bc4f0e14df369f3103
--- /dev/null
+++ b/ui/src/utils/hooks.tsx
@@ -0,0 +1,88 @@
+import React from 'react';
+
+/**
+ * Updates a deeply nested value in an object using a string path
+ * @param obj The object to update
+ * @param value The new value to set
+ * @param path String path to the property (e.g. 'config.process[0].model.name_or_path')
+ * @returns A new object with the updated value
+ */
+export function setNestedValue(obj: T, value: V, path?: string): T {
+ // Create a copy of the original object to maintain immutability
+ const result = { ...obj };
+
+ // if path is not provided, be root path
+ if (!path) {
+ path = '';
+ }
+
+ // Split the path into segments
+ const pathArray = path.split('.').flatMap(segment => {
+ // Handle array notation like 'process[0]'
+ const arrayMatch = segment.match(/^([^\[]+)(\[\d+\])+/);
+ if (arrayMatch) {
+ const propName = arrayMatch[1];
+ const indices = segment
+ .substring(propName.length)
+ .match(/\[(\d+)\]/g)
+ ?.map(idx => parseInt(idx.substring(1, idx.length - 1)));
+
+ // Return property name followed by array indices
+ return [propName, ...(indices || [])];
+ }
+ return segment;
+ });
+
+ // Navigate to the target location
+ let current: any = result;
+ for (let i = 0; i < pathArray.length - 1; i++) {
+ const key = pathArray[i];
+
+ // If current key is a number, treat it as an array index
+ if (typeof key === 'number') {
+ if (!Array.isArray(current)) {
+ throw new Error(`Cannot access index ${key} of non-array`);
+ }
+ // Create a copy of the array to maintain immutability
+ current = [...current];
+ } else {
+ // For object properties, create a new object if it doesn't exist
+ if (current[key] === undefined) {
+ // Check if the next key is a number, if so create an array, otherwise an object
+ const nextKey = pathArray[i + 1];
+ current[key] = typeof nextKey === 'number' ? [] : {};
+ } else {
+ // Create a shallow copy to maintain immutability
+ current[key] = Array.isArray(current[key]) ? [...current[key]] : { ...current[key] };
+ }
+ }
+
+ // Move to the next level
+ current = current[key];
+ }
+
+ // Set the value at the final path segment
+ const finalKey = pathArray[pathArray.length - 1];
+ current[finalKey] = value;
+
+ return result;
+}
+
+/**
+ * Custom hook for managing a complex state object with string path updates
+ * @param initialState The initial state object
+ * @returns [state, setValue] tuple
+ */
+export function useNestedState(initialState: T): [T, (value: any, path?: string) => void] {
+ const [state, setState] = React.useState(initialState);
+
+ const setValue = React.useCallback((value: any, path?: string) => {
+ if (path === undefined) {
+ setState(value);
+ return;
+ }
+ setState(prevState => setNestedValue(prevState, value, path));
+ }, []);
+
+ return [state, setValue];
+}
diff --git a/ui/src/utils/jobs.ts b/ui/src/utils/jobs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..700300d5a90106a71e76e2378f6ae0f05b5f3c0b
--- /dev/null
+++ b/ui/src/utils/jobs.ts
@@ -0,0 +1,58 @@
+import { JobConfig, JobRecord } from '@/types';
+import { apiClient } from '@/utils/api';
+import { usingBrowserDb } from '@/utils/env';
+import { deleteJobRecord } from '@/utils/storage/jobStorage';
+
+export const startJob = async (jobID: string) => {
+ if (usingBrowserDb) {
+ throw new Error('Local jobs are not supported when using browser database mode.');
+ }
+
+ await apiClient.get(`/api/jobs/${jobID}/start`).then(res => res.data);
+};
+
+export const stopJob = async (jobID: string) => {
+ if (usingBrowserDb) {
+ throw new Error('Stopping jobs is not available when using browser database mode.');
+ }
+
+ await apiClient.get(`/api/jobs/${jobID}/stop`).then(res => res.data);
+};
+
+export const deleteJob = async (jobID: string) => {
+ if (usingBrowserDb) {
+ await deleteJobRecord(jobID);
+ return;
+ }
+
+ await apiClient.get(`/api/jobs/${jobID}/delete`).then(res => res.data);
+};
+
+export const getJobConfig = (job: JobRecord) => {
+ return JSON.parse(job.job_config) as JobConfig;
+};
+
+export const getAvaliableJobActions = (job: JobRecord) => {
+ const jobConfig = getJobConfig(job);
+ const isStopping = job.stop && job.status === 'running';
+ const editableStatuses = ['completed', 'stopped', 'error', 'submitted', 'pending'];
+ const canDelete = editableStatuses.includes(job.status) && !isStopping;
+ const canEdit = editableStatuses.includes(job.status) && !isStopping;
+ const canStop = job.status === 'running' && !isStopping;
+ let canStart = ['stopped', 'error'].includes(job.status) && !isStopping;
+ // can resume if more steps were added
+ if (job.status === 'completed' && jobConfig.config.process[0].train.steps > job.step && !isStopping) {
+ canStart = true;
+ }
+ return { canDelete, canEdit, canStop, canStart };
+};
+
+export const getNumberOfSamples = (job: JobRecord) => {
+ const jobConfig = getJobConfig(job);
+ return jobConfig.config.process[0].sample?.prompts?.length || 0;
+};
+
+export const getTotalSteps = (job: JobRecord) => {
+ const jobConfig = getJobConfig(job);
+ return jobConfig.config.process[0].train.steps;
+};
diff --git a/ui/src/utils/storage/datasetStorage.ts b/ui/src/utils/storage/datasetStorage.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7fcc65b0131205084d4c39d3feeea52cd44a0bc2
--- /dev/null
+++ b/ui/src/utils/storage/datasetStorage.ts
@@ -0,0 +1,154 @@
+import { usingBrowserDb } from '@/utils/env';
+
+const DATASETS_KEY = 'aitk.browser.datasets';
+
+export interface DatasetEntry {
+ name: string;
+ path: string;
+}
+
+const normalizeEntry = (value: any): DatasetEntry | null => {
+ if (!value) {
+ return null;
+ }
+ if (typeof value === 'string') {
+ return { name: value, path: '' };
+ }
+ if (typeof value === 'object') {
+ const name = typeof value.name === 'string' ? value.name : '';
+ if (!name) {
+ return null;
+ }
+ const path = typeof value.path === 'string' ? value.path : '';
+ return { name, path };
+ }
+ return null;
+};
+
+const readDatasets = (): DatasetEntry[] => {
+ if (typeof window === 'undefined') {
+ return [];
+ }
+
+ try {
+ const raw = window.localStorage.getItem(DATASETS_KEY);
+ if (!raw) {
+ return [];
+ }
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) {
+ return [];
+ }
+ const items = parsed
+ .map(normalizeEntry)
+ .filter((entry): entry is DatasetEntry => Boolean(entry));
+ return items;
+ } catch (error) {
+ console.error('Failed to read datasets from localStorage:', error);
+ return [];
+ }
+};
+
+const writeDatasets = (datasets: DatasetEntry[]) => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ try {
+ window.localStorage.setItem(DATASETS_KEY, JSON.stringify(datasets));
+ } catch (error) {
+ console.error('Failed to write datasets to localStorage:', error);
+ }
+};
+
+const sanitizeName = (name: string) => name.trim().toLowerCase();
+
+export const listUserDatasetEntries = (): DatasetEntry[] => {
+ if (!usingBrowserDb) {
+ return [];
+ }
+ const datasets = readDatasets();
+ const unique = new Map();
+ datasets.forEach(entry => {
+ if (!unique.has(entry.name)) {
+ unique.set(entry.name, entry);
+ }
+ });
+ return Array.from(unique.values());
+};
+
+export const listUserDatasets = (): string[] => {
+ return listUserDatasetEntries().map(entry => entry.name);
+};
+
+export const getUserDatasetPath = (datasetName: string): string | null => {
+ if (!usingBrowserDb) {
+ return null;
+ }
+ const normalized = sanitizeName(datasetName);
+ const entry = listUserDatasetEntries().find(item => sanitizeName(item.name) === normalized);
+ return entry?.path || null;
+};
+
+export const addUserDataset = (datasetName: string, datasetPath: string) => {
+ if (!usingBrowserDb) {
+ return;
+ }
+ if (!datasetName) {
+ return;
+ }
+ const trimmed = sanitizeName(datasetName);
+ if (trimmed === '') {
+ return;
+ }
+ const existing = listUserDatasetEntries();
+ const index = existing.findIndex(entry => sanitizeName(entry.name) === trimmed);
+ if (index !== -1) {
+ existing[index] = {
+ ...existing[index],
+ name: datasetName,
+ path: datasetPath || existing[index].path,
+ };
+ writeDatasets(existing);
+ return;
+ }
+ existing.push({ name: datasetName, path: datasetPath || '' });
+ writeDatasets(existing);
+};
+
+export const updateUserDatasetPath = (datasetName: string, datasetPath: string) => {
+ if (!usingBrowserDb) {
+ return;
+ }
+ const normalized = sanitizeName(datasetName);
+ const existing = listUserDatasetEntries();
+ const updated = existing.map(entry =>
+ sanitizeName(entry.name) === normalized ? { ...entry, path: datasetPath || entry.path } : entry,
+ );
+ writeDatasets(updated);
+};
+
+export const removeUserDataset = (datasetName: string) => {
+ if (!usingBrowserDb) {
+ return;
+ }
+ const normalized = sanitizeName(datasetName);
+ const existing = listUserDatasetEntries();
+ const updated = existing.filter(entry => sanitizeName(entry.name) !== normalized);
+ writeDatasets(updated);
+};
+
+export const hasUserDataset = (datasetName: string): boolean => {
+ if (!usingBrowserDb) {
+ return true;
+ }
+ const normalized = sanitizeName(datasetName);
+ return listUserDatasetEntries().some(entry => sanitizeName(entry.name) === normalized);
+};
+
+export const clearUserDatasets = () => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ window.localStorage.removeItem(DATASETS_KEY);
+};
diff --git a/ui/src/utils/storage/jobStorage.ts b/ui/src/utils/storage/jobStorage.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6b827d8928056750c3ba50b2c2955659c30743db
--- /dev/null
+++ b/ui/src/utils/storage/jobStorage.ts
@@ -0,0 +1,199 @@
+import { apiClient } from '@/utils/api';
+import { usingBrowserDb } from '@/utils/env';
+import { JobRecord, JobUpsertPayload } from '@/types';
+import { v4 as uuidv4 } from 'uuid';
+
+const LOCAL_JOBS_STORAGE_KEY = 'aitk.browser.jobs';
+
+const ensureIsoString = (value: unknown): string => {
+ if (!value) {
+ return new Date().toISOString();
+ }
+ if (typeof value === 'string') {
+ return value;
+ }
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+ const date = new Date(value as any);
+ if (!Number.isNaN(date.getTime())) {
+ return date.toISOString();
+ }
+ return new Date().toISOString();
+};
+
+const normalizeJob = (job: any): JobRecord => {
+ return {
+ id: job.id,
+ name: job.name || '',
+ gpu_ids: job.gpu_ids || '',
+ job_config: typeof job.job_config === 'string' ? job.job_config : JSON.stringify(job.job_config || {}),
+ status: job.status || 'stopped',
+ stop: Boolean(job.stop),
+ step: typeof job.step === 'number' ? job.step : 0,
+ info: job.info || '',
+ speed_string: job.speed_string || '',
+ created_at: ensureIsoString(job.created_at),
+ updated_at: ensureIsoString(job.updated_at),
+ };
+};
+
+const readLocalJobs = (): JobRecord[] => {
+ if (typeof window === 'undefined') {
+ return [];
+ }
+ try {
+ const raw = window.localStorage.getItem(LOCAL_JOBS_STORAGE_KEY);
+ if (!raw) {
+ return [];
+ }
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) {
+ return [];
+ }
+ return parsed.map(normalizeJob);
+ } catch (error) {
+ console.error('Failed to read jobs from localStorage:', error);
+ return [];
+ }
+};
+
+const writeLocalJobs = (jobs: JobRecord[]) => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ window.localStorage.setItem(LOCAL_JOBS_STORAGE_KEY, JSON.stringify(jobs));
+ } catch (error) {
+ console.error('Failed to write jobs to localStorage:', error);
+ }
+};
+
+const serializeJobConfig = (jobConfig: unknown): string => {
+ if (typeof jobConfig === 'string') {
+ return jobConfig;
+ }
+ try {
+ return JSON.stringify(jobConfig ?? {});
+ } catch (error) {
+ console.error('Failed to serialize job config:', error);
+ return JSON.stringify({});
+ }
+};
+
+export const listJobs = async (): Promise => {
+ if (usingBrowserDb) {
+ const jobs = readLocalJobs();
+ return jobs.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
+ }
+
+ const response = await apiClient.get('/api/jobs');
+ const jobs = Array.isArray(response.data?.jobs) ? response.data.jobs : [];
+ return jobs.map(normalizeJob);
+};
+
+export const getJob = async (id: string): Promise => {
+ if (usingBrowserDb) {
+ const jobs = readLocalJobs();
+ const job = jobs.find(item => item.id === id);
+ return job || null;
+ }
+
+ const response = await apiClient.get('/api/jobs', { params: { id } });
+ if (!response.data) {
+ return null;
+ }
+ return normalizeJob(response.data);
+};
+
+const ensureUniqueName = (jobs: JobRecord[], name: string, excludeId?: string) => {
+ const existing = jobs.find(job => job.name === name && job.id !== excludeId);
+ if (existing) {
+ const error: Error & { code?: string } = new Error('Job name already exists');
+ error.code = 'P2002';
+ throw error;
+ }
+};
+
+export const upsertJob = async (payload: JobUpsertPayload): Promise => {
+ if (usingBrowserDb) {
+ const jobs = readLocalJobs();
+ const serializedConfig = serializeJobConfig(payload.job_config);
+ const now = new Date().toISOString();
+
+ if (payload.id) {
+ const index = jobs.findIndex(job => job.id === payload.id);
+ if (index === -1) {
+ throw new Error('Job not found');
+ }
+ ensureUniqueName(jobs, payload.name, payload.id);
+ const updatedJob: JobRecord = {
+ ...jobs[index],
+ name: payload.name,
+ gpu_ids: payload.gpu_ids ?? jobs[index].gpu_ids,
+ job_config: serializedConfig,
+ status: payload.status ?? jobs[index].status,
+ stop: payload.stop ?? jobs[index].stop,
+ step: payload.step ?? jobs[index].step,
+ info: payload.info ?? jobs[index].info,
+ speed_string: payload.speed_string ?? jobs[index].speed_string,
+ updated_at: now,
+ };
+ jobs[index] = updatedJob;
+ writeLocalJobs(jobs);
+ return updatedJob;
+ }
+
+ ensureUniqueName(jobs, payload.name);
+
+ const newJob: JobRecord = {
+ id: uuidv4(),
+ name: payload.name,
+ gpu_ids: payload.gpu_ids ?? '',
+ job_config: serializedConfig,
+ status: payload.status ?? 'stopped',
+ stop: payload.stop ?? false,
+ step: payload.step ?? 0,
+ info: payload.info ?? '',
+ speed_string: payload.speed_string ?? '',
+ created_at: now,
+ updated_at: now,
+ };
+
+ jobs.push(newJob);
+ writeLocalJobs(jobs);
+ return newJob;
+ }
+
+ const response = await apiClient.post('/api/jobs', {
+ id: payload.id,
+ name: payload.name,
+ gpu_ids: payload.gpu_ids,
+ job_config: payload.job_config,
+ });
+
+ return normalizeJob(response.data);
+};
+
+export const deleteJobRecord = async (id: string): Promise => {
+ if (usingBrowserDb) {
+ const jobs = readLocalJobs();
+ const index = jobs.findIndex(job => job.id === id);
+ if (index === -1) {
+ return null;
+ }
+ const [removed] = jobs.splice(index, 1);
+ writeLocalJobs(jobs);
+ return removed;
+ }
+
+ const response = await apiClient.get(`/api/jobs/${id}/delete`);
+ return normalizeJob(response.data);
+};
+
+export const clearLocalJobs = () => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ window.localStorage.removeItem(LOCAL_JOBS_STORAGE_KEY);
+};
diff --git a/ui/src/utils/storage/settingsStorage.ts b/ui/src/utils/storage/settingsStorage.ts
new file mode 100644
index 0000000000000000000000000000000000000000..21b0a53831bc817eb1c6b28b09b34dece5cd6e88
--- /dev/null
+++ b/ui/src/utils/storage/settingsStorage.ts
@@ -0,0 +1,56 @@
+import { apiClient } from '@/utils/api';
+import { usingBrowserDb } from '@/utils/env';
+import { SettingsData } from '@/types';
+
+const LOCAL_SETTINGS_STORAGE_KEY = 'aitk.browser.settings';
+
+export const DEFAULT_SETTINGS: SettingsData = {
+ HF_TOKEN: '',
+ TRAINING_FOLDER: '',
+ DATASETS_FOLDER: '',
+ HF_JOBS_NAMESPACE: '',
+ HF_JOBS_DEFAULT_HARDWARE: 'a100-large',
+};
+
+const mergeWithDefaults = (data: Partial | null | undefined): SettingsData => ({
+ ...DEFAULT_SETTINGS,
+ ...data,
+});
+
+export const loadSettings = async (): Promise => {
+ if (usingBrowserDb) {
+ if (typeof window === 'undefined') {
+ return DEFAULT_SETTINGS;
+ }
+ try {
+ const raw = window.localStorage.getItem(LOCAL_SETTINGS_STORAGE_KEY);
+ if (!raw) {
+ return DEFAULT_SETTINGS;
+ }
+ const parsed = JSON.parse(raw);
+ return mergeWithDefaults(parsed);
+ } catch (error) {
+ console.error('Failed to read settings from localStorage:', error);
+ return DEFAULT_SETTINGS;
+ }
+ }
+
+ const response = await apiClient.get('/api/settings');
+ return mergeWithDefaults(response.data);
+};
+
+export const persistSettings = async (settings: SettingsData): Promise => {
+ if (usingBrowserDb) {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ window.localStorage.setItem(LOCAL_SETTINGS_STORAGE_KEY, JSON.stringify(settings));
+ } catch (error) {
+ console.error('Failed to write settings to localStorage:', error);
+ }
+ return;
+ }
+
+ await apiClient.post('/api/settings', settings);
+};
diff --git a/ui/tailwind.config.ts b/ui/tailwind.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..433a6adec0ba685e5eb5fdec0718eac6d5396c79
--- /dev/null
+++ b/ui/tailwind.config.ts
@@ -0,0 +1,31 @@
+import type { Config } from 'tailwindcss';
+
+const config: Config = {
+ content: [
+ './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/components/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/app/**/*.{js,ts,jsx,tsx,mdx}',
+ ],
+ darkMode: 'class',
+ theme: {
+ extend: {
+ colors: {
+ gray: {
+ 950: '#0a0a0a',
+ 900: '#171717',
+ 800: '#262626',
+ 700: '#404040',
+ 600: '#525252',
+ 500: '#737373',
+ 400: '#a3a3a3',
+ 300: '#d4d4d4',
+ 200: '#e5e5e5',
+ 100: '#f5f5f5',
+ },
+ },
+ },
+ },
+ plugins: [],
+};
+
+export default config;
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..c1334095f876a408c10f2357faaced969ec090ab
--- /dev/null
+++ b/ui/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/ui/tsconfig.worker.json b/ui/tsconfig.worker.json
new file mode 100644
index 0000000000000000000000000000000000000000..6b4d9531ccaa7d736f46860d947b8af7f95c860c
--- /dev/null
+++ b/ui/tsconfig.worker.json
@@ -0,0 +1,15 @@
+{
+ // tsconfig.worker.json
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "es2020",
+ "outDir": "dist",
+ "moduleResolution": "node",
+ "types": [
+ "node"
+ ]
+ },
+ "include": [
+ "cron/**/*.ts"
+ ]
+}
\ No newline at end of file