feat: unified HomeAI dashboard — merge character + desktop into single app

Combines homeai-character (service status, character profiles, editor) and
homeai-desktop (chat with voice I/O) into homeai-dashboard on port 5173.

- 4-page sidebar layout: Dashboard, Chat, Characters, Editor
- Merged Vite middleware: health checks, service restart, bridge proxy
- Bridge upgraded to ThreadingHTTPServer (fixes LAN request queuing)
- TTS strips emojis before synthesis
- Updated start.sh with new launchd service names
- Added preload-models to startup sequence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-13 18:40:04 +00:00
parent 0c33de607f
commit 3c0d905e64
37 changed files with 5158 additions and 2 deletions

View File

@@ -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
---

View File

@@ -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")

View File

@@ -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
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

View 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."
}

View 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>

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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

View 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"
}
]
}

View 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" }
}
}

View 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;

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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'}
/>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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 }
}

View 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 }
}

View 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 }
}

View 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 }
}

View 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);
}

View 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
}

View 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
}
}

View 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))
}
}

View 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',
}

View 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>,
)

View 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>
);
}

View 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>
)
}

View 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>
);
}

View 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>
);
}

View 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,
},
})

221
start.sh Executable file
View 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 "$@"