diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 000000000..df1be23fe --- /dev/null +++ b/ui/index.html @@ -0,0 +1,13 @@ + + + + + + LeRobot UI + + + +
+ + + diff --git a/ui/launch.sh b/ui/launch.sh new file mode 100755 index 000000000..a353bfa27 --- /dev/null +++ b/ui/launch.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Launch LeRobot UI — backend (FastAPI) + frontend (Svelte/Vite) +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# ---- Python dependencies ---- +if ! python -c "import fastapi" 2>/dev/null; then + echo "Installing Python dependencies..." + pip install -r requirements.txt +fi + +# ---- Node dependencies ---- +if [ ! -d "node_modules" ]; then + echo "Installing Node dependencies..." + npm install +fi + +# ---- Start backend ---- +echo "Starting backend server on :8000 ..." +python server.py & +BACKEND_PID=$! + +# Give the backend a moment to bind its port +sleep 1 + +# ---- Start frontend ---- +echo "Starting frontend dev server on :5173 ..." +npm run dev & +FRONTEND_PID=$! + +# ---- Cleanup on exit ---- +cleanup() { + echo "" + echo "Shutting down..." + kill "$BACKEND_PID" 2>/dev/null || true + kill "$FRONTEND_PID" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +echo "" +echo "================================================" +echo " LeRobot UI → http://localhost:5173" +echo " Backend API → http://localhost:8000" +echo " Press Ctrl+C to stop" +echo "================================================" +echo "" + +wait diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 000000000..2f737c16a --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,1299 @@ +{ + "name": "lerobot-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lerobot-ui", + "version": "1.0.0", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.2.0", + "vite": "^5.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz", + "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", + "debug": "^4.3.4", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.10", + "svelte-hmr": "^0.16.0", + "vitefu": "^0.2.5" + }, + "engines": { + "node": "^18.0.0 || >=20" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz", + "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.0.0 || >=20" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/code-red": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", + "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@types/estree": "^1.0.1", + "acorn": "^8.10.0", + "estree-walker": "^3.0.3", + "periscopic": "^3.1.0" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, + "license": "CC0-1.0" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/periscopic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", + "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^3.0.0", + "is-reference": "^3.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "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==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz", + "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@jridgewell/sourcemap-codec": "^1.4.15", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/estree": "^1.0.1", + "acorn": "^8.9.0", + "aria-query": "^5.3.0", + "axobject-query": "^4.0.0", + "code-red": "^1.0.3", + "css-tree": "^2.3.1", + "estree-walker": "^3.0.3", + "is-reference": "^3.0.1", + "locate-character": "^3.0.0", + "magic-string": "^0.30.4", + "periscopic": "^3.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/svelte-hmr": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz", + "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^12.20 || ^14.13.1 || >= 16" + }, + "peerDependencies": { + "svelte": "^3.19.0 || ^4.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", + "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 000000000..59c0ae224 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,15 @@ +{ + "name": "lerobot-ui", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.2.0", + "vite": "^5.0.0" + } +} diff --git a/ui/requirements.txt b/ui/requirements.txt new file mode 100644 index 000000000..f18074c3e --- /dev/null +++ b/ui/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.27.0 +opencv-python>=4.9.0 +pydantic>=2.0.0 diff --git a/ui/server.py b/ui/server.py new file mode 100644 index 000000000..9d5376143 --- /dev/null +++ b/ui/server.py @@ -0,0 +1,647 @@ +""" +LeRobot UI — FastAPI Backend + +Manages subprocess lifecycle for lerobot CLI tools, camera streaming via +OpenCV MJPEG, hardware discovery, and a circular log buffer. +""" + +from __future__ import annotations + +import asyncio +import glob +import json +import os +import platform +import signal +import subprocess +import threading +import time +from collections import deque +from datetime import datetime +from pathlib import Path +from typing import Any + +import cv2 +import uvicorn +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +# --------------------------------------------------------------------------- +# App setup +# --------------------------------------------------------------------------- + +app = FastAPI(title="LeRobot UI Server", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# --------------------------------------------------------------------------- +# Global state +# --------------------------------------------------------------------------- + +MAX_LOG_LINES = 500 + +_state: dict[str, Any] = { + "mode": "idle", # idle | recording | teleoperation | evaluating + "message": "Ready", + "error": None, + "episode_count": 0, + "subprocess": None, # active Popen + "subprocess_lock": threading.Lock(), + "logs": deque(maxlen=MAX_LOG_LINES), + "log_lock": threading.Lock(), + "cameras": {}, # name -> {cap, thread, frame, running} + "camera_lock": threading.Lock(), + "pending_cameras": [], # list of camera dicts to restart after subprocess ends +} + + +# --------------------------------------------------------------------------- +# Logging helpers +# --------------------------------------------------------------------------- + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") + + +def _log(msg: str) -> None: + with _state["log_lock"]: + _state["logs"].append({"ts": _ts(), "msg": msg}) + + +# --------------------------------------------------------------------------- +# Camera streaming +# --------------------------------------------------------------------------- + +def _camera_reader(name: str, index_or_path: str | int) -> None: + """Background thread: reads frames from a camera and stores the latest.""" + # Attempt numeric index first, then string path + try: + src: int | str = int(index_or_path) + except (ValueError, TypeError): + src = str(index_or_path) + + cap = cv2.VideoCapture(src) + if not cap.isOpened(): + _log(f"[camera/{name}] Could not open {index_or_path}") + with _state["camera_lock"]: + if name in _state["cameras"]: + _state["cameras"][name]["running"] = False + return + + _log(f"[camera/{name}] Opened {index_or_path}") + + with _state["camera_lock"]: + if name in _state["cameras"]: + _state["cameras"][name]["cap"] = cap + + while True: + with _state["camera_lock"]: + if name not in _state["cameras"] or not _state["cameras"][name].get("running"): + break + + ret, frame = cap.read() + if not ret: + time.sleep(0.05) + continue + + _, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 75]) + with _state["camera_lock"]: + if name in _state["cameras"]: + _state["cameras"][name]["frame"] = buf.tobytes() + + cap.release() + _log(f"[camera/{name}] Stopped") + + +def _start_cameras(cameras: list[dict]) -> None: + """Start MJPEG reader threads for a list of camera dicts.""" + with _state["camera_lock"]: + # Stop any existing camera with same name + for cam in cameras: + name = cam["name"] + if name in _state["cameras"]: + _state["cameras"][name]["running"] = False + + time.sleep(0.1) # let threads notice the stop flag + + with _state["camera_lock"]: + for cam in cameras: + name = cam["name"] + entry: dict[str, Any] = { + "cap": None, + "thread": None, + "frame": None, + "running": True, + "index_or_path": cam.get("index_or_path", 0), + } + t = threading.Thread( + target=_camera_reader, + args=(name, cam.get("index_or_path", 0)), + daemon=True, + ) + entry["thread"] = t + _state["cameras"][name] = entry + t.start() + + +def _stop_all_cameras() -> None: + with _state["camera_lock"]: + for name in list(_state["cameras"].keys()): + _state["cameras"][name]["running"] = False + _log("[cameras] All camera streams stopped") + + +def _mjpeg_generator(name: str): + """Yield MJPEG frames for a given camera name.""" + while True: + frame = None + with _state["camera_lock"]: + cam = _state["cameras"].get(name) + if cam: + frame = cam.get("frame") + + if frame: + yield ( + b"--frame\r\n" + b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n" + ) + time.sleep(0.033) # ~30 fps ceiling + + +# --------------------------------------------------------------------------- +# Subprocess management +# --------------------------------------------------------------------------- + +def _read_output(proc: subprocess.Popen, label: str) -> None: + """Read stdout/stderr from a subprocess and feed into log buffer.""" + streams = [] + if proc.stdout: + streams.append(proc.stdout) + if proc.stderr: + streams.append(proc.stderr) + + import select as _select + + while True: + if proc.poll() is not None: + # Drain remaining output + for s in streams: + for line in s: + _log(f"[{label}] {line.rstrip()}") + break + + try: + readable, _, _ = _select.select(streams, [], [], 0.1) + for s in readable: + line = s.readline() + if line: + _log(f"[{label}] {line.rstrip()}") + except Exception: + break + + +def _watch_subprocess(label: str) -> None: + """Watch the active subprocess; clean up state when it exits.""" + with _state["subprocess_lock"]: + proc = _state["subprocess"] + + if proc is None: + return + + proc.wait() + rc = proc.returncode + _log(f"[{label}] Process exited with code {rc}") + + with _state["subprocess_lock"]: + if _state["subprocess"] is proc: + _state["subprocess"] = None + _state["mode"] = "idle" + _state["message"] = f"Finished (exit {rc})" + if rc not in (0, -15, -2): # not clean exit / SIGTERM / SIGINT + _state["error"] = f"Process exited with code {rc}" + + # Restart camera preview if cameras were pending + pending = _state.get("pending_cameras", []) + if pending: + _log("[cameras] Restarting camera preview after subprocess ended") + _start_cameras(pending) + + +def _launch(cmd: list[str], mode: str, label: str) -> None: + """Launch a subprocess, stop cameras first, set state.""" + with _state["subprocess_lock"]: + if _state["subprocess"] is not None and _state["subprocess"].poll() is None: + raise RuntimeError("A process is already running. Stop it first.") + + # Stop cameras so the subprocess can access them + _stop_all_cameras() + + _log(f"[server] Launching: {' '.join(cmd)}") + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + + with _state["subprocess_lock"]: + _state["subprocess"] = proc + _state["mode"] = mode + _state["message"] = f"Running {label}..." + _state["error"] = None + + # Pipe output + t_out = threading.Thread(target=_read_output, args=(proc, label), daemon=True) + t_out.start() + + # Watch for completion + t_watch = threading.Thread(target=_watch_subprocess, args=(label,), daemon=True) + t_watch.start() + + +# --------------------------------------------------------------------------- +# Hardware discovery +# --------------------------------------------------------------------------- + +def _discover_robot_types() -> list[str]: + try: + from importlib.util import find_spec + + spec = find_spec("lerobot") + if spec is None or spec.origin is None: + return [] + robots_dir = Path(spec.origin).parent / "robots" + return sorted( + d.name + for d in robots_dir.iterdir() + if d.is_dir() and not d.name.startswith("_") + ) + except Exception: + return [] + + +def _discover_teleop_types() -> list[str]: + try: + from importlib.util import find_spec + + spec = find_spec("lerobot") + if spec is None or spec.origin is None: + return [] + teleops_dir = Path(spec.origin).parent / "teleoperators" + return sorted( + d.name + for d in teleops_dir.iterdir() + if d.is_dir() and not d.name.startswith("_") + ) + except Exception: + return [] + + +def _discover_serial_ports() -> list[str]: + patterns = [ + "/dev/ttyUSB*", + "/dev/ttyACM*", + "/dev/cu.usbmodem*", + "/dev/cu.usbserial*", + ] + ports: list[str] = [] + for pat in patterns: + ports.extend(glob.glob(pat)) + return sorted(set(ports)) + + +def _discover_can_interfaces() -> list[str]: + try: + result = subprocess.run( + ["ip", "link", "show"], + capture_output=True, + text=True, + timeout=5, + ) + interfaces = [] + for line in result.stdout.splitlines(): + # Lines like: "3: can0: <...>" + parts = line.strip().split(":") + if len(parts) >= 2: + iface = parts[1].strip() + if iface.startswith("can"): + interfaces.append(iface) + return sorted(interfaces) + except Exception: + return [] + + +def _discover_cameras() -> list[dict]: + """Scan OpenCV indices 0-9 and /dev/video* paths.""" + found: list[dict] = [] + checked: set = set() + + # Numeric indices + for idx in range(10): + cap = cv2.VideoCapture(idx) + if cap.isOpened(): + found.append({"index_or_path": idx, "label": f"Camera {idx} (index {idx})"}) + checked.add(idx) + cap.release() + + # /dev/video* paths on Linux + for path in sorted(glob.glob("/dev/video*")): + cap = cv2.VideoCapture(path) + if cap.isOpened(): + # Avoid duplicate if already found by index + label = f"Camera ({path})" + found.append({"index_or_path": path, "label": label}) + cap.release() + + return found + + +# --------------------------------------------------------------------------- +# Pydantic models +# --------------------------------------------------------------------------- + +class CameraEntry(BaseModel): + name: str + type: str = "opencv" + index_or_path: str | int = 0 + width: int = 640 + height: int = 480 + fps: int = 30 + + +class PreviewRequest(BaseModel): + cameras: list[CameraEntry] + + +class RecordRequest(BaseModel): + robot_type: str + robot_port: str = "" + robot_id: str = "" + robot_cameras: list[CameraEntry] = [] + teleop_type: str + teleop_port: str = "" + teleop_id: str = "" + repo_id: str + single_task: str + num_episodes: int = 10 + fps: int = 30 + episode_time_s: int = 60 + reset_time_s: int = 5 + push_to_hub: bool = True + private: bool = False + display_data: bool = False + + +class TeleopRequest(BaseModel): + robot_type: str + robot_port: str = "" + robot_cameras: list[CameraEntry] = [] + teleop_type: str + teleop_port: str = "" + display_data: bool = False + + +class EvalRequest(BaseModel): + policy_path: str + env_type: str + n_episodes: int = 10 + batch_size: int = 1 + device: str = "cpu" + + +# --------------------------------------------------------------------------- +# Helper: build cameras JSON arg +# --------------------------------------------------------------------------- + +def _cameras_arg(cameras: list[CameraEntry]) -> str: + d: dict[str, dict] = {} + for cam in cameras: + d[cam.name] = { + "type": cam.type, + "index_or_path": cam.index_or_path, + "width": cam.width, + "height": cam.height, + "fps": cam.fps, + } + return json.dumps(d) + + +# --------------------------------------------------------------------------- +# API routes +# --------------------------------------------------------------------------- + +@app.get("/api/status") +def get_status() -> dict: + with _state["subprocess_lock"]: + proc = _state["subprocess"] + proc_running = proc is not None and proc.poll() is None + + with _state["camera_lock"]: + active_cams = [ + name + for name, cam in _state["cameras"].items() + if cam.get("running") and cam.get("frame") is not None + ] + + return { + "mode": _state["mode"], + "message": _state["message"], + "error": _state["error"], + "episode_count": _state["episode_count"], + "process_running": proc_running, + "active_cameras": active_cams, + } + + +@app.get("/api/robots") +def list_robots() -> dict: + return {"types": _discover_robot_types()} + + +@app.get("/api/teleops") +def list_teleops() -> dict: + return {"types": _discover_teleop_types()} + + +@app.get("/api/hardware/serial-ports") +def serial_ports() -> dict: + return {"ports": _discover_serial_ports()} + + +@app.get("/api/hardware/can-interfaces") +def can_interfaces() -> dict: + return {"interfaces": _discover_can_interfaces()} + + +@app.get("/api/hardware/cameras") +def hardware_cameras() -> dict: + return {"cameras": _discover_cameras()} + + +@app.post("/api/cameras/preview") +def start_preview(req: PreviewRequest) -> dict: + cams = [c.model_dump() for c in req.cameras] + _state["pending_cameras"] = cams + _start_cameras(cams) + return {"status": "ok", "started": len(cams)} + + +@app.post("/api/cameras/stop") +def stop_preview() -> dict: + _stop_all_cameras() + _state["pending_cameras"] = [] + return {"status": "ok"} + + +@app.get("/api/camera/stream/{name}") +def camera_stream(name: str): + with _state["camera_lock"]: + if name not in _state["cameras"]: + raise HTTPException(status_code=404, detail=f"Camera '{name}' not active") + return StreamingResponse( + _mjpeg_generator(name), + media_type="multipart/x-mixed-replace; boundary=frame", + ) + + +@app.post("/api/record/start") +def start_record(req: RecordRequest) -> dict: + cmd = ["lerobot-record"] + + cmd += [f"--robot.type={req.robot_type}"] + if req.robot_port: + cmd += [f"--robot.port={req.robot_port}"] + if req.robot_id: + cmd += [f"--robot.id={req.robot_id}"] + if req.robot_cameras: + cmd += [f"--robot.cameras={_cameras_arg(req.robot_cameras)}"] + + cmd += [f"--teleop.type={req.teleop_type}"] + if req.teleop_port: + cmd += [f"--teleop.port={req.teleop_port}"] + if req.teleop_id: + cmd += [f"--teleop.id={req.teleop_id}"] + + cmd += [ + f"--dataset.repo_id={req.repo_id}", + f"--dataset.single_task={req.single_task}", + f"--dataset.num_episodes={req.num_episodes}", + f"--dataset.fps={req.fps}", + f"--dataset.episode_time_s={req.episode_time_s}", + f"--dataset.reset_time_s={req.reset_time_s}", + f"--dataset.push_to_hub={'true' if req.push_to_hub else 'false'}", + f"--dataset.private={'true' if req.private else 'false'}", + f"--display_data={'true' if req.display_data else 'false'}", + ] + + _state["pending_cameras"] = [c.model_dump() for c in req.robot_cameras] + + try: + _launch(cmd, "recording", "lerobot-record") + except RuntimeError as e: + raise HTTPException(status_code=409, detail=str(e)) + + return {"status": "ok"} + + +@app.post("/api/teleop/start") +def start_teleop(req: TeleopRequest) -> dict: + cmd = ["lerobot-teleoperate"] + + cmd += [f"--robot.type={req.robot_type}"] + if req.robot_port: + cmd += [f"--robot.port={req.robot_port}"] + if req.robot_cameras: + cmd += [f"--robot.cameras={_cameras_arg(req.robot_cameras)}"] + + cmd += [f"--teleop.type={req.teleop_type}"] + if req.teleop_port: + cmd += [f"--teleop.port={req.teleop_port}"] + + cmd += [f"--display_data={'true' if req.display_data else 'false'}"] + + _state["pending_cameras"] = [c.model_dump() for c in req.robot_cameras] + + try: + _launch(cmd, "teleoperation", "lerobot-teleoperate") + except RuntimeError as e: + raise HTTPException(status_code=409, detail=str(e)) + + return {"status": "ok"} + + +@app.post("/api/eval/start") +def start_eval(req: EvalRequest) -> dict: + cmd = [ + "lerobot-eval", + f"--policy.path={req.policy_path}", + f"--env.type={req.env_type}", + f"--eval.n_episodes={req.n_episodes}", + f"--eval.batch_size={req.batch_size}", + f"--policy.device={req.device}", + ] + + try: + _launch(cmd, "evaluating", "lerobot-eval") + except RuntimeError as e: + raise HTTPException(status_code=409, detail=str(e)) + + return {"status": "ok"} + + +@app.post("/api/process/stop") +def stop_process() -> dict: + with _state["subprocess_lock"]: + proc = _state["subprocess"] + if proc is None or proc.poll() is not None: + return {"status": "no_process"} + try: + proc.send_signal(signal.SIGTERM) + except ProcessLookupError: + pass + _log("[server] Sent SIGTERM to active process") + return {"status": "ok"} + + +@app.post("/api/process/kill") +def kill_process() -> dict: + with _state["subprocess_lock"]: + proc = _state["subprocess"] + if proc is None or proc.poll() is not None: + return {"status": "no_process"} + try: + proc.send_signal(signal.SIGKILL) + except ProcessLookupError: + pass + _log("[server] Sent SIGKILL to active process") + return {"status": "ok"} + + +@app.get("/api/logs") +def get_logs() -> dict: + with _state["log_lock"]: + return {"logs": list(_state["logs"])} + + +@app.post("/api/counter/reset") +def reset_counter() -> dict: + _state["episode_count"] = 0 + return {"status": "ok"} + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + _log("[server] LeRobot UI backend starting on :8000") + uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info") diff --git a/ui/src/App.svelte b/ui/src/App.svelte new file mode 100644 index 000000000..93366e2fd --- /dev/null +++ b/ui/src/App.svelte @@ -0,0 +1,1128 @@ + + + + +
+ + +
+
+ + + {#if mode === 'recording'}{/if} + {modeLabel} + + {message} +
+
+ {#if isActive} + + + {/if} +
+
+ + +
+ + +
+ + + + + + {#if activeTab === 'setup'} +
+ + +
+
+

Robot Configuration

+
+ +
+ + + +
+ + +
+ + +
+
+

Camera Configuration

+
+ + + {discoveredCameras.length} detected + +
+
+ + + {#if discoveredCameras.length > 0} +
+ Detected: {discoveredCameras.map(c => c.label).join(', ')} +
+ {/if} + + + {#each cfg.cameras as cam, i} +
+
+ + + + + +
+ +
+
+
+ {/each} + +
+ + {#if discoveredCameras.length > 0} + + {/if} +
+ {#if cfg.cameras.length > 0} + {#if previewActive && mode === 'idle'} + + {:else if mode === 'idle'} + + {/if} + {/if} +
+
+ +
+ + + {:else if activeTab === 'record'} +
+ + +
+

Teleop Device

+ +
+ + + +
+ + +
+ + +
+

Dataset

+ + + + + +
+ + + +
+ +
+ +
+ +
+
+
Push to Hub
+
Upload dataset to HuggingFace after recording
+
+ +
+ +
+
+
Private Dataset
+
Make repository private on HuggingFace
+
+ +
+
+ + +
+ {#if mode === 'recording'} +
+ + Recording in progress — {message} +
+ + {:else} + + {/if} + + {#if !cfg.singleTask && mode === 'idle'} +

Fill in task description and repo ID to enable recording.

+ {/if} +
+ +
+ + + {:else if activeTab === 'teleop'} +
+ +
+

Teleoperation

+ + +
+
+ Robot Type + {cfg.robotType || '—'} +
+
+ Robot Port + {cfg.robotPort || '—'} +
+
+ Teleop Type + {cfg.teleopType || '—'} +
+
+ Teleop Port + {cfg.teleopPort || '—'} +
+
+ Cameras + {cfg.cameras.length ? cfg.cameras.map(c => c.name).join(', ') : 'none'} +
+
+ +

+ Configure robot and teleop device in the Setup tab before starting. +

+ + {#if mode === 'teleoperation'} +
+ + Teleoperation active — {message} +
+ + {:else} + + {/if} +
+ +
+ + + {:else if activeTab === 'evaluate'} +
+ +
+

Evaluate Policy

+ + + + + +
+ + + +
+ + {#if mode === 'evaluating'} +
+ + Evaluating — {message} +
+ + {:else} + + {/if} +
+ +
+ {/if} + + + {#if serverError} +
+ + {serverError} +
+ {/if} + +
+ + +
+
+
+

Camera Preview

+ {#if activeCameras.length > 0} + + {activeCameras.length} live + + {/if} +
+ + {#if isActive && cfg.cameras.length > 0} + +
+ {#each cfg.cameras as cam} +
+ {cam.name} { e.target.style.display = 'none'; }} + /> +
{cam.name}
+
+ {/each} +
+
+ + Cameras in use by the active robot process +
+ + {:else if previewActive && cfg.cameras.length > 0} + +
+ {#each cfg.cameras as cam} +
+ {cam.name} { e.target.style.display = 'none'; }} + /> +
{cam.name}
+
+ {/each} +
+ + {:else if cfg.cameras.length > 0} +
+ 📷 +

{cfg.cameras.length} camera{cfg.cameras.length > 1 ? 's' : ''} configured

+

Click Start Preview in Setup to view live feeds.

+
+ + {:else} +
+ 📷 +

No cameras configured

+

Add cameras in the Setup tab.

+
+ {/if} + +
+
+ +
+ + +
+ + + {/if} + + + {#if logsExpanded} +
+ {#each logs as line} +
+ {line.ts} + {line.msg} +
+ {/each} + {#if logs.length === 0} + No logs yet. + {/if} +
+ {/if} +
+ + + + +
+ + + diff --git a/ui/src/app.css b/ui/src/app.css new file mode 100644 index 000000000..7b072565c --- /dev/null +++ b/ui/src/app.css @@ -0,0 +1,504 @@ +/* ========================================================= + LeRobot UI — Global Styles (Dark Theme) + ========================================================= */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +/* ---- CSS Variables ---- */ +:root { + --bg: #0f1117; + --panel: #1a1d27; + --panel-hover: #1f2333; + --border: #30363d; + --border-light: #21262d; + --text: #e1e4e8; + --text-muted: #8b949e; + --text-faint: #484f58; + --accent: #6e56cf; + --accent-hover: #7c3aed; + --accent-dim: rgba(110, 86, 207, 0.15); + --error: #f85149; + --error-dim: rgba(248, 81, 73, 0.15); + --success: #3fb950; + --success-dim: rgba(63, 185, 80, 0.15); + --warning: #e3b341; + --warning-dim: rgba(227, 179, 65, 0.15); + --info: #58a6ff; + --info-dim: rgba(88, 166, 255, 0.15); + --red: #f85149; + --red-dim: rgba(248, 81, 73, 0.2); + --blue: #58a6ff; + --blue-dim: rgba(88, 166, 255, 0.15); + --purple: #bc8cff; + --purple-dim: rgba(188, 140, 255, 0.15); + --radius: 8px; + --radius-sm: 4px; + --radius-lg: 12px; + --shadow: 0 2px 12px rgba(0, 0, 0, 0.4); +} + +/* ---- Reset ---- */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + background: var(--bg); + color: var(--text); + font-family: 'Inter', system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +#app { + height: 100%; + display: flex; + flex-direction: column; +} + +/* ---- Scrollbar ---- */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: var(--bg); +} +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--text-faint); +} + +/* ---- Typography ---- */ +h1 { font-size: 1.25rem; font-weight: 700; } +h2 { font-size: 1rem; font-weight: 600; } +h3 { font-size: 0.875rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } + +/* ---- Cards / Panels ---- */ +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem; +} + +.panel-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-light); +} + +/* ---- Buttons ---- */ +button { + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + border: none; + border-radius: var(--radius); + padding: 0.5rem 1rem; + cursor: pointer; + transition: background 0.15s, opacity 0.15s, transform 0.1s; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +button:active:not(:disabled) { + transform: scale(0.97); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn-primary { + background: var(--accent); + color: #fff; +} +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.btn-secondary { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} +.btn-secondary:hover:not(:disabled) { + background: var(--panel-hover); +} + +.btn-danger { + background: var(--error); + color: #fff; +} +.btn-danger:hover:not(:disabled) { + background: #e03b31; +} + +.btn-ghost { + background: transparent; + color: var(--text-muted); + padding: 0.4rem 0.75rem; +} +.btn-ghost:hover:not(:disabled) { + background: var(--panel-hover); + color: var(--text); +} + +.btn-sm { + font-size: 0.75rem; + padding: 0.3rem 0.65rem; +} + +.btn-lg { + font-size: 1rem; + padding: 0.75rem 1.5rem; + border-radius: var(--radius-lg); + font-weight: 600; + width: 100%; + justify-content: center; +} + +.btn-icon { + padding: 0.4rem; + width: 2rem; + height: 2rem; + justify-content: center; +} + +/* ---- Status Badges ---- */ +.badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.6rem; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.badge-idle { background: var(--success-dim); color: var(--success); } +.badge-recording { background: var(--red-dim); color: var(--red); } +.badge-teleoperation { background: var(--blue-dim); color: var(--blue); } +.badge-evaluating { background: var(--purple-dim); color: var(--purple); } + +/* ---- Inputs ---- */ +input[type="text"], +input[type="number"], +input[type="password"], +textarea, +select { + font-family: inherit; + font-size: 0.875rem; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + width: 100%; + outline: none; + transition: border-color 0.15s; + -webkit-appearance: none; + appearance: none; +} + +input:focus, textarea:focus, select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim); +} + +input::placeholder, textarea::placeholder { + color: var(--text-faint); +} + +select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238b949e' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2rem; +} + +label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.8rem; + color: var(--text-muted); + font-weight: 500; +} + +.form-row { + display: grid; + gap: 0.75rem; +} + +.form-row-2 { grid-template-columns: 1fr 1fr; } +.form-row-3 { grid-template-columns: 1fr 1fr 1fr; } + +/* ---- Toggle / Switch ---- */ +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; +} + +.toggle-label { + font-size: 0.875rem; + color: var(--text); +} + +.toggle-sublabel { + font-size: 0.75rem; + color: var(--text-muted); +} + +.toggle { + position: relative; + width: 40px; + height: 22px; + flex-shrink: 0; +} + +.toggle input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.toggle-track { + position: absolute; + inset: 0; + background: var(--border); + border-radius: 11px; + cursor: pointer; + transition: background 0.2s; +} + +.toggle input:checked + .toggle-track { + background: var(--accent); +} + +.toggle-track::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + background: #fff; + border-radius: 50%; + top: 3px; + left: 3px; + transition: transform 0.2s; +} + +.toggle input:checked + .toggle-track::after { + transform: translateX(18px); +} + +/* ---- Section divider ---- */ +.section-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 1.25rem 0 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.section-title::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-light); +} + +/* ---- Status / Alert boxes ---- */ +.alert { + padding: 0.75rem 1rem; + border-radius: var(--radius); + font-size: 0.875rem; + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.alert-error { background: var(--error-dim); border: 1px solid var(--error); color: var(--error); } +.alert-success { background: var(--success-dim); border: 1px solid var(--success); color: var(--success); } +.alert-warning { background: var(--warning-dim); border: 1px solid var(--warning); color: var(--warning); } +.alert-info { background: var(--info-dim); border: 1px solid var(--info); color: var(--info); } + +/* ---- Recording pulse indicator ---- */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.rec-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--red); + animation: pulse 1s infinite; + flex-shrink: 0; +} + +/* ---- Spinner ---- */ +@keyframes spin { + to { transform: rotate(360deg); } +} + +.spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + +/* ---- Log terminal ---- */ +.log-terminal { + background: #0d1117; + border: 1px solid var(--border-light); + border-radius: var(--radius); + font-family: 'Menlo', 'Monaco', 'Consolas', monospace; + font-size: 0.75rem; + line-height: 1.6; + overflow-y: auto; + padding: 0.75rem; + color: #c9d1d9; +} + +.log-line { + display: flex; + gap: 0.75rem; +} + +.log-ts { + color: var(--text-faint); + flex-shrink: 0; + user-select: none; +} + +.log-msg { + white-space: pre-wrap; + word-break: break-all; +} + +/* ---- Camera grid ---- */ +.camera-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: 1fr; +} + +.camera-grid.cams-2 { grid-template-columns: 1fr 1fr; } +.camera-grid.cams-3 { grid-template-columns: 1fr 1fr; } +.camera-grid.cams-4 { grid-template-columns: 1fr 1fr; } + +.camera-feed { + background: #0d1117; + border: 1px solid var(--border-light); + border-radius: var(--radius); + overflow: hidden; + aspect-ratio: 4/3; + position: relative; +} + +.camera-feed img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.camera-label { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 0.7rem; + padding: 0.3rem 0.5rem; + backdrop-filter: blur(4px); +} + +.camera-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 3rem 1rem; + color: var(--text-faint); + text-align: center; + border: 1px dashed var(--border); + border-radius: var(--radius); + font-size: 0.875rem; +} + +/* ---- Tabs ---- */ +.tabs { + display: flex; + gap: 0.25rem; + border-bottom: 1px solid var(--border); + margin-bottom: 1.25rem; +} + +.tab-btn { + background: transparent; + color: var(--text-muted); + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + padding: 0.6rem 1rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + transform: none; +} + +.tab-btn:hover { color: var(--text); } + +.tab-btn.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* ---- Misc utilities ---- */ +.flex { display: flex; } +.flex-1 { flex: 1; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.gap-2 { gap: 0.5rem; } +.gap-3 { gap: 0.75rem; } +.mt-1 { margin-top: 0.25rem; } +.mt-2 { margin-top: 0.5rem; } +.mt-3 { margin-top: 0.75rem; } +.mt-4 { margin-top: 1rem; } +.text-muted { color: var(--text-muted); } +.text-sm { font-size: 0.8rem; } +.text-xs { font-size: 0.72rem; } +.font-mono { font-family: 'Menlo', 'Monaco', monospace; } diff --git a/ui/src/main.js b/ui/src/main.js new file mode 100644 index 000000000..d026861d9 --- /dev/null +++ b/ui/src/main.js @@ -0,0 +1,8 @@ +import './app.css'; +import App from './App.svelte'; + +const app = new App({ + target: document.getElementById('app'), +}); + +export default app; diff --git a/ui/vite.config.js b/ui/vite.config.js new file mode 100644 index 000000000..45683e7ac --- /dev/null +++ b/ui/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:8000', + }, + }, +});