Compare commits
3 Commits
voice-pipe
...
3c0d905e64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c0d905e64 | ||
|
|
0c33de607f | ||
|
|
2d063c7db7 |
1
TODO.md
1
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
|
||||
|
||||
---
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.homeai.character-dashboard</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/bin/npx</string>
|
||||
<string>vite</string>
|
||||
<string>--host</string>
|
||||
<string>--port</string>
|
||||
<string>5173</string>
|
||||
</array>
|
||||
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/aodhan/gitea/homeai/homeai-character</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>HOME</key>
|
||||
<string>/Users/aodhan</string>
|
||||
</dict>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/homeai-character-dashboard.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/homeai-character-dashboard-error.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
2
homeai-dashboard/.gitignore
vendored
Normal file
2
homeai-dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
49
homeai-dashboard/characters/aria.json
Normal file
49
homeai-dashboard/characters/aria.json
Normal file
@@ -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."
|
||||
}
|
||||
15
homeai-dashboard/index.html
Normal file
15
homeai-dashboard/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#030712" />
|
||||
<title>HomeAI Dashboard</title>
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
41
homeai-dashboard/launchd/com.homeai.dashboard.plist
Normal file
41
homeai-dashboard/launchd/com.homeai.dashboard.plist
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.homeai.dashboard</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/bin/npx</string>
|
||||
<string>vite</string>
|
||||
<string>--host</string>
|
||||
<string>--port</string>
|
||||
<string>5173</string>
|
||||
</array>
|
||||
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/aodhan/gitea/homeai/homeai-dashboard</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>HOME</key>
|
||||
<string>/Users/aodhan</string>
|
||||
</dict>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/homeai-dashboard.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/homeai-dashboard-error.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
2229
homeai-dashboard/package-lock.json
generated
Normal file
2229
homeai-dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
homeai-dashboard/package.json
Normal file
26
homeai-dashboard/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
9
homeai-dashboard/public/icon.svg
Normal file
9
homeai-dashboard/public/icon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#030712"/>
|
||||
<circle cx="32" cy="28" r="12" fill="none" stroke="#818cf8" stroke-width="2.5"/>
|
||||
<path d="M26 26c0-3.3 2.7-6 6-6s6 2.7 6 6" fill="none" stroke="#818cf8" stroke-width="2" stroke-linecap="round"/>
|
||||
<rect x="30" y="40" width="4" height="8" rx="2" fill="#818cf8"/>
|
||||
<path d="M24 52h16" stroke="#818cf8" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="29" cy="27" r="1.5" fill="#34d399"/>
|
||||
<circle cx="35" cy="27" r="1.5" fill="#34d399"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 575 B |
16
homeai-dashboard/public/manifest.json
Normal file
16
homeai-dashboard/public/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
82
homeai-dashboard/schema/character.schema.json
Normal file
82
homeai-dashboard/schema/character.schema.json
Normal file
@@ -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" }
|
||||
}
|
||||
}
|
||||
123
homeai-dashboard/src/App.jsx
Normal file
123
homeai-dashboard/src/App.jsx
Normal file
@@ -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 (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`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}
|
||||
<span>{children}</span>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function Layout({ children }) {
|
||||
return (
|
||||
<div className="h-screen bg-gray-950 flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="px-6 py-5 border-b border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white tracking-tight">HomeAI</h1>
|
||||
<p className="text-xs text-gray-500">LINDBLUM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||
<NavItem
|
||||
to="/"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Dashboard
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
to="/chat"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Chat
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
to="/characters"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Characters
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
to="/editor"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Editor
|
||||
</NavItem>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-800">
|
||||
<p className="text-xs text-gray-600">HomeAI v0.1.0</p>
|
||||
<p className="text-xs text-gray-700">Mac Mini M4 Pro</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<div className="flex-1 overflow-y-auto p-8"><div className="max-w-6xl mx-auto"><Dashboard /></div></div>} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/characters" element={<div className="flex-1 overflow-y-auto p-8"><div className="max-w-6xl mx-auto"><Characters /></div></div>} />
|
||||
<Route path="/editor" element={<div className="flex-1 overflow-y-auto p-8"><div className="max-w-6xl mx-auto"><Editor /></div></div>} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
35
homeai-dashboard/src/components/ChatPanel.jsx
Normal file
35
homeai-dashboard/src/components/ChatPanel.jsx
Normal file
@@ -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 (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-indigo-600/20 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-indigo-400 text-2xl">AI</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-medium text-gray-200 mb-2">Hi, I'm Aria</h2>
|
||||
<p className="text-gray-500 text-sm">Type a message or press the mic to talk</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} onReplay={onReplay} />
|
||||
))}
|
||||
{isLoading && <ThinkingIndicator />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
homeai-dashboard/src/components/InputBar.jsx
Normal file
53
homeai-dashboard/src/components/InputBar.jsx
Normal file
@@ -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 (
|
||||
<form onSubmit={handleSubmit} className="border-t border-gray-800 bg-gray-950 px-4 py-3 shrink-0">
|
||||
<div className="flex items-end gap-2 max-w-3xl mx-auto">
|
||||
<VoiceButton
|
||||
isRecording={isRecording}
|
||||
isTranscribing={isTranscribing}
|
||||
onToggle={onVoiceToggle}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
rows={1}
|
||||
className="flex-1 bg-gray-800 text-gray-100 rounded-xl px-4 py-2.5 text-sm resize-none placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 min-h-[42px] max-h-32"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!text.trim() || isLoading}
|
||||
className="w-10 h-10 rounded-full bg-indigo-600 text-white flex items-center justify-center shrink-0 hover:bg-indigo-500 disabled:opacity-40 disabled:hover:bg-indigo-600 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
39
homeai-dashboard/src/components/MessageBubble.jsx
Normal file
39
homeai-dashboard/src/components/MessageBubble.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
export default function MessageBubble({ message, onReplay }) {
|
||||
const isUser = message.role === 'user'
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} px-4 py-1.5`}>
|
||||
<div className={`flex items-start gap-3 max-w-[80%] ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||
{!isUser && (
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-600/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span className="text-indigo-400 text-sm">AI</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div
|
||||
className={`rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
|
||||
isUser
|
||||
? 'bg-indigo-600 text-white'
|
||||
: message.isError
|
||||
? 'bg-red-900/40 text-red-200 border border-red-800/50'
|
||||
: 'bg-gray-800 text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
{!isUser && !message.isError && onReplay && (
|
||||
<button
|
||||
onClick={() => onReplay(message.content)}
|
||||
className="mt-1 ml-1 text-gray-500 hover:text-indigo-400 transition-colors"
|
||||
title="Replay audio"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M6.5 9H4a1 1 0 00-1 1v4a1 1 0 001 1h2.5l4 4V5l-4 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
homeai-dashboard/src/components/SettingsDrawer.jsx
Normal file
74
homeai-dashboard/src/components/SettingsDrawer.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { VOICES } from '../lib/constants'
|
||||
|
||||
export default function SettingsDrawer({ isOpen, onClose, settings, onUpdate }) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
|
||||
<div className="fixed right-0 top-0 bottom-0 w-80 bg-gray-900 border-l border-gray-800 z-50 flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800">
|
||||
<h2 className="text-sm font-medium text-gray-200">Settings</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-300">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-5">
|
||||
{/* Voice */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5">Voice</label>
|
||||
<select
|
||||
value={settings.voice}
|
||||
onChange={(e) => 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) => (
|
||||
<option key={v.id} value={v.id}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Auto TTS */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-200">Auto-speak responses</div>
|
||||
<div className="text-xs text-gray-500">Speak assistant replies aloud</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onUpdate('autoTts', !settings.autoTts)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors ${
|
||||
settings.autoTts ? 'bg-indigo-600' : 'bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white transition-transform ${
|
||||
settings.autoTts ? 'translate-x-4' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* STT Mode */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5">Speech recognition</label>
|
||||
<select
|
||||
value={settings.sttMode}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="bridge">Wyoming STT (local)</option>
|
||||
<option value="webspeech">Web Speech API (browser)</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{settings.sttMode === 'bridge'
|
||||
? 'Uses Whisper via the local bridge server'
|
||||
: 'Uses browser built-in speech recognition'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
11
homeai-dashboard/src/components/StatusIndicator.jsx
Normal file
11
homeai-dashboard/src/components/StatusIndicator.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function StatusIndicator({ isOnline }) {
|
||||
if (isOnline === null) {
|
||||
return <span className="inline-block w-2.5 h-2.5 rounded-full bg-gray-500 animate-pulse" title="Checking..." />
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-full ${isOnline ? 'bg-emerald-400' : 'bg-red-400'}`}
|
||||
title={isOnline ? 'Bridge online' : 'Bridge offline'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
14
homeai-dashboard/src/components/ThinkingIndicator.jsx
Normal file
14
homeai-dashboard/src/components/ThinkingIndicator.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-600/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-indigo-400 text-sm">AI</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pt-2.5">
|
||||
<span className="w-2 h-2 rounded-full bg-gray-400 animate-[bounce_1.4s_ease-in-out_infinite]" />
|
||||
<span className="w-2 h-2 rounded-full bg-gray-400 animate-[bounce_1.4s_ease-in-out_0.2s_infinite]" />
|
||||
<span className="w-2 h-2 rounded-full bg-gray-400 animate-[bounce_1.4s_ease-in-out_0.4s_infinite]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
homeai-dashboard/src/components/VoiceButton.jsx
Normal file
32
homeai-dashboard/src/components/VoiceButton.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
export default function VoiceButton({ isRecording, isTranscribing, onToggle, disabled }) {
|
||||
const handleClick = () => {
|
||||
if (disabled || isTranscribing) return
|
||||
onToggle()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={disabled || isTranscribing}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all shrink-0 ${
|
||||
isRecording
|
||||
? 'bg-red-500 text-white shadow-[0_0_0_4px_rgba(239,68,68,0.3)] animate-pulse'
|
||||
: isTranscribing
|
||||
? 'bg-gray-700 text-gray-400 cursor-wait'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
|
||||
}`}
|
||||
title={isRecording ? 'Stop recording' : isTranscribing ? 'Transcribing...' : 'Start recording (Space)'}
|
||||
>
|
||||
{isTranscribing ? (
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
18
homeai-dashboard/src/hooks/useBridgeHealth.js
Normal file
18
homeai-dashboard/src/hooks/useBridgeHealth.js
Normal file
@@ -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
|
||||
}
|
||||
45
homeai-dashboard/src/hooks/useChat.js
Normal file
45
homeai-dashboard/src/hooks/useChat.js
Normal file
@@ -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 }
|
||||
}
|
||||
27
homeai-dashboard/src/hooks/useSettings.js
Normal file
27
homeai-dashboard/src/hooks/useSettings.js
Normal file
@@ -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 }
|
||||
}
|
||||
56
homeai-dashboard/src/hooks/useTtsPlayback.js
Normal file
56
homeai-dashboard/src/hooks/useTtsPlayback.js
Normal file
@@ -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 }
|
||||
}
|
||||
91
homeai-dashboard/src/hooks/useVoiceInput.js
Normal file
91
homeai-dashboard/src/hooks/useVoiceInput.js
Normal file
@@ -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 }
|
||||
}
|
||||
35
homeai-dashboard/src/index.css
Normal file
35
homeai-dashboard/src/index.css
Normal file
@@ -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);
|
||||
}
|
||||
13
homeai-dashboard/src/lib/SchemaValidator.js
Normal file
13
homeai-dashboard/src/lib/SchemaValidator.js
Normal file
@@ -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
|
||||
}
|
||||
44
homeai-dashboard/src/lib/api.js
Normal file
44
homeai-dashboard/src/lib/api.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
92
homeai-dashboard/src/lib/audio.js
Normal file
92
homeai-dashboard/src/lib/audio.js
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
37
homeai-dashboard/src/lib/constants.js
Normal file
37
homeai-dashboard/src/lib/constants.js
Normal file
@@ -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',
|
||||
}
|
||||
10
homeai-dashboard/src/main.jsx
Normal file
10
homeai-dashboard/src/main.jsx
Normal file
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
292
homeai-dashboard/src/pages/Characters.jsx
Normal file
292
homeai-dashboard/src/pages/Characters.jsx
Normal file
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-100">Characters</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{profiles.length} profile{profiles.length !== 1 ? 's' : ''} stored
|
||||
{activeProfile && (
|
||||
<span className="ml-2 text-emerald-400">
|
||||
Active: {activeProfile.data.display_name || activeProfile.data.name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg cursor-pointer transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Import JSON
|
||||
<input type="file" accept=".json" multiple className="hidden" onChange={handleImport} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-500/50 text-red-300 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => { 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'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-10 h-10 mx-auto text-gray-600 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
<p className="text-gray-500 text-sm">Drop character JSON files here to import</p>
|
||||
</div>
|
||||
|
||||
{/* Profile grid */}
|
||||
{profiles.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<svg className="w-16 h-16 mx-auto text-gray-700 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
<p className="text-gray-500">No character profiles yet. Import a JSON file to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{profiles.map(profile => {
|
||||
const isActive = profile.id === activeId;
|
||||
const char = profile.data;
|
||||
return (
|
||||
<div
|
||||
key={profile.id}
|
||||
className={`relative rounded-xl border overflow-hidden transition-all duration-200 ${
|
||||
isActive
|
||||
? 'border-emerald-500/60 bg-emerald-500/5 ring-1 ring-emerald-500/30'
|
||||
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{/* Image area */}
|
||||
<div className="relative h-48 bg-gray-900 flex items-center justify-center overflow-hidden group">
|
||||
{profile.image ? (
|
||||
<img
|
||||
src={profile.image}
|
||||
alt={char.display_name || char.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-6xl font-bold text-gray-700 select-none">
|
||||
{(char.display_name || char.name || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<label className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer">
|
||||
<div className="text-center">
|
||||
<svg className="w-8 h-8 mx-auto text-white/80 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z" />
|
||||
</svg>
|
||||
<span className="text-xs text-white/70">Change image</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageUpload(profile.id, e)}
|
||||
/>
|
||||
</label>
|
||||
{isActive && (
|
||||
<span className="absolute top-2 right-2 px-2 py-0.5 bg-emerald-500 text-white text-xs font-medium rounded-full">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-200">
|
||||
{char.display_name || char.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{char.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="px-2 py-0.5 bg-gray-700/70 text-gray-400 text-xs rounded-full">
|
||||
{char.tts?.engine || 'kokoro'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-gray-700/70 text-gray-400 text-xs rounded-full">
|
||||
{char.model_overrides?.primary || 'default'}
|
||||
</span>
|
||||
{char.tts?.kokoro_voice && (
|
||||
<span className="px-2 py-0.5 bg-gray-700/70 text-gray-400 text-xs rounded-full">
|
||||
{char.tts.kokoro_voice}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
{!isActive ? (
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
disabled
|
||||
className="flex-1 px-3 py-1.5 bg-gray-700 text-gray-500 text-sm rounded-lg cursor-not-allowed"
|
||||
>
|
||||
Active
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
homeai-dashboard/src/pages/Chat.jsx
Normal file
115
homeai-dashboard/src/pages/Chat.jsx
Normal file
@@ -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 (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Status bar */}
|
||||
<header className="flex items-center justify-between px-4 py-2 border-b border-gray-800/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIndicator isOnline={isOnline} />
|
||||
<span className="text-xs text-gray-500">
|
||||
{isOnline === null ? 'Connecting...' : isOnline ? 'Connected' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={clearHistory}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors px-2 py-1"
|
||||
title="Clear conversation"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
{isPlaying && (
|
||||
<button
|
||||
onClick={stop}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors px-2 py-1"
|
||||
title="Stop speaking"
|
||||
>
|
||||
Stop audio
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors p-1"
|
||||
title="Settings"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Chat area */}
|
||||
<ChatPanel messages={messages} isLoading={isLoading} onReplay={speak} />
|
||||
|
||||
{/* Input */}
|
||||
<InputBar
|
||||
onSend={handleSend}
|
||||
onVoiceToggle={handleVoiceToggle}
|
||||
isLoading={isLoading}
|
||||
isRecording={isRecording}
|
||||
isTranscribing={isTranscribing}
|
||||
/>
|
||||
|
||||
{/* Settings drawer */}
|
||||
<SettingsDrawer
|
||||
isOpen={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
settings={settings}
|
||||
onUpdate={updateSetting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
376
homeai-dashboard/src/pages/Dashboard.jsx
Normal file
376
homeai-dashboard/src/pages/Dashboard.jsx
Normal file
@@ -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': (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
),
|
||||
'Agent': (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 002.25-2.25V6.75a2.25 2.25 0 00-2.25-2.25H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25zm.75-12h9v9h-9v-9z" />
|
||||
</svg>
|
||||
),
|
||||
'Voice': (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
),
|
||||
'Smart Home': (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
||||
),
|
||||
'Infrastructure': (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
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 (
|
||||
<span className={`inline-block w-2.5 h-2.5 rounded-full shadow-lg ${colors[status] || colors.unknown}`} />
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-100">Service Status</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{onlineCount}/{totalCount} services online
|
||||
{lastRefresh && (
|
||||
<span className="ml-3">
|
||||
Last check: {lastRefresh.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={refreshAll}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg border border-gray-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary bar */}
|
||||
<div className="h-2 rounded-full bg-gray-800 overflow-hidden flex">
|
||||
{allOnline ? (
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-500 to-indigo-500 transition-all duration-500"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-400 transition-all duration-500"
|
||||
style={{ width: `${(onlineCount / totalCount) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-red-500 to-red-400 transition-all duration-500"
|
||||
style={{ width: `${(offlineCount / totalCount) * 100}%` }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service grid by category */}
|
||||
{categories.map(category => (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-gray-400">{CATEGORY_ICONS[category]}</span>
|
||||
<h2 className="text-lg font-semibold text-gray-300">{category}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{SERVICES.filter(s => s.category === category).map(service => {
|
||||
const st = statuses[service.name] || { status: 'unknown' };
|
||||
return (
|
||||
<div
|
||||
key={service.name}
|
||||
className={`relative rounded-xl border p-4 transition-all duration-200 ${
|
||||
st.status === 'online'
|
||||
? 'bg-gray-800/50 border-gray-700 hover:border-emerald-500/50'
|
||||
: st.status === 'offline'
|
||||
? 'bg-gray-800/50 border-red-500/30 hover:border-red-500/50'
|
||||
: 'bg-gray-800/50 border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot status={st.status} />
|
||||
<h3 className="font-medium text-gray-200">{service.name}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{service.description}</p>
|
||||
{st.responseTime !== null && (
|
||||
<p className="text-xs text-gray-600 mt-0.5">{st.responseTime}ms</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{service.restart && st.status === 'offline' && (
|
||||
<button
|
||||
onClick={() => 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] ? (
|
||||
<>
|
||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Restarting
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728M12 9v3m0 0v3m0-3h3m-3 0H9" />
|
||||
</svg>
|
||||
Restart
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{service.uiUrl && (
|
||||
<a
|
||||
href={service.uiUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs px-2.5 py-1 rounded-md bg-gray-700 hover:bg-gray-600 text-gray-300 transition-colors flex items-center gap-1"
|
||||
>
|
||||
Open
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
581
homeai-dashboard/src/pages/Editor.jsx
Normal file
581
homeai-dashboard/src/pages/Editor.jsx
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-100">Character Editor</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Editing: {character.display_name || character.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<label className="cursor-pointer flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg border border-gray-700 transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
Import
|
||||
<input type="file" accept=".json" className="hidden" onChange={handleImport} />
|
||||
</label>
|
||||
<button
|
||||
onClick={handleSaveToProfiles}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
saved
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{saved
|
||||
? <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
: <path strokeLinecap="round" strokeLinejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z" />
|
||||
}
|
||||
</svg>
|
||||
{saved ? 'Saved' : 'Save to Profiles'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg border border-gray-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
Export JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-500/50 text-red-300 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className={cardClass}>
|
||||
<h2 className="text-lg font-semibold text-gray-200">Basic Info</h2>
|
||||
<div>
|
||||
<label className={labelClass}>Name (ID)</label>
|
||||
<input type="text" className={inputClass} value={character.name} onChange={(e) => handleChange('name', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Display Name</label>
|
||||
<input type="text" className={inputClass} value={character.display_name} onChange={(e) => handleChange('display_name', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Description</label>
|
||||
<input type="text" className={inputClass} value={character.description} onChange={(e) => handleChange('description', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TTS Configuration */}
|
||||
<div className={cardClass}>
|
||||
<h2 className="text-lg font-semibold text-gray-200">TTS Configuration</h2>
|
||||
<div>
|
||||
<label className={labelClass}>Engine</label>
|
||||
<select className={selectClass} value={character.tts.engine} onChange={(e) => handleNestedChange('tts', 'engine', e.target.value)}>
|
||||
<option value="kokoro">Kokoro</option>
|
||||
<option value="chatterbox">Chatterbox</option>
|
||||
<option value="qwen3">Qwen3</option>
|
||||
<option value="elevenlabs">ElevenLabs</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{character.tts.engine === 'elevenlabs' && (
|
||||
<div className="space-y-4 border border-gray-700 p-4 rounded-lg bg-gray-800/50">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 text-gray-500">ElevenLabs API Key (Local Use Only)</label>
|
||||
<div className="flex gap-2">
|
||||
<input type="password" placeholder="sk_..." className={inputClass + " text-sm"} value={elevenLabsApiKey} onChange={(e) => setElevenLabsApiKey(e.target.value)} />
|
||||
<button onClick={() => 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'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Voice ID</label>
|
||||
{elevenLabsVoices.length > 0 ? (
|
||||
<select className={selectClass} value={character.tts.elevenlabs_voice_id || ''} onChange={(e) => handleNestedChange('tts', 'elevenlabs_voice_id', e.target.value)}>
|
||||
<option value="">-- Select Voice --</option>
|
||||
{elevenLabsVoices.map(v => (
|
||||
<option key={v.voice_id} value={v.voice_id}>{v.name} ({v.category})</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input type="text" className={inputClass} value={character.tts.elevenlabs_voice_id || ''} onChange={(e) => handleNestedChange('tts', 'elevenlabs_voice_id', e.target.value)} placeholder="e.g. 21m00Tcm4TlvDq8ikWAM" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Model</label>
|
||||
{elevenLabsModels.length > 0 ? (
|
||||
<select className={selectClass} value={character.tts.elevenlabs_model || 'eleven_monolingual_v1'} onChange={(e) => handleNestedChange('tts', 'elevenlabs_model', e.target.value)}>
|
||||
<option value="">-- Select Model --</option>
|
||||
{elevenLabsModels.map(m => (
|
||||
<option key={m.model_id} value={m.model_id}>{m.name} ({m.model_id})</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input type="text" className={inputClass} value={character.tts.elevenlabs_model || 'eleven_monolingual_v1'} onChange={(e) => handleNestedChange('tts', 'elevenlabs_model', e.target.value)} placeholder="e.g. eleven_monolingual_v1" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.tts.engine === 'kokoro' && (
|
||||
<div>
|
||||
<label className={labelClass}>Kokoro Voice</label>
|
||||
<select className={selectClass} value={character.tts.kokoro_voice || 'af_heart'} onChange={(e) => handleNestedChange('tts', 'kokoro_voice', e.target.value)}>
|
||||
<option value="af_heart">af_heart (American Female)</option>
|
||||
<option value="af_alloy">af_alloy (American Female)</option>
|
||||
<option value="af_aoede">af_aoede (American Female)</option>
|
||||
<option value="af_bella">af_bella (American Female)</option>
|
||||
<option value="af_jessica">af_jessica (American Female)</option>
|
||||
<option value="af_kore">af_kore (American Female)</option>
|
||||
<option value="af_nicole">af_nicole (American Female)</option>
|
||||
<option value="af_nova">af_nova (American Female)</option>
|
||||
<option value="af_river">af_river (American Female)</option>
|
||||
<option value="af_sarah">af_sarah (American Female)</option>
|
||||
<option value="af_sky">af_sky (American Female)</option>
|
||||
<option value="am_adam">am_adam (American Male)</option>
|
||||
<option value="am_echo">am_echo (American Male)</option>
|
||||
<option value="am_eric">am_eric (American Male)</option>
|
||||
<option value="am_fenrir">am_fenrir (American Male)</option>
|
||||
<option value="am_liam">am_liam (American Male)</option>
|
||||
<option value="am_michael">am_michael (American Male)</option>
|
||||
<option value="am_onyx">am_onyx (American Male)</option>
|
||||
<option value="am_puck">am_puck (American Male)</option>
|
||||
<option value="am_santa">am_santa (American Male)</option>
|
||||
<option value="bf_alice">bf_alice (British Female)</option>
|
||||
<option value="bf_emma">bf_emma (British Female)</option>
|
||||
<option value="bf_isabella">bf_isabella (British Female)</option>
|
||||
<option value="bf_lily">bf_lily (British Female)</option>
|
||||
<option value="bm_daniel">bm_daniel (British Male)</option>
|
||||
<option value="bm_fable">bm_fable (British Male)</option>
|
||||
<option value="bm_george">bm_george (British Male)</option>
|
||||
<option value="bm_lewis">bm_lewis (British Male)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.tts.engine === 'chatterbox' && (
|
||||
<div>
|
||||
<label className={labelClass}>Voice Reference Path</label>
|
||||
<input type="text" className={inputClass} value={character.tts.voice_ref_path || ''} onChange={(e) => handleNestedChange('tts', 'voice_ref_path', e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Speed: {character.tts.speed}</label>
|
||||
<input type="range" min="0.5" max="2.0" step="0.1" className="w-full accent-indigo-500" value={character.tts.speed} onChange={(e) => handleNestedChange('tts', 'speed', parseFloat(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Preview Text</label>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={previewText}
|
||||
onChange={(e) => setPreviewText(e.target.value)}
|
||||
placeholder={`Hi, I am ${character.display_name}. This is a preview of my voice.`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={previewTTS}
|
||||
disabled={ttsState === 'loading'}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
ttsState === 'loading'
|
||||
? 'bg-indigo-800 text-indigo-300 cursor-wait'
|
||||
: ttsState === 'playing'
|
||||
? 'bg-emerald-600 hover:bg-emerald-500 text-white'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 text-white'
|
||||
}`}
|
||||
>
|
||||
{ttsState === 'loading' && (
|
||||
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{ttsState === 'loading' ? 'Synthesizing...' : ttsState === 'playing' ? 'Playing...' : 'Preview Voice'}
|
||||
</button>
|
||||
{ttsState !== 'idle' && (
|
||||
<button
|
||||
onClick={stopPreview}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
{character.tts.engine === 'kokoro'
|
||||
? 'Previews via local Kokoro TTS bridge (port 8081).'
|
||||
: 'Uses browser TTS for preview. Local TTS available with Kokoro engine.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div className={cardClass}>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-gray-200">System Prompt</h2>
|
||||
<span className="text-xs text-gray-600">{character.system_prompt.length} chars</span>
|
||||
</div>
|
||||
<textarea
|
||||
className={inputClass + " h-32 resize-y"}
|
||||
value={character.system_prompt}
|
||||
onChange={(e) => handleChange('system_prompt', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Live2D Expressions */}
|
||||
<div className={cardClass}>
|
||||
<h2 className="text-lg font-semibold text-gray-200">Live2D Expressions</h2>
|
||||
{Object.entries(character.live2d_expressions).map(([key, val]) => (
|
||||
<div key={key} className="flex justify-between items-center gap-4">
|
||||
<label className="text-sm font-medium text-gray-400 w-1/3 capitalize">{key}</label>
|
||||
<input type="text" className={inputClass + " w-2/3"} value={val} onChange={(e) => handleNestedChange('live2d_expressions', key, e.target.value)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Model Overrides */}
|
||||
<div className={cardClass}>
|
||||
<h2 className="text-lg font-semibold text-gray-200">Model Overrides</h2>
|
||||
<div>
|
||||
<label className={labelClass}>Primary Model</label>
|
||||
<select className={selectClass} value={character.model_overrides?.primary || 'llama3.3:70b'} onChange={(e) => handleNestedChange('model_overrides', 'primary', e.target.value)}>
|
||||
<option value="llama3.3:70b">llama3.3:70b</option>
|
||||
<option value="qwen3.5:35b-a3b">qwen3.5:35b-a3b</option>
|
||||
<option value="qwen2.5:7b">qwen2.5:7b</option>
|
||||
<option value="qwen3:32b">qwen3:32b</option>
|
||||
<option value="codestral:22b">codestral:22b</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Fast Model</label>
|
||||
<select className={selectClass} value={character.model_overrides?.fast || 'qwen2.5:7b'} onChange={(e) => handleNestedChange('model_overrides', 'fast', e.target.value)}>
|
||||
<option value="qwen2.5:7b">qwen2.5:7b</option>
|
||||
<option value="qwen3.5:35b-a3b">qwen3.5:35b-a3b</option>
|
||||
<option value="llama3.3:70b">llama3.3:70b</option>
|
||||
<option value="qwen3:32b">qwen3:32b</option>
|
||||
<option value="codestral:22b">codestral:22b</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Rules */}
|
||||
<div className={cardClass}>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-gray-200">Custom Rules</h2>
|
||||
<button onClick={addRule} className="flex items-center gap-1 bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded-lg text-sm transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(!character.custom_rules || character.custom_rules.length === 0) ? (
|
||||
<p className="text-sm text-gray-600 italic">No custom rules defined.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{character.custom_rules.map((rule, idx) => (
|
||||
<div key={idx} className="border border-gray-700 p-4 rounded-lg relative bg-gray-800/50">
|
||||
<button
|
||||
onClick={() => removeRule(idx)}
|
||||
className="absolute top-3 right-3 text-gray-500 hover:text-red-400 transition-colors"
|
||||
title="Remove Rule"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-1">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 text-gray-500">Trigger</label>
|
||||
<input type="text" className={inputClass + " text-sm"} value={rule.trigger || ''} onChange={(e) => handleRuleChange(idx, 'trigger', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 text-gray-500">Condition (Optional)</label>
|
||||
<input type="text" className={inputClass + " text-sm"} value={rule.condition || ''} onChange={(e) => handleRuleChange(idx, 'condition', e.target.value)} placeholder="e.g. time_of_day == morning" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium mb-1 text-gray-500">Response</label>
|
||||
<textarea className={inputClass + " text-sm h-16 resize-y"} value={rule.response || ''} onChange={(e) => handleRuleChange(idx, 'response', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
homeai-dashboard/vite.config.js
Normal file
201
homeai-dashboard/vite.config.js
Normal file
@@ -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,
|
||||
},
|
||||
})
|
||||
2
homeai-desktop/.gitignore
vendored
Normal file
2
homeai-desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
15
homeai-desktop/index.html
Normal file
15
homeai-desktop/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#030712" />
|
||||
<title>HomeAI Assistant</title>
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
homeai-desktop/launchd/com.homeai.desktop-assistant.plist
Normal file
31
homeai-desktop/launchd/com.homeai.desktop-assistant.plist
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.homeai.desktop-assistant</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/bin/npx</string>
|
||||
<string>vite</string>
|
||||
<string>--host</string>
|
||||
<string>--port</string>
|
||||
<string>5174</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/aodhan/gitea/homeai/homeai-desktop</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/homeai-desktop-assistant.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/homeai-desktop-assistant-error.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
2117
homeai-desktop/package-lock.json
generated
Normal file
2117
homeai-desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
homeai-desktop/package.json
Normal file
24
homeai-desktop/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "homeai-desktop",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwindcss": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"vite": "^8.0.0-beta.13"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "^8.0.0-beta.13"
|
||||
}
|
||||
}
|
||||
9
homeai-desktop/public/icon.svg
Normal file
9
homeai-desktop/public/icon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#030712"/>
|
||||
<circle cx="32" cy="28" r="12" fill="none" stroke="#818cf8" stroke-width="2.5"/>
|
||||
<path d="M26 26c0-3.3 2.7-6 6-6s6 2.7 6 6" fill="none" stroke="#818cf8" stroke-width="2" stroke-linecap="round"/>
|
||||
<rect x="30" y="40" width="4" height="8" rx="2" fill="#818cf8"/>
|
||||
<path d="M24 52h16" stroke="#818cf8" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="29" cy="27" r="1.5" fill="#34d399"/>
|
||||
<circle cx="35" cy="27" r="1.5" fill="#34d399"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 575 B |
16
homeai-desktop/public/manifest.json
Normal file
16
homeai-desktop/public/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "HomeAI Assistant",
|
||||
"short_name": "HomeAI",
|
||||
"description": "Desktop AI assistant powered by local LLMs",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#030712",
|
||||
"theme_color": "#030712",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
115
homeai-desktop/src/App.jsx
Normal file
115
homeai-desktop/src/App.jsx
Normal file
@@ -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 App() {
|
||||
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 (
|
||||
<div className="h-screen flex flex-col bg-gray-950">
|
||||
{/* Status bar */}
|
||||
<header className="flex items-center justify-between px-4 py-2 border-b border-gray-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIndicator isOnline={isOnline} />
|
||||
<span className="text-xs text-gray-500">
|
||||
{isOnline === null ? 'Connecting...' : isOnline ? 'Connected' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={clearHistory}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors px-2 py-1"
|
||||
title="Clear conversation"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
{isPlaying && (
|
||||
<button
|
||||
onClick={stop}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors px-2 py-1"
|
||||
title="Stop speaking"
|
||||
>
|
||||
Stop audio
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors p-1"
|
||||
title="Settings"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Chat area */}
|
||||
<ChatPanel messages={messages} isLoading={isLoading} onReplay={speak} />
|
||||
|
||||
{/* Input */}
|
||||
<InputBar
|
||||
onSend={handleSend}
|
||||
onVoiceToggle={handleVoiceToggle}
|
||||
isLoading={isLoading}
|
||||
isRecording={isRecording}
|
||||
isTranscribing={isTranscribing}
|
||||
/>
|
||||
|
||||
{/* Settings drawer */}
|
||||
<SettingsDrawer
|
||||
isOpen={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
settings={settings}
|
||||
onUpdate={updateSetting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
homeai-desktop/src/components/ChatPanel.jsx
Normal file
35
homeai-desktop/src/components/ChatPanel.jsx
Normal file
@@ -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 (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-indigo-600/20 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-indigo-400 text-2xl">AI</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-medium text-gray-200 mb-2">Hi, I'm Aria</h2>
|
||||
<p className="text-gray-500 text-sm">Type a message or press the mic to talk</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} onReplay={onReplay} />
|
||||
))}
|
||||
{isLoading && <ThinkingIndicator />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
homeai-desktop/src/components/InputBar.jsx
Normal file
53
homeai-desktop/src/components/InputBar.jsx
Normal file
@@ -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 (
|
||||
<form onSubmit={handleSubmit} className="border-t border-gray-800 bg-gray-950 px-4 py-3">
|
||||
<div className="flex items-end gap-2 max-w-3xl mx-auto">
|
||||
<VoiceButton
|
||||
isRecording={isRecording}
|
||||
isTranscribing={isTranscribing}
|
||||
onToggle={onVoiceToggle}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
rows={1}
|
||||
className="flex-1 bg-gray-800 text-gray-100 rounded-xl px-4 py-2.5 text-sm resize-none placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 min-h-[42px] max-h-32"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!text.trim() || isLoading}
|
||||
className="w-10 h-10 rounded-full bg-indigo-600 text-white flex items-center justify-center shrink-0 hover:bg-indigo-500 disabled:opacity-40 disabled:hover:bg-indigo-600 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
39
homeai-desktop/src/components/MessageBubble.jsx
Normal file
39
homeai-desktop/src/components/MessageBubble.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
export default function MessageBubble({ message, onReplay }) {
|
||||
const isUser = message.role === 'user'
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} px-4 py-1.5`}>
|
||||
<div className={`flex items-start gap-3 max-w-[80%] ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||
{!isUser && (
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-600/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span className="text-indigo-400 text-sm">AI</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div
|
||||
className={`rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
|
||||
isUser
|
||||
? 'bg-indigo-600 text-white'
|
||||
: message.isError
|
||||
? 'bg-red-900/40 text-red-200 border border-red-800/50'
|
||||
: 'bg-gray-800 text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
{!isUser && !message.isError && onReplay && (
|
||||
<button
|
||||
onClick={() => onReplay(message.content)}
|
||||
className="mt-1 ml-1 text-gray-500 hover:text-indigo-400 transition-colors"
|
||||
title="Replay audio"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M6.5 9H4a1 1 0 00-1 1v4a1 1 0 001 1h2.5l4 4V5l-4 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
homeai-desktop/src/components/SettingsDrawer.jsx
Normal file
74
homeai-desktop/src/components/SettingsDrawer.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { VOICES } from '../lib/constants'
|
||||
|
||||
export default function SettingsDrawer({ isOpen, onClose, settings, onUpdate }) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
|
||||
<div className="fixed right-0 top-0 bottom-0 w-80 bg-gray-900 border-l border-gray-800 z-50 flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800">
|
||||
<h2 className="text-sm font-medium text-gray-200">Settings</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-300">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-5">
|
||||
{/* Voice */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5">Voice</label>
|
||||
<select
|
||||
value={settings.voice}
|
||||
onChange={(e) => 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) => (
|
||||
<option key={v.id} value={v.id}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Auto TTS */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-200">Auto-speak responses</div>
|
||||
<div className="text-xs text-gray-500">Speak assistant replies aloud</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onUpdate('autoTts', !settings.autoTts)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors ${
|
||||
settings.autoTts ? 'bg-indigo-600' : 'bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white transition-transform ${
|
||||
settings.autoTts ? 'translate-x-4' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* STT Mode */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5">Speech recognition</label>
|
||||
<select
|
||||
value={settings.sttMode}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="bridge">Wyoming STT (local)</option>
|
||||
<option value="webspeech">Web Speech API (browser)</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{settings.sttMode === 'bridge'
|
||||
? 'Uses Whisper via the local bridge server'
|
||||
: 'Uses browser built-in speech recognition'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
11
homeai-desktop/src/components/StatusIndicator.jsx
Normal file
11
homeai-desktop/src/components/StatusIndicator.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function StatusIndicator({ isOnline }) {
|
||||
if (isOnline === null) {
|
||||
return <span className="inline-block w-2.5 h-2.5 rounded-full bg-gray-500 animate-pulse" title="Checking..." />
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-full ${isOnline ? 'bg-emerald-400' : 'bg-red-400'}`}
|
||||
title={isOnline ? 'Bridge online' : 'Bridge offline'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
14
homeai-desktop/src/components/ThinkingIndicator.jsx
Normal file
14
homeai-desktop/src/components/ThinkingIndicator.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-600/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-indigo-400 text-sm">AI</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pt-2.5">
|
||||
<span className="w-2 h-2 rounded-full bg-gray-400 animate-[bounce_1.4s_ease-in-out_infinite]" />
|
||||
<span className="w-2 h-2 rounded-full bg-gray-400 animate-[bounce_1.4s_ease-in-out_0.2s_infinite]" />
|
||||
<span className="w-2 h-2 rounded-full bg-gray-400 animate-[bounce_1.4s_ease-in-out_0.4s_infinite]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
homeai-desktop/src/components/VoiceButton.jsx
Normal file
32
homeai-desktop/src/components/VoiceButton.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
export default function VoiceButton({ isRecording, isTranscribing, onToggle, disabled }) {
|
||||
const handleClick = () => {
|
||||
if (disabled || isTranscribing) return
|
||||
onToggle()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={disabled || isTranscribing}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all shrink-0 ${
|
||||
isRecording
|
||||
? 'bg-red-500 text-white shadow-[0_0_0_4px_rgba(239,68,68,0.3)] animate-pulse'
|
||||
: isTranscribing
|
||||
? 'bg-gray-700 text-gray-400 cursor-wait'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
|
||||
}`}
|
||||
title={isRecording ? 'Stop recording' : isTranscribing ? 'Transcribing...' : 'Start recording (Space)'}
|
||||
>
|
||||
{isTranscribing ? (
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
18
homeai-desktop/src/hooks/useBridgeHealth.js
Normal file
18
homeai-desktop/src/hooks/useBridgeHealth.js
Normal file
@@ -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
|
||||
}
|
||||
45
homeai-desktop/src/hooks/useChat.js
Normal file
45
homeai-desktop/src/hooks/useChat.js
Normal file
@@ -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 }
|
||||
}
|
||||
27
homeai-desktop/src/hooks/useSettings.js
Normal file
27
homeai-desktop/src/hooks/useSettings.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { DEFAULT_SETTINGS } from '../lib/constants'
|
||||
|
||||
const STORAGE_KEY = 'homeai_desktop_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 }
|
||||
}
|
||||
56
homeai-desktop/src/hooks/useTtsPlayback.js
Normal file
56
homeai-desktop/src/hooks/useTtsPlayback.js
Normal file
@@ -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 }
|
||||
}
|
||||
93
homeai-desktop/src/hooks/useVoiceInput.js
Normal file
93
homeai-desktop/src/hooks/useVoiceInput.js
Normal file
@@ -0,0 +1,93 @@
|
||||
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])
|
||||
|
||||
// Web Speech API fallback
|
||||
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 = () => {
|
||||
// If no result event fired, resolve with null
|
||||
setIsRecording(false)
|
||||
if (webSpeechRef.current) {
|
||||
webSpeechRef.current = null
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
recognition.stop()
|
||||
})
|
||||
}
|
||||
|
||||
return { isRecording, isTranscribing, startRecording, stopRecording }
|
||||
}
|
||||
1
homeai-desktop/src/index.css
Normal file
1
homeai-desktop/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
44
homeai-desktop/src/lib/api.js
Normal file
44
homeai-desktop/src/lib/api.js
Normal file
@@ -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', { signal: AbortSignal.timeout(5000) })
|
||||
const data = await res.json()
|
||||
return data.status === 'online'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
103
homeai-desktop/src/lib/audio.js
Normal file
103
homeai-desktop/src/lib/audio.js
Normal file
@@ -0,0 +1,103 @@
|
||||
const TARGET_RATE = 16000
|
||||
|
||||
/**
|
||||
* Record audio from the microphone, returning a WAV blob when stopped.
|
||||
* Returns { start, stop } — call start() to begin, stop() resolves with a Blob.
|
||||
*/
|
||||
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)
|
||||
|
||||
// ScriptProcessorNode captures raw Float32 PCM
|
||||
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() {
|
||||
// Stop everything
|
||||
processor.disconnect()
|
||||
source.disconnect()
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
await audioCtx.close()
|
||||
|
||||
// Merge all sample chunks
|
||||
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
|
||||
}
|
||||
|
||||
// Resample if the actual sample rate differs from target
|
||||
const resampled = audioCtx.sampleRate !== TARGET_RATE
|
||||
? resample(merged, audioCtx.sampleRate, TARGET_RATE)
|
||||
: merged
|
||||
|
||||
// Convert to 16-bit PCM WAV
|
||||
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)
|
||||
|
||||
// WAV header
|
||||
writeString(view, 0, 'RIFF')
|
||||
view.setUint32(4, 36 + numSamples * 2, true)
|
||||
writeString(view, 8, 'WAVE')
|
||||
writeString(view, 12, 'fmt ')
|
||||
view.setUint32(16, 16, true) // chunk size
|
||||
view.setUint16(20, 1, true) // PCM
|
||||
view.setUint16(22, 1, true) // mono
|
||||
view.setUint32(24, sampleRate, true)
|
||||
view.setUint32(28, sampleRate * 2, true) // byte rate
|
||||
view.setUint16(32, 2, true) // block align
|
||||
view.setUint16(34, 16, true) // bits per sample
|
||||
writeString(view, 36, 'data')
|
||||
view.setUint32(40, numSamples * 2, true)
|
||||
|
||||
// PCM data — clamp Float32 to Int16
|
||||
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))
|
||||
}
|
||||
}
|
||||
37
homeai-desktop/src/lib/constants.js
Normal file
37
homeai-desktop/src/lib/constants.js
Normal file
@@ -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', // 'bridge' or 'webspeech'
|
||||
}
|
||||
10
homeai-desktop/src/main.jsx
Normal file
10
homeai-desktop/src/main.jsx
Normal file
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
102
homeai-desktop/vite.config.js
Normal file
102
homeai-desktop/vite.config.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
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'))
|
||||
|
||||
// Health check — direct to bridge
|
||||
server.middlewares.use('/api/health', async (req, res) => {
|
||||
try {
|
||||
const { default: http } = await import('http')
|
||||
const start = Date.now()
|
||||
await new Promise((resolve, reject) => {
|
||||
const reqObj = http.get('http://localhost:8081/', { 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 }))
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
bridgeProxyPlugin(),
|
||||
tailwindcss(),
|
||||
react(),
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
},
|
||||
})
|
||||
221
start.sh
Executable file
221
start.sh
Executable file
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user