diff --git a/TODO.md b/TODO.md
index e255244..1535275 100644
--- a/TODO.md
+++ b/TODO.md
@@ -99,6 +99,7 @@
- [x] Build unified HomeAI dashboard — dark-themed frontend showing live service status + links to individual UIs
- [x] Add character profile management to dashboard — store/switch character configs with attached profile images
- [x] Add TTS voice preview in character editor — Kokoro preview via OpenClaw bridge with loading state, custom text, stop control
+- [x] Merge homeai-character + homeai-desktop into unified homeai-dashboard (services, chat, characters, editor)
- [ ] Deploy dashboard as Docker container or static site on Mac Mini
---
diff --git a/homeai-agent/openclaw-http-bridge.py b/homeai-agent/openclaw-http-bridge.py
index e3cfbe1..2e1c6e9 100644
--- a/homeai-agent/openclaw-http-bridge.py
+++ b/homeai-agent/openclaw-http-bridge.py
@@ -28,10 +28,12 @@ import subprocess
import sys
import asyncio
from http.server import HTTPServer, BaseHTTPRequestHandler
+from socketserver import ThreadingMixIn
from urllib.parse import urlparse
from pathlib import Path
import wave
import io
+import re
from wyoming.client import AsyncTcpClient
from wyoming.tts import Synthesize, SynthesizeVoice
from wyoming.asr import Transcribe, Transcript
@@ -107,6 +109,13 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
return
text = data.get("text", "Hello, this is a test.")
+ # Strip emojis so TTS doesn't try to read them out
+ text = re.sub(
+ r'[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF'
+ r'\U0001F1E0-\U0001F1FF\U0001F900-\U0001F9FF\U0001FA00-\U0001FAFF'
+ r'\U00002702-\U000027B0\U0000FE00-\U0000FE0F\U0000200D'
+ r'\U00002600-\U000026FF\U00002300-\U000023FF]+', '', text
+ ).strip()
voice = data.get("voice", "af_heart")
try:
@@ -316,6 +325,10 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
self._send_json_response(404, {"error": "Not found"})
+class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
+ daemon_threads = True
+
+
def main():
"""Run the HTTP bridge server."""
parser = argparse.ArgumentParser(description="OpenClaw HTTP Bridge")
@@ -332,8 +345,8 @@ def main():
)
args = parser.parse_args()
- HTTPServer.allow_reuse_address = True
- server = HTTPServer((args.host, args.port), OpenClawBridgeHandler)
+ ThreadingHTTPServer.allow_reuse_address = True
+ server = ThreadingHTTPServer((args.host, args.port), OpenClawBridgeHandler)
print(f"OpenClaw HTTP Bridge running on http://{args.host}:{args.port}")
print(f"Endpoint: POST http://{args.host}:{args.port}/api/agent/message")
print("Press Ctrl+C to stop")
diff --git a/homeai-character/launchd/com.homeai.character-dashboard.plist b/homeai-character/launchd/com.homeai.character-dashboard.plist
new file mode 100644
index 0000000..dae452e
--- /dev/null
+++ b/homeai-character/launchd/com.homeai.character-dashboard.plist
@@ -0,0 +1,38 @@
+
+
+
+
+ Label
+ com.homeai.character-dashboard
+
+ ProgramArguments
+
+ /opt/homebrew/bin/npx
+ vite
+ --host
+ --port
+ 5173
+
+
+ WorkingDirectory
+ /Users/aodhan/gitea/homeai/homeai-character
+
+ EnvironmentVariables
+
+ PATH
+ /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
+ HOME
+ /Users/aodhan
+
+
+ RunAtLoad
+
+ KeepAlive
+
+
+ StandardOutPath
+ /tmp/homeai-character-dashboard.log
+ StandardErrorPath
+ /tmp/homeai-character-dashboard-error.log
+
+
diff --git a/homeai-dashboard/.gitignore b/homeai-dashboard/.gitignore
new file mode 100644
index 0000000..b947077
--- /dev/null
+++ b/homeai-dashboard/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/homeai-dashboard/characters/aria.json b/homeai-dashboard/characters/aria.json
new file mode 100644
index 0000000..adf6d7a
--- /dev/null
+++ b/homeai-dashboard/characters/aria.json
@@ -0,0 +1,49 @@
+{
+ "schema_version": 1,
+ "name": "aria",
+ "display_name": "Aria",
+ "description": "Default HomeAI assistant persona",
+ "system_prompt": "You are Aria, a warm, curious, and helpful AI assistant living in the home. You speak naturally and conversationally — never robotic. You are knowledgeable but never condescending. You remember the people you live with and build on those memories over time. Keep responses concise when controlling smart home devices; be more expressive in casual conversation. Never break character.",
+ "model_overrides": {
+ "primary": "llama3.3:70b",
+ "fast": "qwen2.5:7b"
+ },
+ "tts": {
+ "engine": "chatterbox",
+ "voice_ref_path": "~/voices/aria-raw.wav",
+ "kokoro_voice": "af_heart",
+ "speed": 1.0
+ },
+ "live2d_expressions": {
+ "idle": "expr_idle",
+ "listening": "expr_listening",
+ "thinking": "expr_thinking",
+ "speaking": "expr_speaking",
+ "happy": "expr_happy",
+ "sad": "expr_sad",
+ "surprised": "expr_surprised",
+ "error": "expr_error"
+ },
+ "vtube_ws_triggers": {
+ "thinking": {
+ "type": "hotkey",
+ "id": "expr_thinking"
+ },
+ "speaking": {
+ "type": "hotkey",
+ "id": "expr_speaking"
+ },
+ "idle": {
+ "type": "hotkey",
+ "id": "expr_idle"
+ }
+ },
+ "custom_rules": [
+ {
+ "trigger": "good morning",
+ "response": "Good morning! How did you sleep?",
+ "condition": "time_of_day == morning"
+ }
+ ],
+ "notes": "Default persona. Voice clone to be added once reference audio recorded."
+}
\ No newline at end of file
diff --git a/homeai-dashboard/index.html b/homeai-dashboard/index.html
new file mode 100644
index 0000000..4d032af
--- /dev/null
+++ b/homeai-dashboard/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+ HomeAI Dashboard
+
+
+
+
+
+
diff --git a/homeai-dashboard/launchd/com.homeai.dashboard.plist b/homeai-dashboard/launchd/com.homeai.dashboard.plist
new file mode 100644
index 0000000..7235ef9
--- /dev/null
+++ b/homeai-dashboard/launchd/com.homeai.dashboard.plist
@@ -0,0 +1,41 @@
+
+
+
+
+ Label
+ com.homeai.dashboard
+
+ ProgramArguments
+
+ /opt/homebrew/bin/npx
+ vite
+ --host
+ --port
+ 5173
+
+
+ WorkingDirectory
+ /Users/aodhan/gitea/homeai/homeai-dashboard
+
+ EnvironmentVariables
+
+ PATH
+ /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
+ HOME
+ /Users/aodhan
+
+
+ RunAtLoad
+
+
+ KeepAlive
+
+
+ StandardOutPath
+ /tmp/homeai-dashboard.log
+
+ StandardErrorPath
+ /tmp/homeai-dashboard-error.log
+
+
diff --git a/homeai-dashboard/package-lock.json b/homeai-dashboard/package-lock.json
new file mode 100644
index 0000000..417d4ab
--- /dev/null
+++ b/homeai-dashboard/package-lock.json
@@ -0,0 +1,2229 @@
+{
+ "name": "homeai-dashboard",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "homeai-dashboard",
+ "version": "0.1.0",
+ "dependencies": {
+ "@tailwindcss/vite": "^4.2.1",
+ "ajv": "^8.18.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-router-dom": "^7.13.1",
+ "tailwindcss": "^4.2.1"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^5.1.1",
+ "vite": "^8.0.0-beta.13"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "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==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
+ "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.0",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
+ "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
+ "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@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==",
+ "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==",
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
+ "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@oxc-project/runtime": {
+ "version": "0.115.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
+ "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.115.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
+ "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
+ "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
+ "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
+ "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
+ "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
+ "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
+ "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
+ "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
+ "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
+ "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
+ "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
+ "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.19.0",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.31.1",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.2.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
+ "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.2.1",
+ "@tailwindcss/oxide-darwin-arm64": "4.2.1",
+ "@tailwindcss/oxide-darwin-x64": "4.2.1",
+ "@tailwindcss/oxide-freebsd-x64": "4.2.1",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.1",
+ "@tailwindcss/oxide-wasm32-wasi": "4.2.1",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
+ "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
+ "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
+ "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
+ "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
+ "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
+ "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
+ "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
+ "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
+ "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
+ "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.8.1",
+ "@emnapi/runtime": "^1.8.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.1",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
+ "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
+ "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz",
+ "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.2.1",
+ "@tailwindcss/oxide": "4.2.1",
+ "tailwindcss": "4.2.1"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.7",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz",
+ "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001778",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz",
+ "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==",
+ "dev": true,
+ "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"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "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/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.313",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
+ "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.20.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
+ "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "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,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "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,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "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/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "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==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
+ "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.31.1",
+ "lightningcss-darwin-arm64": "1.31.1",
+ "lightningcss-darwin-x64": "1.31.1",
+ "lightningcss-freebsd-x64": "1.31.1",
+ "lightningcss-linux-arm-gnueabihf": "1.31.1",
+ "lightningcss-linux-arm64-gnu": "1.31.1",
+ "lightningcss-linux-arm64-musl": "1.31.1",
+ "lightningcss-linux-x64-gnu": "1.31.1",
+ "lightningcss-linux-x64-musl": "1.31.1",
+ "lightningcss-win32-arm64-msvc": "1.31.1",
+ "lightningcss-win32-x64-msvc": "1.31.1"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
+ "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
+ "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
+ "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
+ "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
+ "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
+ "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
+ "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
+ "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
+ "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
+ "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
+ "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "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==",
+ "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/node-releases": {
+ "version": "2.0.36",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "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/react": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.4"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.13.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
+ "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.13.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
+ "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.13.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
+ "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.115.0",
+ "@rolldown/pluginutils": "1.0.0-rc.9"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.9",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.9",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
+ }
+ },
+ "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
+ "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
+ "license": "MIT"
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "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==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
+ "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "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==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
+ "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/runtime": "0.115.0",
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.8",
+ "rolldown": "1.0.0-rc.9",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.0.0-alpha.31",
+ "esbuild": "^0.27.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/homeai-dashboard/package.json b/homeai-dashboard/package.json
new file mode 100644
index 0000000..3b53577
--- /dev/null
+++ b/homeai-dashboard/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "homeai-dashboard",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@tailwindcss/vite": "^4.2.1",
+ "ajv": "^8.18.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-router-dom": "^7.13.1",
+ "tailwindcss": "^4.2.1"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^5.1.1",
+ "vite": "^8.0.0-beta.13"
+ },
+ "overrides": {
+ "vite": "^8.0.0-beta.13"
+ }
+}
diff --git a/homeai-dashboard/public/icon.svg b/homeai-dashboard/public/icon.svg
new file mode 100644
index 0000000..336118c
--- /dev/null
+++ b/homeai-dashboard/public/icon.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/homeai-dashboard/public/manifest.json b/homeai-dashboard/public/manifest.json
new file mode 100644
index 0000000..6972040
--- /dev/null
+++ b/homeai-dashboard/public/manifest.json
@@ -0,0 +1,16 @@
+{
+ "name": "HomeAI Dashboard",
+ "short_name": "HomeAI",
+ "description": "HomeAI dashboard — services, chat, and character management",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#030712",
+ "theme_color": "#030712",
+ "icons": [
+ {
+ "src": "/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml"
+ }
+ ]
+}
diff --git a/homeai-dashboard/schema/character.schema.json b/homeai-dashboard/schema/character.schema.json
new file mode 100644
index 0000000..bd524dc
--- /dev/null
+++ b/homeai-dashboard/schema/character.schema.json
@@ -0,0 +1,82 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "HomeAI Character Config",
+ "version": "1",
+ "type": "object",
+ "required": ["schema_version", "name", "system_prompt", "tts"],
+ "properties": {
+ "schema_version": { "type": "integer", "const": 1 },
+ "name": { "type": "string" },
+ "display_name": { "type": "string" },
+ "description": { "type": "string" },
+
+ "system_prompt": { "type": "string" },
+
+ "model_overrides": {
+ "type": "object",
+ "properties": {
+ "primary": { "type": "string" },
+ "fast": { "type": "string" }
+ }
+ },
+
+ "tts": {
+ "type": "object",
+ "required": ["engine"],
+ "properties": {
+ "engine": {
+ "type": "string",
+ "enum": ["kokoro", "chatterbox", "qwen3", "elevenlabs"]
+ },
+ "voice_ref_path": { "type": "string" },
+ "kokoro_voice": { "type": "string" },
+ "elevenlabs_voice_id": { "type": "string" },
+ "elevenlabs_model": { "type": "string", "default": "eleven_monolingual_v1" },
+ "speed": { "type": "number", "default": 1.0 }
+ }
+ },
+
+ "live2d_expressions": {
+ "type": "object",
+ "description": "Maps semantic state to VTube Studio hotkey ID",
+ "properties": {
+ "idle": { "type": "string" },
+ "listening": { "type": "string" },
+ "thinking": { "type": "string" },
+ "speaking": { "type": "string" },
+ "happy": { "type": "string" },
+ "sad": { "type": "string" },
+ "surprised": { "type": "string" },
+ "error": { "type": "string" }
+ }
+ },
+
+ "vtube_ws_triggers": {
+ "type": "object",
+ "description": "VTube Studio WebSocket actions keyed by event name",
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "type": { "type": "string", "enum": ["hotkey", "parameter"] },
+ "id": { "type": "string" },
+ "value": { "type": "number" }
+ }
+ }
+ },
+
+ "custom_rules": {
+ "type": "array",
+ "description": "Trigger/response overrides for specific contexts",
+ "items": {
+ "type": "object",
+ "properties": {
+ "trigger": { "type": "string" },
+ "response": { "type": "string" },
+ "condition": { "type": "string" }
+ }
+ }
+ },
+
+ "notes": { "type": "string" }
+ }
+}
\ No newline at end of file
diff --git a/homeai-dashboard/src/App.jsx b/homeai-dashboard/src/App.jsx
new file mode 100644
index 0000000..b2694b5
--- /dev/null
+++ b/homeai-dashboard/src/App.jsx
@@ -0,0 +1,123 @@
+import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom';
+import Dashboard from './pages/Dashboard';
+import Chat from './pages/Chat';
+import Characters from './pages/Characters';
+import Editor from './pages/Editor';
+
+function NavItem({ to, children, icon }) {
+ return (
+
+ `flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
+ isActive
+ ? 'bg-gray-800 text-white'
+ : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/50'
+ }`
+ }
+ >
+ {icon}
+ {children}
+
+ );
+}
+
+function Layout({ children }) {
+ return (
+
+ {/* Sidebar */}
+
+ {/* Logo */}
+
+
+ {/* Nav */}
+
+
+
+
+ }
+ >
+ Dashboard
+
+
+
+
+
+ }
+ >
+ Chat
+
+
+
+
+
+ }
+ >
+ Characters
+
+
+
+
+
+
+ }
+ >
+ Editor
+
+
+
+ {/* Footer */}
+
+
HomeAI v0.1.0
+
Mac Mini M4 Pro
+
+
+
+ {/* Main content */}
+
+ {children}
+
+
+ );
+}
+
+function App() {
+ return (
+
+
+
+
} />
+ } />
+
} />
+
} />
+
+
+
+ );
+}
+
+export default App;
diff --git a/homeai-dashboard/src/components/ChatPanel.jsx b/homeai-dashboard/src/components/ChatPanel.jsx
new file mode 100644
index 0000000..47e8994
--- /dev/null
+++ b/homeai-dashboard/src/components/ChatPanel.jsx
@@ -0,0 +1,35 @@
+import { useEffect, useRef } from 'react'
+import MessageBubble from './MessageBubble'
+import ThinkingIndicator from './ThinkingIndicator'
+
+export default function ChatPanel({ messages, isLoading, onReplay }) {
+ const bottomRef = useRef(null)
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [messages, isLoading])
+
+ if (messages.length === 0 && !isLoading) {
+ return (
+
+
+
+ AI
+
+
Hi, I'm Aria
+
Type a message or press the mic to talk
+
+
+ )
+ }
+
+ return (
+
+ {messages.map((msg) => (
+
+ ))}
+ {isLoading &&
}
+
+
+ )
+}
diff --git a/homeai-dashboard/src/components/InputBar.jsx b/homeai-dashboard/src/components/InputBar.jsx
new file mode 100644
index 0000000..0ec437f
--- /dev/null
+++ b/homeai-dashboard/src/components/InputBar.jsx
@@ -0,0 +1,53 @@
+import { useState, useRef } from 'react'
+import VoiceButton from './VoiceButton'
+
+export default function InputBar({ onSend, onVoiceToggle, isLoading, isRecording, isTranscribing }) {
+ const [text, setText] = useState('')
+ const inputRef = useRef(null)
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ if (!text.trim() || isLoading) return
+ onSend(text)
+ setText('')
+ }
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSubmit(e)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/homeai-dashboard/src/components/MessageBubble.jsx b/homeai-dashboard/src/components/MessageBubble.jsx
new file mode 100644
index 0000000..fb2af38
--- /dev/null
+++ b/homeai-dashboard/src/components/MessageBubble.jsx
@@ -0,0 +1,39 @@
+export default function MessageBubble({ message, onReplay }) {
+ const isUser = message.role === 'user'
+
+ return (
+
+
+ {!isUser && (
+
+ AI
+
+ )}
+
+
+ {message.content}
+
+ {!isUser && !message.isError && onReplay && (
+
onReplay(message.content)}
+ className="mt-1 ml-1 text-gray-500 hover:text-indigo-400 transition-colors"
+ title="Replay audio"
+ >
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/homeai-dashboard/src/components/SettingsDrawer.jsx b/homeai-dashboard/src/components/SettingsDrawer.jsx
new file mode 100644
index 0000000..23fd98e
--- /dev/null
+++ b/homeai-dashboard/src/components/SettingsDrawer.jsx
@@ -0,0 +1,74 @@
+import { VOICES } from '../lib/constants'
+
+export default function SettingsDrawer({ isOpen, onClose, settings, onUpdate }) {
+ if (!isOpen) return null
+
+ return (
+ <>
+
+
+
+
+ {/* Voice */}
+
+ Voice
+ onUpdate('voice', e.target.value)}
+ className="w-full bg-gray-800 text-gray-200 text-sm rounded-lg px-3 py-2 border border-gray-700 focus:outline-none focus:border-indigo-500"
+ >
+ {VOICES.map((v) => (
+ {v.label}
+ ))}
+
+
+
+ {/* Auto TTS */}
+
+
+
Auto-speak responses
+
Speak assistant replies aloud
+
+
onUpdate('autoTts', !settings.autoTts)}
+ className={`relative w-10 h-6 rounded-full transition-colors ${
+ settings.autoTts ? 'bg-indigo-600' : 'bg-gray-700'
+ }`}
+ >
+
+
+
+
+ {/* STT Mode */}
+
+
Speech recognition
+
onUpdate('sttMode', e.target.value)}
+ className="w-full bg-gray-800 text-gray-200 text-sm rounded-lg px-3 py-2 border border-gray-700 focus:outline-none focus:border-indigo-500"
+ >
+ Wyoming STT (local)
+ Web Speech API (browser)
+
+
+ {settings.sttMode === 'bridge'
+ ? 'Uses Whisper via the local bridge server'
+ : 'Uses browser built-in speech recognition'}
+
+
+
+
+ >
+ )
+}
diff --git a/homeai-dashboard/src/components/StatusIndicator.jsx b/homeai-dashboard/src/components/StatusIndicator.jsx
new file mode 100644
index 0000000..a73d5f9
--- /dev/null
+++ b/homeai-dashboard/src/components/StatusIndicator.jsx
@@ -0,0 +1,11 @@
+export default function StatusIndicator({ isOnline }) {
+ if (isOnline === null) {
+ return
+ }
+ return (
+
+ )
+}
diff --git a/homeai-dashboard/src/components/ThinkingIndicator.jsx b/homeai-dashboard/src/components/ThinkingIndicator.jsx
new file mode 100644
index 0000000..852e44b
--- /dev/null
+++ b/homeai-dashboard/src/components/ThinkingIndicator.jsx
@@ -0,0 +1,14 @@
+export default function ThinkingIndicator() {
+ return (
+
+ )
+}
diff --git a/homeai-dashboard/src/components/VoiceButton.jsx b/homeai-dashboard/src/components/VoiceButton.jsx
new file mode 100644
index 0000000..508b83e
--- /dev/null
+++ b/homeai-dashboard/src/components/VoiceButton.jsx
@@ -0,0 +1,32 @@
+export default function VoiceButton({ isRecording, isTranscribing, onToggle, disabled }) {
+ const handleClick = () => {
+ if (disabled || isTranscribing) return
+ onToggle()
+ }
+
+ return (
+
+ {isTranscribing ? (
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
diff --git a/homeai-dashboard/src/hooks/useBridgeHealth.js b/homeai-dashboard/src/hooks/useBridgeHealth.js
new file mode 100644
index 0000000..73228a8
--- /dev/null
+++ b/homeai-dashboard/src/hooks/useBridgeHealth.js
@@ -0,0 +1,18 @@
+import { useState, useEffect, useRef } from 'react'
+import { healthCheck } from '../lib/api'
+
+export function useBridgeHealth() {
+ const [isOnline, setIsOnline] = useState(null)
+ const intervalRef = useRef(null)
+
+ useEffect(() => {
+ const check = async () => {
+ setIsOnline(await healthCheck())
+ }
+ check()
+ intervalRef.current = setInterval(check, 15000)
+ return () => clearInterval(intervalRef.current)
+ }, [])
+
+ return isOnline
+}
diff --git a/homeai-dashboard/src/hooks/useChat.js b/homeai-dashboard/src/hooks/useChat.js
new file mode 100644
index 0000000..c015cb5
--- /dev/null
+++ b/homeai-dashboard/src/hooks/useChat.js
@@ -0,0 +1,45 @@
+import { useState, useCallback } from 'react'
+import { sendMessage } from '../lib/api'
+
+export function useChat() {
+ const [messages, setMessages] = useState([])
+ const [isLoading, setIsLoading] = useState(false)
+
+ const send = useCallback(async (text) => {
+ if (!text.trim() || isLoading) return null
+
+ const userMsg = { id: Date.now(), role: 'user', content: text.trim(), timestamp: new Date() }
+ setMessages((prev) => [...prev, userMsg])
+ setIsLoading(true)
+
+ try {
+ const response = await sendMessage(text.trim())
+ const assistantMsg = {
+ id: Date.now() + 1,
+ role: 'assistant',
+ content: response,
+ timestamp: new Date(),
+ }
+ setMessages((prev) => [...prev, assistantMsg])
+ return response
+ } catch (err) {
+ const errorMsg = {
+ id: Date.now() + 1,
+ role: 'assistant',
+ content: `Error: ${err.message}`,
+ timestamp: new Date(),
+ isError: true,
+ }
+ setMessages((prev) => [...prev, errorMsg])
+ return null
+ } finally {
+ setIsLoading(false)
+ }
+ }, [isLoading])
+
+ const clearHistory = useCallback(() => {
+ setMessages([])
+ }, [])
+
+ return { messages, isLoading, send, clearHistory }
+}
diff --git a/homeai-dashboard/src/hooks/useSettings.js b/homeai-dashboard/src/hooks/useSettings.js
new file mode 100644
index 0000000..78dc59e
--- /dev/null
+++ b/homeai-dashboard/src/hooks/useSettings.js
@@ -0,0 +1,27 @@
+import { useState, useCallback } from 'react'
+import { DEFAULT_SETTINGS } from '../lib/constants'
+
+const STORAGE_KEY = 'homeai_dashboard_settings'
+
+function loadSettings() {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY)
+ return stored ? { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } : { ...DEFAULT_SETTINGS }
+ } catch {
+ return { ...DEFAULT_SETTINGS }
+ }
+}
+
+export function useSettings() {
+ const [settings, setSettings] = useState(loadSettings)
+
+ const updateSetting = useCallback((key, value) => {
+ setSettings((prev) => {
+ const next = { ...prev, [key]: value }
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
+ return next
+ })
+ }, [])
+
+ return { settings, updateSetting }
+}
diff --git a/homeai-dashboard/src/hooks/useTtsPlayback.js b/homeai-dashboard/src/hooks/useTtsPlayback.js
new file mode 100644
index 0000000..52d325d
--- /dev/null
+++ b/homeai-dashboard/src/hooks/useTtsPlayback.js
@@ -0,0 +1,56 @@
+import { useState, useRef, useCallback } from 'react'
+import { synthesize } from '../lib/api'
+
+export function useTtsPlayback(voice) {
+ const [isPlaying, setIsPlaying] = useState(false)
+ const audioCtxRef = useRef(null)
+ const sourceRef = useRef(null)
+
+ const getAudioContext = () => {
+ if (!audioCtxRef.current || audioCtxRef.current.state === 'closed') {
+ audioCtxRef.current = new AudioContext()
+ }
+ return audioCtxRef.current
+ }
+
+ const speak = useCallback(async (text) => {
+ if (!text) return
+
+ // Stop any current playback
+ if (sourceRef.current) {
+ try { sourceRef.current.stop() } catch {}
+ }
+
+ setIsPlaying(true)
+ try {
+ const audioData = await synthesize(text, voice)
+ const ctx = getAudioContext()
+ if (ctx.state === 'suspended') await ctx.resume()
+
+ const audioBuffer = await ctx.decodeAudioData(audioData)
+ const source = ctx.createBufferSource()
+ source.buffer = audioBuffer
+ source.connect(ctx.destination)
+ sourceRef.current = source
+
+ source.onended = () => {
+ setIsPlaying(false)
+ sourceRef.current = null
+ }
+ source.start()
+ } catch (err) {
+ console.error('TTS playback error:', err)
+ setIsPlaying(false)
+ }
+ }, [voice])
+
+ const stop = useCallback(() => {
+ if (sourceRef.current) {
+ try { sourceRef.current.stop() } catch {}
+ sourceRef.current = null
+ }
+ setIsPlaying(false)
+ }, [])
+
+ return { isPlaying, speak, stop }
+}
diff --git a/homeai-dashboard/src/hooks/useVoiceInput.js b/homeai-dashboard/src/hooks/useVoiceInput.js
new file mode 100644
index 0000000..225f234
--- /dev/null
+++ b/homeai-dashboard/src/hooks/useVoiceInput.js
@@ -0,0 +1,91 @@
+import { useState, useRef, useCallback } from 'react'
+import { createRecorder } from '../lib/audio'
+import { transcribe } from '../lib/api'
+
+export function useVoiceInput(sttMode = 'bridge') {
+ const [isRecording, setIsRecording] = useState(false)
+ const [isTranscribing, setIsTranscribing] = useState(false)
+ const recorderRef = useRef(null)
+ const webSpeechRef = useRef(null)
+
+ const startRecording = useCallback(async () => {
+ if (isRecording) return
+
+ if (sttMode === 'webspeech' && 'webkitSpeechRecognition' in window) {
+ return startWebSpeech()
+ }
+
+ try {
+ const recorder = createRecorder()
+ recorderRef.current = recorder
+ await recorder.start()
+ setIsRecording(true)
+ } catch (err) {
+ console.error('Mic access error:', err)
+ }
+ }, [isRecording, sttMode])
+
+ const stopRecording = useCallback(async () => {
+ if (!isRecording) return null
+
+ if (sttMode === 'webspeech' && webSpeechRef.current) {
+ return stopWebSpeech()
+ }
+
+ setIsRecording(false)
+ setIsTranscribing(true)
+
+ try {
+ const wavBlob = await recorderRef.current.stop()
+ recorderRef.current = null
+ const text = await transcribe(wavBlob)
+ return text
+ } catch (err) {
+ console.error('Transcription error:', err)
+ return null
+ } finally {
+ setIsTranscribing(false)
+ }
+ }, [isRecording, sttMode])
+
+ function startWebSpeech() {
+ return new Promise((resolve) => {
+ const SpeechRecognition = window.webkitSpeechRecognition || window.SpeechRecognition
+ const recognition = new SpeechRecognition()
+ recognition.continuous = false
+ recognition.interimResults = false
+ recognition.lang = 'en-US'
+ webSpeechRef.current = { recognition, resolve: null }
+ recognition.start()
+ setIsRecording(true)
+ resolve()
+ })
+ }
+
+ function stopWebSpeech() {
+ return new Promise((resolve) => {
+ const { recognition } = webSpeechRef.current
+ recognition.onresult = (e) => {
+ const text = e.results[0]?.[0]?.transcript || ''
+ setIsRecording(false)
+ webSpeechRef.current = null
+ resolve(text)
+ }
+ recognition.onerror = () => {
+ setIsRecording(false)
+ webSpeechRef.current = null
+ resolve(null)
+ }
+ recognition.onend = () => {
+ setIsRecording(false)
+ if (webSpeechRef.current) {
+ webSpeechRef.current = null
+ resolve(null)
+ }
+ }
+ recognition.stop()
+ })
+ }
+
+ return { isRecording, isTranscribing, startRecording, stopRecording }
+}
diff --git a/homeai-dashboard/src/index.css b/homeai-dashboard/src/index.css
new file mode 100644
index 0000000..ab4623a
--- /dev/null
+++ b/homeai-dashboard/src/index.css
@@ -0,0 +1,35 @@
+@import "tailwindcss";
+
+body {
+ margin: 0;
+ background-color: #030712;
+ color: #f3f4f6;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+#root {
+ min-height: 100vh;
+}
+
+/* Scrollbar styling for dark theme */
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #0a0a0f;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #374151;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #4b5563;
+}
+
+::selection {
+ background: rgba(99, 102, 241, 0.3);
+}
diff --git a/homeai-dashboard/src/lib/SchemaValidator.js b/homeai-dashboard/src/lib/SchemaValidator.js
new file mode 100644
index 0000000..c28b1fb
--- /dev/null
+++ b/homeai-dashboard/src/lib/SchemaValidator.js
@@ -0,0 +1,13 @@
+import Ajv from 'ajv'
+import schema from '../../schema/character.schema.json'
+
+const ajv = new Ajv({ allErrors: true, strict: false })
+const validate = ajv.compile(schema)
+
+export function validateCharacter(config) {
+ const valid = validate(config)
+ if (!valid) {
+ throw new Error(ajv.errorsText(validate.errors))
+ }
+ return true
+}
diff --git a/homeai-dashboard/src/lib/api.js b/homeai-dashboard/src/lib/api.js
new file mode 100644
index 0000000..63daa68
--- /dev/null
+++ b/homeai-dashboard/src/lib/api.js
@@ -0,0 +1,44 @@
+export async function sendMessage(text) {
+ const res = await fetch('/api/agent/message', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message: text, agent: 'main' }),
+ })
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: 'Request failed' }))
+ throw new Error(err.error || `HTTP ${res.status}`)
+ }
+ const data = await res.json()
+ return data.response
+}
+
+export async function synthesize(text, voice) {
+ const res = await fetch('/api/tts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text, voice }),
+ })
+ if (!res.ok) throw new Error('TTS failed')
+ return await res.arrayBuffer()
+}
+
+export async function transcribe(wavBlob) {
+ const res = await fetch('/api/stt', {
+ method: 'POST',
+ headers: { 'Content-Type': 'audio/wav' },
+ body: wavBlob,
+ })
+ if (!res.ok) throw new Error('STT failed')
+ const data = await res.json()
+ return data.text
+}
+
+export async function healthCheck() {
+ try {
+ const res = await fetch('/api/health?url=' + encodeURIComponent('http://localhost:8081/'), { signal: AbortSignal.timeout(5000) })
+ const data = await res.json()
+ return data.status === 'online'
+ } catch {
+ return false
+ }
+}
diff --git a/homeai-dashboard/src/lib/audio.js b/homeai-dashboard/src/lib/audio.js
new file mode 100644
index 0000000..fc0dea2
--- /dev/null
+++ b/homeai-dashboard/src/lib/audio.js
@@ -0,0 +1,92 @@
+const TARGET_RATE = 16000
+
+export function createRecorder() {
+ let audioCtx
+ let source
+ let processor
+ let stream
+ let samples = []
+
+ async function start() {
+ samples = []
+ stream = await navigator.mediaDevices.getUserMedia({
+ audio: { channelCount: 1, sampleRate: TARGET_RATE },
+ })
+ audioCtx = new AudioContext({ sampleRate: TARGET_RATE })
+ source = audioCtx.createMediaStreamSource(stream)
+
+ processor = audioCtx.createScriptProcessor(4096, 1, 1)
+ processor.onaudioprocess = (e) => {
+ const input = e.inputBuffer.getChannelData(0)
+ samples.push(new Float32Array(input))
+ }
+ source.connect(processor)
+ processor.connect(audioCtx.destination)
+ }
+
+ async function stop() {
+ processor.disconnect()
+ source.disconnect()
+ stream.getTracks().forEach((t) => t.stop())
+ await audioCtx.close()
+
+ const totalLength = samples.reduce((acc, s) => acc + s.length, 0)
+ const merged = new Float32Array(totalLength)
+ let offset = 0
+ for (const chunk of samples) {
+ merged.set(chunk, offset)
+ offset += chunk.length
+ }
+
+ const resampled = audioCtx.sampleRate !== TARGET_RATE
+ ? resample(merged, audioCtx.sampleRate, TARGET_RATE)
+ : merged
+
+ return encodeWav(resampled, TARGET_RATE)
+ }
+
+ return { start, stop }
+}
+
+function resample(samples, fromRate, toRate) {
+ const ratio = fromRate / toRate
+ const newLength = Math.round(samples.length / ratio)
+ const result = new Float32Array(newLength)
+ for (let i = 0; i < newLength; i++) {
+ result[i] = samples[Math.round(i * ratio)]
+ }
+ return result
+}
+
+function encodeWav(samples, sampleRate) {
+ const numSamples = samples.length
+ const buffer = new ArrayBuffer(44 + numSamples * 2)
+ const view = new DataView(buffer)
+
+ writeString(view, 0, 'RIFF')
+ view.setUint32(4, 36 + numSamples * 2, true)
+ writeString(view, 8, 'WAVE')
+ writeString(view, 12, 'fmt ')
+ view.setUint32(16, 16, true)
+ view.setUint16(20, 1, true)
+ view.setUint16(22, 1, true)
+ view.setUint32(24, sampleRate, true)
+ view.setUint32(28, sampleRate * 2, true)
+ view.setUint16(32, 2, true)
+ view.setUint16(34, 16, true)
+ writeString(view, 36, 'data')
+ view.setUint32(40, numSamples * 2, true)
+
+ for (let i = 0; i < numSamples; i++) {
+ const s = Math.max(-1, Math.min(1, samples[i]))
+ view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true)
+ }
+
+ return new Blob([buffer], { type: 'audio/wav' })
+}
+
+function writeString(view, offset, str) {
+ for (let i = 0; i < str.length; i++) {
+ view.setUint8(offset + i, str.charCodeAt(i))
+ }
+}
diff --git a/homeai-dashboard/src/lib/constants.js b/homeai-dashboard/src/lib/constants.js
new file mode 100644
index 0000000..4851015
--- /dev/null
+++ b/homeai-dashboard/src/lib/constants.js
@@ -0,0 +1,37 @@
+export const DEFAULT_VOICE = 'af_heart'
+
+export const VOICES = [
+ { id: 'af_heart', label: 'Heart (F, US)' },
+ { id: 'af_alloy', label: 'Alloy (F, US)' },
+ { id: 'af_aoede', label: 'Aoede (F, US)' },
+ { id: 'af_bella', label: 'Bella (F, US)' },
+ { id: 'af_jessica', label: 'Jessica (F, US)' },
+ { id: 'af_kore', label: 'Kore (F, US)' },
+ { id: 'af_nicole', label: 'Nicole (F, US)' },
+ { id: 'af_nova', label: 'Nova (F, US)' },
+ { id: 'af_river', label: 'River (F, US)' },
+ { id: 'af_sarah', label: 'Sarah (F, US)' },
+ { id: 'af_sky', label: 'Sky (F, US)' },
+ { id: 'am_adam', label: 'Adam (M, US)' },
+ { id: 'am_echo', label: 'Echo (M, US)' },
+ { id: 'am_eric', label: 'Eric (M, US)' },
+ { id: 'am_fenrir', label: 'Fenrir (M, US)' },
+ { id: 'am_liam', label: 'Liam (M, US)' },
+ { id: 'am_michael', label: 'Michael (M, US)' },
+ { id: 'am_onyx', label: 'Onyx (M, US)' },
+ { id: 'am_puck', label: 'Puck (M, US)' },
+ { id: 'bf_alice', label: 'Alice (F, UK)' },
+ { id: 'bf_emma', label: 'Emma (F, UK)' },
+ { id: 'bf_isabella', label: 'Isabella (F, UK)' },
+ { id: 'bf_lily', label: 'Lily (F, UK)' },
+ { id: 'bm_daniel', label: 'Daniel (M, UK)' },
+ { id: 'bm_fable', label: 'Fable (M, UK)' },
+ { id: 'bm_george', label: 'George (M, UK)' },
+ { id: 'bm_lewis', label: 'Lewis (M, UK)' },
+]
+
+export const DEFAULT_SETTINGS = {
+ voice: DEFAULT_VOICE,
+ autoTts: true,
+ sttMode: 'bridge',
+}
diff --git a/homeai-dashboard/src/main.jsx b/homeai-dashboard/src/main.jsx
new file mode 100644
index 0000000..b9a1a6d
--- /dev/null
+++ b/homeai-dashboard/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.jsx'
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/homeai-dashboard/src/pages/Characters.jsx b/homeai-dashboard/src/pages/Characters.jsx
new file mode 100644
index 0000000..3425b49
--- /dev/null
+++ b/homeai-dashboard/src/pages/Characters.jsx
@@ -0,0 +1,292 @@
+import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { validateCharacter } from '../lib/SchemaValidator';
+
+const STORAGE_KEY = 'homeai_characters';
+const ACTIVE_KEY = 'homeai_active_character';
+
+function loadProfiles() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw ? JSON.parse(raw) : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveProfiles(profiles) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(profiles));
+}
+
+function getActiveId() {
+ return localStorage.getItem(ACTIVE_KEY) || null;
+}
+
+function setActiveId(id) {
+ localStorage.setItem(ACTIVE_KEY, id);
+}
+
+export default function Characters() {
+ const [profiles, setProfiles] = useState(loadProfiles);
+ const [activeId, setActive] = useState(getActiveId);
+ const [error, setError] = useState(null);
+ const [dragOver, setDragOver] = useState(false);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ saveProfiles(profiles);
+ }, [profiles]);
+
+ const handleImport = (e) => {
+ const files = Array.from(e.target?.files || []);
+ importFiles(files);
+ if (e.target) e.target.value = '';
+ };
+
+ const importFiles = (files) => {
+ files.forEach(file => {
+ if (!file.name.endsWith('.json')) return;
+ const reader = new FileReader();
+ reader.onload = (ev) => {
+ try {
+ const data = JSON.parse(ev.target.result);
+ validateCharacter(data);
+ const id = data.name + '_' + Date.now();
+ setProfiles(prev => [...prev, { id, data, image: null, addedAt: new Date().toISOString() }]);
+ setError(null);
+ } catch (err) {
+ setError(`Import failed for ${file.name}: ${err.message}`);
+ }
+ };
+ reader.readAsText(file);
+ });
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ setDragOver(false);
+ const files = Array.from(e.dataTransfer.files);
+ importFiles(files);
+ };
+
+ const handleImageUpload = (profileId, e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = (ev) => {
+ setProfiles(prev =>
+ prev.map(p => p.id === profileId ? { ...p, image: ev.target.result } : p)
+ );
+ };
+ reader.readAsDataURL(file);
+ };
+
+ const removeProfile = (id) => {
+ setProfiles(prev => prev.filter(p => p.id !== id));
+ if (activeId === id) {
+ setActive(null);
+ localStorage.removeItem(ACTIVE_KEY);
+ }
+ };
+
+ const activateProfile = (id) => {
+ setActive(id);
+ setActiveId(id);
+ };
+
+ const exportProfile = (profile) => {
+ const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(profile.data, null, 2));
+ const a = document.createElement('a');
+ a.href = dataStr;
+ a.download = `${profile.data.name || 'character'}.json`;
+ a.click();
+ };
+
+ const editProfile = (profile) => {
+ sessionStorage.setItem('edit_character', JSON.stringify(profile.data));
+ sessionStorage.setItem('edit_character_profile_id', profile.id);
+ navigate('/editor');
+ };
+
+ const activeProfile = profiles.find(p => p.id === activeId);
+
+ return (
+
+ {/* Header */}
+
+
+
Characters
+
+ {profiles.length} profile{profiles.length !== 1 ? 's' : ''} stored
+ {activeProfile && (
+
+ Active: {activeProfile.data.display_name || activeProfile.data.name}
+
+ )}
+
+
+
+
+
+
+ Import JSON
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Drop zone */}
+
{ e.preventDefault(); setDragOver(true); }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={handleDrop}
+ className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
+ dragOver
+ ? 'border-indigo-500 bg-indigo-500/10'
+ : 'border-gray-700 hover:border-gray-600'
+ }`}
+ >
+
+
+
+
Drop character JSON files here to import
+
+
+ {/* Profile grid */}
+ {profiles.length === 0 ? (
+
+
+
+
+
No character profiles yet. Import a JSON file to get started.
+
+ ) : (
+
+ {profiles.map(profile => {
+ const isActive = profile.id === activeId;
+ const char = profile.data;
+ return (
+
+ {/* Image area */}
+
+ {profile.image ? (
+
+ ) : (
+
+ {(char.display_name || char.name || '?')[0].toUpperCase()}
+
+ )}
+
+
+ handleImageUpload(profile.id, e)}
+ />
+
+ {isActive && (
+
+ Active
+
+ )}
+
+
+ {/* Info */}
+
+
+
+ {char.display_name || char.name}
+
+
{char.description}
+
+
+
+
+ {char.tts?.engine || 'kokoro'}
+
+
+ {char.model_overrides?.primary || 'default'}
+
+ {char.tts?.kokoro_voice && (
+
+ {char.tts.kokoro_voice}
+
+ )}
+
+
+
+ {!isActive ? (
+
activateProfile(profile.id)}
+ className="flex-1 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm rounded-lg transition-colors"
+ >
+ Activate
+
+ ) : (
+
+ Active
+
+ )}
+
editProfile(profile)}
+ className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
+ title="Edit"
+ >
+
+
+
+
+
exportProfile(profile)}
+ className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
+ title="Export"
+ >
+
+
+
+
+
removeProfile(profile.id)}
+ className="px-3 py-1.5 bg-gray-700 hover:bg-red-600 text-gray-300 hover:text-white text-sm rounded-lg transition-colors"
+ title="Delete"
+ >
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/homeai-dashboard/src/pages/Chat.jsx b/homeai-dashboard/src/pages/Chat.jsx
new file mode 100644
index 0000000..08817a5
--- /dev/null
+++ b/homeai-dashboard/src/pages/Chat.jsx
@@ -0,0 +1,115 @@
+import { useState, useEffect, useCallback } from 'react'
+import ChatPanel from '../components/ChatPanel'
+import InputBar from '../components/InputBar'
+import StatusIndicator from '../components/StatusIndicator'
+import SettingsDrawer from '../components/SettingsDrawer'
+import { useSettings } from '../hooks/useSettings'
+import { useBridgeHealth } from '../hooks/useBridgeHealth'
+import { useChat } from '../hooks/useChat'
+import { useTtsPlayback } from '../hooks/useTtsPlayback'
+import { useVoiceInput } from '../hooks/useVoiceInput'
+
+export default function Chat() {
+ const { settings, updateSetting } = useSettings()
+ const isOnline = useBridgeHealth()
+ const { messages, isLoading, send, clearHistory } = useChat()
+ const { isPlaying, speak, stop } = useTtsPlayback(settings.voice)
+ const { isRecording, isTranscribing, startRecording, stopRecording } = useVoiceInput(settings.sttMode)
+ const [settingsOpen, setSettingsOpen] = useState(false)
+
+ // Send a message and optionally speak the response
+ const handleSend = useCallback(async (text) => {
+ const response = await send(text)
+ if (response && settings.autoTts) {
+ speak(response)
+ }
+ }, [send, settings.autoTts, speak])
+
+ // Toggle voice recording
+ const handleVoiceToggle = useCallback(async () => {
+ if (isRecording) {
+ const text = await stopRecording()
+ if (text) {
+ handleSend(text)
+ }
+ } else {
+ startRecording()
+ }
+ }, [isRecording, stopRecording, startRecording, handleSend])
+
+ // Space bar push-to-talk when input not focused
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (e.code === 'Space' && e.target.tagName !== 'TEXTAREA' && e.target.tagName !== 'INPUT') {
+ e.preventDefault()
+ handleVoiceToggle()
+ }
+ }
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [handleVoiceToggle])
+
+ return (
+
+ {/* Status bar */}
+
+
+ {/* Chat area */}
+
+
+ {/* Input */}
+
+
+ {/* Settings drawer */}
+
setSettingsOpen(false)}
+ settings={settings}
+ onUpdate={updateSetting}
+ />
+
+ )
+}
diff --git a/homeai-dashboard/src/pages/Dashboard.jsx b/homeai-dashboard/src/pages/Dashboard.jsx
new file mode 100644
index 0000000..5498932
--- /dev/null
+++ b/homeai-dashboard/src/pages/Dashboard.jsx
@@ -0,0 +1,376 @@
+import { useState, useEffect, useCallback } from 'react';
+
+const SERVICES = [
+ {
+ name: 'Ollama',
+ url: 'http://localhost:11434',
+ healthPath: '/api/tags',
+ uiUrl: null,
+ description: 'Local LLM runtime',
+ category: 'AI & LLM',
+ restart: { type: 'launchd', id: 'gui/501/com.homeai.ollama' },
+ },
+ {
+ name: 'Open WebUI',
+ url: 'http://localhost:3030',
+ healthPath: '/',
+ uiUrl: 'http://localhost:3030',
+ description: 'Chat interface',
+ category: 'AI & LLM',
+ restart: { type: 'docker', id: 'homeai-open-webui' },
+ },
+ {
+ name: 'OpenClaw Gateway',
+ url: 'http://localhost:8080',
+ healthPath: '/',
+ uiUrl: null,
+ description: 'Agent gateway',
+ category: 'Agent',
+ restart: { type: 'launchd', id: 'gui/501/com.homeai.openclaw' },
+ },
+ {
+ name: 'OpenClaw Bridge',
+ url: 'http://localhost:8081',
+ healthPath: '/',
+ uiUrl: null,
+ description: 'HTTP-to-CLI bridge',
+ category: 'Agent',
+ restart: { type: 'launchd', id: 'gui/501/com.homeai.openclaw-bridge' },
+ },
+ {
+ name: 'Wyoming STT',
+ url: 'http://localhost:10300',
+ healthPath: '/',
+ uiUrl: null,
+ description: 'Whisper speech-to-text',
+ category: 'Voice',
+ tcp: true,
+ restart: { type: 'launchd', id: 'gui/501/com.homeai.wyoming-stt' },
+ },
+ {
+ name: 'Wyoming TTS',
+ url: 'http://localhost:10301',
+ healthPath: '/',
+ uiUrl: null,
+ description: 'Kokoro text-to-speech',
+ category: 'Voice',
+ tcp: true,
+ restart: { type: 'launchd', id: 'gui/501/com.homeai.wyoming-tts' },
+ },
+ {
+ name: 'Wyoming Satellite',
+ url: 'http://localhost:10700',
+ healthPath: '/',
+ uiUrl: null,
+ description: 'Mac Mini mic/speaker satellite',
+ category: 'Voice',
+ tcp: true,
+ restart: { type: 'launchd', id: 'gui/501/com.homeai.wyoming-satellite' },
+ },
+ {
+ name: 'Home Assistant',
+ url: 'https://10.0.0.199:8123',
+ healthPath: '/api/',
+ uiUrl: 'https://10.0.0.199:8123',
+ description: 'Smart home platform',
+ category: 'Smart Home',
+ },
+ {
+ name: 'Uptime Kuma',
+ url: 'http://localhost:3001',
+ healthPath: '/',
+ uiUrl: 'http://localhost:3001',
+ description: 'Service health monitoring',
+ category: 'Infrastructure',
+ restart: { type: 'docker', id: 'homeai-uptime-kuma' },
+ },
+ {
+ name: 'n8n',
+ url: 'http://localhost:5678',
+ healthPath: '/',
+ uiUrl: 'http://localhost:5678',
+ description: 'Workflow automation',
+ category: 'Infrastructure',
+ restart: { type: 'docker', id: 'homeai-n8n' },
+ },
+ {
+ name: 'code-server',
+ url: 'http://localhost:8090',
+ healthPath: '/',
+ uiUrl: 'http://localhost:8090',
+ description: 'Browser-based VS Code',
+ category: 'Infrastructure',
+ restart: { type: 'docker', id: 'homeai-code-server' },
+ },
+ {
+ name: 'Portainer',
+ url: 'https://10.0.0.199:9443',
+ healthPath: '/',
+ uiUrl: 'https://10.0.0.199:9443',
+ description: 'Docker management',
+ category: 'Infrastructure',
+ },
+ {
+ name: 'Gitea',
+ url: 'http://10.0.0.199:3000',
+ healthPath: '/',
+ uiUrl: 'http://10.0.0.199:3000',
+ description: 'Self-hosted Git',
+ category: 'Infrastructure',
+ },
+];
+
+const CATEGORY_ICONS = {
+ 'AI & LLM': (
+
+
+
+ ),
+ 'Agent': (
+
+
+
+ ),
+ 'Voice': (
+
+
+
+ ),
+ 'Smart Home': (
+
+
+
+ ),
+ 'Infrastructure': (
+
+
+
+ ),
+};
+
+function StatusDot({ status }) {
+ const colors = {
+ online: 'bg-emerald-400 shadow-emerald-400/50',
+ offline: 'bg-red-400 shadow-red-400/50',
+ checking: 'bg-amber-400 shadow-amber-400/50 animate-pulse',
+ unknown: 'bg-gray-500',
+ };
+ return (
+
+ );
+}
+
+export default function Dashboard() {
+ const [statuses, setStatuses] = useState(() =>
+ Object.fromEntries(SERVICES.map(s => [s.name, { status: 'checking', lastCheck: null, responseTime: null }]))
+ );
+ const [lastRefresh, setLastRefresh] = useState(null);
+ const [restarting, setRestarting] = useState({});
+
+ const checkService = useCallback(async (service) => {
+ try {
+ const target = encodeURIComponent(service.url + service.healthPath);
+ const modeParam = service.tcp ? '&mode=tcp' : '';
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 8000);
+
+ const res = await fetch(`/api/health?url=${target}${modeParam}`, { signal: controller.signal });
+ clearTimeout(timeout);
+
+ const data = await res.json();
+ return { status: data.status, lastCheck: new Date(), responseTime: data.responseTime };
+ } catch {
+ return { status: 'offline', lastCheck: new Date(), responseTime: null };
+ }
+ }, []);
+
+ const refreshAll = useCallback(async () => {
+ setStatuses(prev =>
+ Object.fromEntries(Object.entries(prev).map(([k, v]) => [k, { ...v, status: 'checking' }]))
+ );
+
+ const results = await Promise.allSettled(
+ SERVICES.map(async (service) => {
+ const result = await checkService(service);
+ return { name: service.name, ...result };
+ })
+ );
+
+ const newStatuses = {};
+ for (const r of results) {
+ if (r.status === 'fulfilled') {
+ newStatuses[r.value.name] = {
+ status: r.value.status,
+ lastCheck: r.value.lastCheck,
+ responseTime: r.value.responseTime,
+ };
+ }
+ }
+ setStatuses(prev => ({ ...prev, ...newStatuses }));
+ setLastRefresh(new Date());
+ }, [checkService]);
+
+ useEffect(() => {
+ refreshAll();
+ const interval = setInterval(refreshAll, 30000);
+ return () => clearInterval(interval);
+ }, [refreshAll]);
+
+ const restartService = useCallback(async (service) => {
+ if (!service.restart) return;
+ setRestarting(prev => ({ ...prev, [service.name]: true }));
+ try {
+ const res = await fetch('/api/service/restart', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(service.restart),
+ });
+ const data = await res.json();
+ if (!data.ok) {
+ console.error(`Restart failed for ${service.name}:`, data.error);
+ }
+ setTimeout(async () => {
+ const result = await checkService(service);
+ setStatuses(prev => ({ ...prev, [service.name]: result }));
+ setRestarting(prev => ({ ...prev, [service.name]: false }));
+ }, 3000);
+ } catch (err) {
+ console.error(`Restart failed for ${service.name}:`, err);
+ setRestarting(prev => ({ ...prev, [service.name]: false }));
+ }
+ }, [checkService]);
+
+ const categories = [...new Set(SERVICES.map(s => s.category))];
+ const onlineCount = Object.values(statuses).filter(s => s.status === 'online').length;
+ const offlineCount = Object.values(statuses).filter(s => s.status === 'offline').length;
+ const totalCount = SERVICES.length;
+ const allOnline = onlineCount === totalCount;
+
+ return (
+
+ {/* Header */}
+
+
+
Service Status
+
+ {onlineCount}/{totalCount} services online
+ {lastRefresh && (
+
+ Last check: {lastRefresh.toLocaleTimeString()}
+
+ )}
+
+
+
+
+
+
+ Refresh
+
+
+
+ {/* Summary bar */}
+
+ {allOnline ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ {/* Service grid by category */}
+ {categories.map(category => (
+
+
+ {CATEGORY_ICONS[category]}
+
{category}
+
+
+ {SERVICES.filter(s => s.category === category).map(service => {
+ const st = statuses[service.name] || { status: 'unknown' };
+ return (
+
+
+
+
+
+
{service.name}
+
+
{service.description}
+ {st.responseTime !== null && (
+
{st.responseTime}ms
+ )}
+
+
+ {service.restart && st.status === 'offline' && (
+
restartService(service)}
+ disabled={restarting[service.name]}
+ className="text-xs px-2.5 py-1 rounded-md bg-amber-600/80 hover:bg-amber-500 disabled:bg-gray-700 disabled:text-gray-500 text-white transition-colors flex items-center gap-1"
+ >
+ {restarting[service.name] ? (
+ <>
+
+
+
+
+ Restarting
+ >
+ ) : (
+ <>
+
+
+
+ Restart
+ >
+ )}
+
+ )}
+ {service.uiUrl && (
+
+ Open
+
+
+
+
+ )}
+
+
+
+ );
+ })}
+
+
+ ))}
+
+ );
+}
diff --git a/homeai-dashboard/src/pages/Editor.jsx b/homeai-dashboard/src/pages/Editor.jsx
new file mode 100644
index 0000000..f34c81c
--- /dev/null
+++ b/homeai-dashboard/src/pages/Editor.jsx
@@ -0,0 +1,581 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { validateCharacter } from '../lib/SchemaValidator';
+
+const DEFAULT_CHARACTER = {
+ schema_version: 1,
+ name: "aria",
+ display_name: "Aria",
+ description: "Default HomeAI assistant persona",
+ system_prompt: "You are Aria, a warm, curious, and helpful AI assistant living in the home. You speak naturally and conversationally — never robotic. You are knowledgeable but never condescending. You remember the people you live with and build on those memories over time. Keep responses concise when controlling smart home devices; be more expressive in casual conversation. Never break character.",
+ model_overrides: {
+ primary: "llama3.3:70b",
+ fast: "qwen2.5:7b"
+ },
+ tts: {
+ engine: "kokoro",
+ kokoro_voice: "af_heart",
+ speed: 1.0
+ },
+ live2d_expressions: {
+ idle: "expr_idle",
+ listening: "expr_listening",
+ thinking: "expr_thinking",
+ speaking: "expr_speaking",
+ happy: "expr_happy",
+ sad: "expr_sad",
+ surprised: "expr_surprised",
+ error: "expr_error"
+ },
+ vtube_ws_triggers: {
+ thinking: { type: "hotkey", id: "expr_thinking" },
+ speaking: { type: "hotkey", id: "expr_speaking" },
+ idle: { type: "hotkey", id: "expr_idle" }
+ },
+ custom_rules: [
+ { trigger: "good morning", response: "Good morning! How did you sleep?", condition: "time_of_day == morning" }
+ ],
+ notes: ""
+};
+
+export default function Editor() {
+ const [character, setCharacter] = useState(() => {
+ const editData = sessionStorage.getItem('edit_character');
+ if (editData) {
+ sessionStorage.removeItem('edit_character');
+ try {
+ return JSON.parse(editData);
+ } catch {
+ return DEFAULT_CHARACTER;
+ }
+ }
+ return DEFAULT_CHARACTER;
+ });
+ const [error, setError] = useState(null);
+ const [saved, setSaved] = useState(false);
+
+ // TTS preview state
+ const [ttsState, setTtsState] = useState('idle');
+ const [previewText, setPreviewText] = useState('');
+ const audioRef = useRef(null);
+ const objectUrlRef = useRef(null);
+
+ // ElevenLabs state
+ const [elevenLabsApiKey, setElevenLabsApiKey] = useState(localStorage.getItem('elevenlabs_api_key') || '');
+ const [elevenLabsVoices, setElevenLabsVoices] = useState([]);
+ const [elevenLabsModels, setElevenLabsModels] = useState([]);
+ const [isLoadingElevenLabs, setIsLoadingElevenLabs] = useState(false);
+
+ const fetchElevenLabsData = async (key) => {
+ if (!key) return;
+ setIsLoadingElevenLabs(true);
+ try {
+ const headers = { 'xi-api-key': key };
+ const [voicesRes, modelsRes] = await Promise.all([
+ fetch('https://api.elevenlabs.io/v1/voices', { headers }),
+ fetch('https://api.elevenlabs.io/v1/models', { headers })
+ ]);
+ if (!voicesRes.ok || !modelsRes.ok) {
+ throw new Error('Failed to fetch from ElevenLabs API (check API key)');
+ }
+ const voicesData = await voicesRes.json();
+ const modelsData = await modelsRes.json();
+ setElevenLabsVoices(voicesData.voices || []);
+ setElevenLabsModels(modelsData.filter(m => m.can_do_text_to_speech) || []);
+ localStorage.setItem('elevenlabs_api_key', key);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setIsLoadingElevenLabs(false);
+ }
+ };
+
+ useEffect(() => {
+ if (elevenLabsApiKey && character.tts.engine === 'elevenlabs') {
+ fetchElevenLabsData(elevenLabsApiKey);
+ }
+ }, [character.tts.engine]);
+
+ useEffect(() => {
+ return () => {
+ if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
+ if (objectUrlRef.current) { URL.revokeObjectURL(objectUrlRef.current); }
+ window.speechSynthesis.cancel();
+ };
+ }, []);
+
+ const handleExport = () => {
+ try {
+ validateCharacter(character);
+ setError(null);
+ const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(character, null, 2));
+ const a = document.createElement('a');
+ a.href = dataStr;
+ a.download = `${character.name || 'character'}.json`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ } catch (err) {
+ setError(err.message);
+ }
+ };
+
+ const handleSaveToProfiles = () => {
+ try {
+ validateCharacter(character);
+ setError(null);
+
+ const profileId = sessionStorage.getItem('edit_character_profile_id');
+ const storageKey = 'homeai_characters';
+ const raw = localStorage.getItem(storageKey);
+ let profiles = raw ? JSON.parse(raw) : [];
+
+ if (profileId) {
+ profiles = profiles.map(p =>
+ p.id === profileId ? { ...p, data: character } : p
+ );
+ sessionStorage.removeItem('edit_character_profile_id');
+ } else {
+ const id = character.name + '_' + Date.now();
+ profiles.push({ id, data: character, image: null, addedAt: new Date().toISOString() });
+ }
+
+ localStorage.setItem(storageKey, JSON.stringify(profiles));
+ setSaved(true);
+ setTimeout(() => setSaved(false), 2000);
+ } catch (err) {
+ setError(err.message);
+ }
+ };
+
+ const handleImport = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const importedChar = JSON.parse(e.target.result);
+ validateCharacter(importedChar);
+ setCharacter(importedChar);
+ setError(null);
+ } catch (err) {
+ setError(`Import failed: ${err.message}`);
+ }
+ };
+ reader.readAsText(file);
+ };
+
+ const handleChange = (field, value) => {
+ setCharacter(prev => ({ ...prev, [field]: value }));
+ };
+
+ const handleNestedChange = (parent, field, value) => {
+ setCharacter(prev => ({
+ ...prev,
+ [parent]: { ...prev[parent], [field]: value }
+ }));
+ };
+
+ const handleRuleChange = (index, field, value) => {
+ setCharacter(prev => {
+ const newRules = [...(prev.custom_rules || [])];
+ newRules[index] = { ...newRules[index], [field]: value };
+ return { ...prev, custom_rules: newRules };
+ });
+ };
+
+ const addRule = () => {
+ setCharacter(prev => ({
+ ...prev,
+ custom_rules: [...(prev.custom_rules || []), { trigger: "", response: "", condition: "" }]
+ }));
+ };
+
+ const removeRule = (index) => {
+ setCharacter(prev => {
+ const newRules = [...(prev.custom_rules || [])];
+ newRules.splice(index, 1);
+ return { ...prev, custom_rules: newRules };
+ });
+ };
+
+ const stopPreview = () => {
+ if (audioRef.current) {
+ audioRef.current.pause();
+ audioRef.current = null;
+ }
+ if (objectUrlRef.current) {
+ URL.revokeObjectURL(objectUrlRef.current);
+ objectUrlRef.current = null;
+ }
+ window.speechSynthesis.cancel();
+ setTtsState('idle');
+ };
+
+ const previewTTS = async () => {
+ stopPreview();
+ const text = previewText || `Hi, I am ${character.display_name}. This is a preview of my voice.`;
+
+ if (character.tts.engine === 'kokoro') {
+ setTtsState('loading');
+ let blob;
+ try {
+ const response = await fetch('/api/tts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text, voice: character.tts.kokoro_voice })
+ });
+ if (!response.ok) throw new Error('TTS bridge returned ' + response.status);
+ blob = await response.blob();
+ } catch (err) {
+ setTtsState('idle');
+ setError(`Kokoro preview failed: ${err.message}. Falling back to browser TTS.`);
+ runBrowserTTS(text);
+ return;
+ }
+ const url = URL.createObjectURL(blob);
+ objectUrlRef.current = url;
+ const audio = new Audio(url);
+ audio.playbackRate = character.tts.speed;
+ audio.onended = () => { stopPreview(); };
+ audio.onerror = () => { stopPreview(); };
+ audioRef.current = audio;
+ setTtsState('playing');
+ audio.play().catch(() => {});
+ } else {
+ runBrowserTTS(text);
+ }
+ };
+
+ const runBrowserTTS = (text) => {
+ const utterance = new SpeechSynthesisUtterance(text);
+ utterance.rate = character.tts.speed;
+ const voices = window.speechSynthesis.getVoices();
+ const preferredVoice = voices.find(v => v.lang.startsWith('en') && v.name.includes('Female')) || voices.find(v => v.lang.startsWith('en'));
+ if (preferredVoice) utterance.voice = preferredVoice;
+ setTtsState('playing');
+ utterance.onend = () => setTtsState('idle');
+ window.speechSynthesis.cancel();
+ window.speechSynthesis.speak(utterance);
+ };
+
+ const inputClass = "w-full bg-gray-800 border border-gray-700 text-gray-200 p-2 rounded-lg focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-colors";
+ const selectClass = "w-full bg-gray-800 border border-gray-700 text-gray-200 p-2 rounded-lg focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-colors";
+ const labelClass = "block text-sm font-medium text-gray-400 mb-1";
+ const cardClass = "bg-gray-900 border border-gray-800 p-5 rounded-xl space-y-4";
+
+ return (
+
+
+
+
Character Editor
+
+ Editing: {character.display_name || character.name}
+
+
+
+
+
+
+
+ Import
+
+
+
+
+ {saved
+ ?
+ :
+ }
+
+ {saved ? 'Saved' : 'Save to Profiles'}
+
+
+
+
+
+ Export JSON
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {/* Basic Info */}
+
+
+ {/* TTS Configuration */}
+
+
TTS Configuration
+
+ Engine
+ handleNestedChange('tts', 'engine', e.target.value)}>
+ Kokoro
+ Chatterbox
+ Qwen3
+ ElevenLabs
+
+
+
+ {character.tts.engine === 'elevenlabs' && (
+
+
+
ElevenLabs API Key (Local Use Only)
+
+ setElevenLabsApiKey(e.target.value)} />
+ fetchElevenLabsData(elevenLabsApiKey)} disabled={isLoadingElevenLabs} className="bg-indigo-600 text-white px-3 py-1 rounded-lg text-sm whitespace-nowrap hover:bg-indigo-500 disabled:opacity-50 transition-colors">
+ {isLoadingElevenLabs ? 'Loading...' : 'Fetch'}
+
+
+
+
+ Voice ID
+ {elevenLabsVoices.length > 0 ? (
+ handleNestedChange('tts', 'elevenlabs_voice_id', e.target.value)}>
+ -- Select Voice --
+ {elevenLabsVoices.map(v => (
+ {v.name} ({v.category})
+ ))}
+
+ ) : (
+ handleNestedChange('tts', 'elevenlabs_voice_id', e.target.value)} placeholder="e.g. 21m00Tcm4TlvDq8ikWAM" />
+ )}
+
+
+ Model
+ {elevenLabsModels.length > 0 ? (
+ handleNestedChange('tts', 'elevenlabs_model', e.target.value)}>
+ -- Select Model --
+ {elevenLabsModels.map(m => (
+ {m.name} ({m.model_id})
+ ))}
+
+ ) : (
+ handleNestedChange('tts', 'elevenlabs_model', e.target.value)} placeholder="e.g. eleven_monolingual_v1" />
+ )}
+
+
+ )}
+
+ {character.tts.engine === 'kokoro' && (
+
+ Kokoro Voice
+ handleNestedChange('tts', 'kokoro_voice', e.target.value)}>
+ af_heart (American Female)
+ af_alloy (American Female)
+ af_aoede (American Female)
+ af_bella (American Female)
+ af_jessica (American Female)
+ af_kore (American Female)
+ af_nicole (American Female)
+ af_nova (American Female)
+ af_river (American Female)
+ af_sarah (American Female)
+ af_sky (American Female)
+ am_adam (American Male)
+ am_echo (American Male)
+ am_eric (American Male)
+ am_fenrir (American Male)
+ am_liam (American Male)
+ am_michael (American Male)
+ am_onyx (American Male)
+ am_puck (American Male)
+ am_santa (American Male)
+ bf_alice (British Female)
+ bf_emma (British Female)
+ bf_isabella (British Female)
+ bf_lily (British Female)
+ bm_daniel (British Male)
+ bm_fable (British Male)
+ bm_george (British Male)
+ bm_lewis (British Male)
+
+
+ )}
+
+ {character.tts.engine === 'chatterbox' && (
+
+ Voice Reference Path
+ handleNestedChange('tts', 'voice_ref_path', e.target.value)} />
+
+ )}
+
+
+ Speed: {character.tts.speed}
+ handleNestedChange('tts', 'speed', parseFloat(e.target.value))} />
+
+
+ Preview Text
+ setPreviewText(e.target.value)}
+ placeholder={`Hi, I am ${character.display_name}. This is a preview of my voice.`}
+ />
+
+
+
+ {ttsState === 'loading' && (
+
+
+
+
+ )}
+ {ttsState === 'loading' ? 'Synthesizing...' : ttsState === 'playing' ? 'Playing...' : 'Preview Voice'}
+
+ {ttsState !== 'idle' && (
+
+ Stop
+
+ )}
+
+
+ {character.tts.engine === 'kokoro'
+ ? 'Previews via local Kokoro TTS bridge (port 8081).'
+ : 'Uses browser TTS for preview. Local TTS available with Kokoro engine.'}
+
+
+
+
+ {/* System Prompt */}
+
+
+
System Prompt
+ {character.system_prompt.length} chars
+
+
handleChange('system_prompt', e.target.value)}
+ />
+
+
+
+ {/* Live2D Expressions */}
+
+
Live2D Expressions
+ {Object.entries(character.live2d_expressions).map(([key, val]) => (
+
+ {key}
+ handleNestedChange('live2d_expressions', key, e.target.value)} />
+
+ ))}
+
+
+ {/* Model Overrides */}
+
+
Model Overrides
+
+ Primary Model
+ handleNestedChange('model_overrides', 'primary', e.target.value)}>
+ llama3.3:70b
+ qwen3.5:35b-a3b
+ qwen2.5:7b
+ qwen3:32b
+ codestral:22b
+
+
+
+ Fast Model
+ handleNestedChange('model_overrides', 'fast', e.target.value)}>
+ qwen2.5:7b
+ qwen3.5:35b-a3b
+ llama3.3:70b
+ qwen3:32b
+ codestral:22b
+
+
+
+
+
+ {/* Custom Rules */}
+
+
+
Custom Rules
+
+
+
+
+ Add Rule
+
+
+
+ {(!character.custom_rules || character.custom_rules.length === 0) ? (
+
No custom rules defined.
+ ) : (
+
+ {character.custom_rules.map((rule, idx) => (
+
+
removeRule(idx)}
+ className="absolute top-3 right-3 text-gray-500 hover:text-red-400 transition-colors"
+ title="Remove Rule"
+ >
+
+
+
+
+
+
+ Trigger
+ handleRuleChange(idx, 'trigger', e.target.value)} />
+
+
+ Condition (Optional)
+ handleRuleChange(idx, 'condition', e.target.value)} placeholder="e.g. time_of_day == morning" />
+
+
+ Response
+ handleRuleChange(idx, 'response', e.target.value)} />
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/homeai-dashboard/vite.config.js b/homeai-dashboard/vite.config.js
new file mode 100644
index 0000000..e354a88
--- /dev/null
+++ b/homeai-dashboard/vite.config.js
@@ -0,0 +1,201 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+
+function healthCheckPlugin() {
+ return {
+ name: 'health-check-proxy',
+ configureServer(server) {
+ server.middlewares.use('/api/health', async (req, res) => {
+ const params = new URL(req.url, 'http://localhost').searchParams;
+ const url = params.get('url');
+ const mode = params.get('mode'); // 'tcp' for raw TCP port check
+ if (!url) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Missing url param' }));
+ return;
+ }
+ const start = Date.now();
+ const parsedUrl = new URL(url);
+
+ try {
+ if (mode === 'tcp') {
+ // TCP socket connect check for non-HTTP services (e.g. Wyoming)
+ const { default: net } = await import('net');
+ await new Promise((resolve, reject) => {
+ const socket = net.createConnection(
+ { host: parsedUrl.hostname, port: parseInt(parsedUrl.port), timeout: 5000 },
+ () => { socket.destroy(); resolve(); }
+ );
+ socket.on('error', reject);
+ socket.on('timeout', () => { socket.destroy(); reject(new Error('timeout')); });
+ });
+ } else {
+ // HTTP/HTTPS health check
+ const { default: https } = await import('https');
+ const { default: http } = await import('http');
+ const client = parsedUrl.protocol === 'https:' ? https : http;
+
+ await new Promise((resolve, reject) => {
+ const reqObj = client.get(url, { rejectUnauthorized: false, timeout: 5000 }, (resp) => {
+ resp.resume();
+ resolve();
+ });
+ reqObj.on('error', reject);
+ reqObj.on('timeout', () => { reqObj.destroy(); reject(new Error('timeout')); });
+ });
+ }
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ status: 'online', responseTime: Date.now() - start }));
+ } catch {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ status: 'offline', responseTime: null }));
+ }
+ });
+
+ // Service restart — runs launchctl or docker restart
+ server.middlewares.use('/api/service/restart', async (req, res) => {
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': 'Content-Type' });
+ res.end();
+ return;
+ }
+ if (req.method !== 'POST') {
+ res.writeHead(405);
+ res.end();
+ return;
+ }
+ try {
+ const chunks = [];
+ for await (const chunk of req) chunks.push(chunk);
+ const { type, id } = JSON.parse(Buffer.concat(chunks).toString());
+
+ if (!type || !id) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'Missing type or id' }));
+ return;
+ }
+
+ // Whitelist valid service IDs to prevent command injection
+ const ALLOWED_LAUNCHD = [
+ 'gui/501/com.homeai.ollama',
+ 'gui/501/com.homeai.openclaw',
+ 'gui/501/com.homeai.openclaw-bridge',
+ 'gui/501/com.homeai.wyoming-stt',
+ 'gui/501/com.homeai.wyoming-tts',
+ 'gui/501/com.homeai.wyoming-satellite',
+ 'gui/501/com.homeai.dashboard',
+ ];
+ const ALLOWED_DOCKER = [
+ 'homeai-open-webui',
+ 'homeai-uptime-kuma',
+ 'homeai-n8n',
+ 'homeai-code-server',
+ ];
+
+ let cmd;
+ if (type === 'launchd' && ALLOWED_LAUNCHD.includes(id)) {
+ cmd = ['launchctl', 'kickstart', '-k', id];
+ } else if (type === 'docker' && ALLOWED_DOCKER.includes(id)) {
+ cmd = ['docker', 'restart', id];
+ } else {
+ res.writeHead(403, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'Service not in allowed list' }));
+ return;
+ }
+
+ const { execFile } = await import('child_process');
+ const { promisify } = await import('util');
+ const execFileAsync = promisify(execFile);
+ const { stdout, stderr } = await execFileAsync(cmd[0], cmd.slice(1), { timeout: 30000 });
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, stdout: stdout.trim(), stderr: stderr.trim() }));
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: err.message }));
+ }
+ });
+ },
+ };
+}
+
+function bridgeProxyPlugin() {
+ return {
+ name: 'bridge-proxy',
+ configureServer(server) {
+ // Proxy a request to the OpenClaw bridge
+ const proxyRequest = (targetPath) => async (req, res) => {
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204, {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type',
+ })
+ res.end()
+ return
+ }
+
+ try {
+ const { default: http } = await import('http')
+ const chunks = []
+ for await (const chunk of req) chunks.push(chunk)
+ const body = Buffer.concat(chunks)
+
+ await new Promise((resolve, reject) => {
+ const proxyReq = http.request(
+ `http://localhost:8081${targetPath}`,
+ {
+ method: req.method,
+ headers: {
+ 'Content-Type': req.headers['content-type'] || 'application/json',
+ 'Content-Length': body.length,
+ },
+ timeout: 120000,
+ },
+ (proxyRes) => {
+ res.writeHead(proxyRes.statusCode, {
+ 'Content-Type': proxyRes.headers['content-type'] || 'application/json',
+ 'Access-Control-Allow-Origin': '*',
+ })
+ proxyRes.pipe(res)
+ proxyRes.on('end', resolve)
+ proxyRes.on('error', resolve)
+ }
+ )
+ proxyReq.on('error', reject)
+ proxyReq.on('timeout', () => {
+ proxyReq.destroy()
+ reject(new Error('timeout'))
+ })
+ proxyReq.write(body)
+ proxyReq.end()
+ })
+ } catch {
+ if (!res.headersSent) {
+ res.writeHead(502, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ error: 'Bridge unreachable' }))
+ }
+ }
+ }
+
+ server.middlewares.use('/api/agent/message', proxyRequest('/api/agent/message'))
+ server.middlewares.use('/api/tts', proxyRequest('/api/tts'))
+ server.middlewares.use('/api/stt', proxyRequest('/api/stt'))
+ },
+ }
+}
+
+export default defineConfig({
+ plugins: [
+ healthCheckPlugin(),
+ bridgeProxyPlugin(),
+ tailwindcss(),
+ react(),
+ ],
+ server: {
+ host: '0.0.0.0',
+ port: 5173,
+ },
+})
diff --git a/start.sh b/start.sh
new file mode 100755
index 0000000..a0efb77
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,221 @@
+#!/usr/bin/env bash
+# start.sh — Start all HomeAI services on LINDBLUM
+#
+# Usage:
+# ./start.sh # start everything
+# ./start.sh status # show what's running
+# ./start.sh stop # stop everything
+# ./start.sh restart # stop then start everything
+
+set -euo pipefail
+
+REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "${REPO_DIR}/scripts/common.sh"
+
+DOCKER_DIR="${HOME}/server/docker"
+
+# ─── Service definitions ──────────────────────────────────────────────────────
+
+LAUNCHD_SERVICES=(
+ com.homeai.ollama
+ com.homeai.wyoming-stt
+ com.homeai.wyoming-tts
+ com.homeai.wyoming-satellite
+ com.homeai.openclaw
+ com.homeai.openclaw-bridge
+ com.homeai.preload-models
+ com.homeai.dashboard
+)
+
+DOCKER_COMPOSE_DIRS=(
+ "${DOCKER_DIR}/open-webui"
+ "${DOCKER_DIR}/uptime-kuma"
+ "${DOCKER_DIR}/n8n"
+ "${DOCKER_DIR}/code-server"
+)
+
+# ─── Helpers ──────────────────────────────────────────────────────────────────
+
+launchd_running() {
+ local label="$1"
+ local pid
+ pid=$(launchctl list "$label" 2>/dev/null | grep '"PID"' | sed 's/[^0-9]//g' || true)
+ [[ -n "$pid" && "$pid" -gt 0 ]] 2>/dev/null
+}
+
+start_launchd() {
+ local label="$1"
+ local plist="${HOME}/Library/LaunchAgents/${label}.plist"
+
+ if [[ ! -f "$plist" ]]; then
+ log_warn "Plist not found: $plist — skipping"
+ return
+ fi
+
+ if launchd_running "$label"; then
+ log_info "Already running: $label"
+ return
+ fi
+
+ log_step "Starting $label"
+ # bootstrap loads + starts the agent
+ launchctl bootstrap "gui/$(id -u)" "$plist" 2>/dev/null \
+ || launchctl kickstart -k "gui/$(id -u)/$label" 2>/dev/null \
+ || true
+ sleep 0.5
+
+ if launchd_running "$label"; then
+ log_success "$label started"
+ else
+ log_warn "$label may not have started — check logs"
+ fi
+}
+
+stop_launchd() {
+ local label="$1"
+ local plist="${HOME}/Library/LaunchAgents/${label}.plist"
+
+ if [[ ! -f "$plist" ]]; then
+ return
+ fi
+
+ if ! launchd_running "$label"; then
+ log_info "Already stopped: $label"
+ return
+ fi
+
+ log_step "Stopping $label"
+ launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true
+ log_success "$label stopped"
+}
+
+start_docker_services() {
+ ensure_docker_running
+
+ for dir in "${DOCKER_COMPOSE_DIRS[@]}"; do
+ if [[ ! -d "$dir" ]]; then
+ log_warn "Docker compose dir not found: $dir — skipping"
+ continue
+ fi
+
+ local name
+ name=$(basename "$dir")
+ log_step "Starting Docker stack: $name"
+ docker_compose -f "${dir}/docker-compose.yml" up -d 2>/dev/null \
+ || docker_compose -f "${dir}/compose.yml" up -d 2>/dev/null \
+ || log_warn "No compose file found in $dir"
+ done
+}
+
+stop_docker_services() {
+ for dir in "${DOCKER_COMPOSE_DIRS[@]}"; do
+ if [[ ! -d "$dir" ]]; then
+ continue
+ fi
+
+ local name
+ name=$(basename "$dir")
+ log_step "Stopping Docker stack: $name"
+ docker_compose -f "${dir}/docker-compose.yml" down 2>/dev/null \
+ || docker_compose -f "${dir}/compose.yml" down 2>/dev/null \
+ || true
+ done
+}
+
+# ─── Commands ─────────────────────────────────────────────────────────────────
+
+do_start() {
+ log_section "Starting HomeAI Services"
+
+ echo ""
+ log_info "── LaunchD services ──"
+ for svc in "${LAUNCHD_SERVICES[@]}"; do
+ start_launchd "$svc"
+ done
+
+ echo ""
+ log_info "── Docker services ──"
+ start_docker_services
+
+ echo ""
+ log_success "All services started."
+ echo ""
+ do_status
+}
+
+do_stop() {
+ log_section "Stopping HomeAI Services"
+
+ echo ""
+ log_info "── LaunchD services ──"
+ for svc in "${LAUNCHD_SERVICES[@]}"; do
+ stop_launchd "$svc"
+ done
+
+ echo ""
+ log_info "── Docker services ──"
+ stop_docker_services
+
+ echo ""
+ log_success "All services stopped."
+}
+
+do_status() {
+ log_section "HomeAI Service Status"
+
+ # LaunchD services
+ printf "\n ${BOLD}%-34s %-10s %s${RESET}\n" "SERVICE" "STATUS" "PID"
+ printf " %-34s %-10s %s\n" "─────────────────────────────────" "────────" "─────"
+
+ for svc in "${LAUNCHD_SERVICES[@]}"; do
+ local pid
+ pid=$(launchctl list "$svc" 2>/dev/null | grep '"PID"' | sed 's/[^0-9]//g' || true)
+ if [[ -n "$pid" && "$pid" -gt 0 ]] 2>/dev/null; then
+ printf " ${GREEN}●${RESET} %-32s ${GREEN}%-10s${RESET} %s\n" "$svc" "running" "$pid"
+ else
+ printf " ${RED}○${RESET} %-32s ${RED}%-10s${RESET} %s\n" "$svc" "stopped" "-"
+ fi
+ done
+
+ # Docker services
+ echo ""
+ local containers=("homeai-open-webui" "homeai-uptime-kuma" "homeai-n8n" "homeai-code-server")
+ for c in "${containers[@]}"; do
+ local state
+ state=$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo "not found")
+ if [[ "$state" == "running" ]]; then
+ printf " ${GREEN}●${RESET} %-32s ${GREEN}%-10s${RESET}\n" "$c" "$state"
+ else
+ printf " ${RED}○${RESET} %-32s ${RED}%-10s${RESET}\n" "$c" "$state"
+ fi
+ done
+
+ echo ""
+}
+
+# ─── Main ─────────────────────────────────────────────────────────────────────
+
+main() {
+ local cmd="${1:-start}"
+
+ case "$cmd" in
+ start) do_start ;;
+ stop) do_stop ;;
+ restart) do_stop; echo ""; do_start ;;
+ status) do_status ;;
+ -h|--help|help)
+ echo ""
+ echo " Usage: $0 [start|stop|restart|status]"
+ echo ""
+ echo " Commands:"
+ echo " start Start all HomeAI services (default)"
+ echo " stop Stop all HomeAI services"
+ echo " restart Stop then start all services"
+ echo " status Show current service status"
+ echo ""
+ ;;
+ *) die "Unknown command: $cmd. Run '$0 help' for usage." ;;
+ esac
+}
+
+main "$@"