diff --git a/TODO.md b/TODO.md index 0fefc52..95a1f48 100644 --- a/TODO.md +++ b/TODO.md @@ -92,6 +92,10 @@ - [x] Record or source voice reference audio for Aria (`~/voices/aria.wav`) - [x] Pre-process audio with ffmpeg, test with Chatterbox - [x] Update `aria.json` with voice clone path if quality is good +- [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 +- [ ] Deploy dashboard as Docker container or static site on Mac Mini --- diff --git a/homeai-agent/openclaw-http-bridge.py b/homeai-agent/openclaw-http-bridge.py index 1dc45d5..1d3e3b2 100644 --- a/homeai-agent/openclaw-http-bridge.py +++ b/homeai-agent/openclaw-http-bridge.py @@ -33,7 +33,7 @@ from pathlib import Path import wave import io from wyoming.client import AsyncTcpClient -from wyoming.tts import Synthesize +from wyoming.tts import Synthesize, SynthesizeVoice from wyoming.audio import AudioStart, AudioChunk, AudioStop from wyoming.info import Info @@ -135,7 +135,7 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler): await client.read_event() # Send Synthesize event - await client.write_event(Synthesize(text=text, voice=voice).event()) + await client.write_event(Synthesize(text=text, voice=SynthesizeVoice(name=voice)).event()) audio_data = bytearray() rate = 24000 diff --git a/homeai-character/index.html b/homeai-character/index.html index d997ef7..ffc89d4 100644 --- a/homeai-character/index.html +++ b/homeai-character/index.html @@ -4,9 +4,9 @@ - homeai-character + HomeAI Dashboard - +
diff --git a/homeai-character/package-lock.json b/homeai-character/package-lock.json index 063ffeb..f71527f 100644 --- a/homeai-character/package-lock.json +++ b/homeai-character/package-lock.json @@ -12,6 +12,7 @@ "ajv": "^8.18.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1", "tailwindcss": "^4.2.1" }, "devDependencies": { @@ -1765,6 +1766,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2960,6 +2974,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3034,6 +3086,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/homeai-character/package.json b/homeai-character/package.json index cc7c3b6..991b976 100644 --- a/homeai-character/package.json +++ b/homeai-character/package.json @@ -14,6 +14,7 @@ "ajv": "^8.18.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1", "tailwindcss": "^4.2.1" }, "devDependencies": { diff --git a/homeai-character/src/App.css b/homeai-character/src/App.css index b9d355d..f34a439 100644 --- a/homeai-character/src/App.css +++ b/homeai-character/src/App.css @@ -1,42 +1,22 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; +/* Scrollbar styling for dark theme */ +::-webkit-scrollbar { + width: 8px; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); +::-webkit-scrollbar-track { + background: #0a0a0f; } -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +::-webkit-scrollbar-thumb { + background: #374151; + border-radius: 4px; } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } +::-webkit-scrollbar-thumb:hover { + background: #4b5563; } -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; +/* Selection color */ +::selection { + background: rgba(99, 102, 241, 0.3); } diff --git a/homeai-character/src/App.jsx b/homeai-character/src/App.jsx index 07debfa..f46417f 100644 --- a/homeai-character/src/App.jsx +++ b/homeai-character/src/App.jsx @@ -1,11 +1,112 @@ -import CharacterManager from './CharacterManager' +import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom'; +import ServiceStatus from './ServiceStatus'; +import CharacterProfiles from './CharacterProfiles'; +import CharacterManager from './CharacterManager'; + +function NavItem({ to, children, icon }) { + return ( + + `flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${ + isActive + ? 'bg-gray-800 text-white' + : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/50' + }` + } + > + {icon} + {children} + + ); +} + +function Layout({ children }) { + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+
+ {children} +
+
+
+ ); +} function App() { return ( -
- -
- ) + + + + } /> + } /> + } /> + + + + ); } -export default App +export default App; diff --git a/homeai-character/src/CharacterManager.jsx b/homeai-character/src/CharacterManager.jsx index c42b49d..e43c6ef 100644 --- a/homeai-character/src/CharacterManager.jsx +++ b/homeai-character/src/CharacterManager.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { validateCharacter } from './SchemaValidator'; const DEFAULT_CHARACTER = { @@ -38,9 +38,28 @@ const DEFAULT_CHARACTER = { }; export default function CharacterManager() { - const [character, setCharacter] = useState(DEFAULT_CHARACTER); + const [character, setCharacter] = useState(() => { + // Check if we're editing from profiles page + 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'); // idle | loading | playing + 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([]); @@ -52,19 +71,15 @@ export default function CharacterManager() { 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); @@ -75,24 +90,60 @@ export default function CharacterManager() { } }; - // Automatically fetch if key exists on load - React.useEffect(() => { + useEffect(() => { if (elevenLabsApiKey && character.tts.engine === 'elevenlabs') { fetchElevenLabsData(elevenLabsApiKey); } }, [character.tts.engine]); + // Cleanup audio on unmount + 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 downloadAnchorNode = document.createElement('a'); - downloadAnchorNode.setAttribute("href", dataStr); - downloadAnchorNode.setAttribute("download", `${character.name || 'character'}.json`); - document.body.appendChild(downloadAnchorNode); - downloadAnchorNode.click(); - downloadAnchorNode.remove(); + 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); } @@ -149,27 +200,49 @@ export default function CharacterManager() { }); }; + 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 () => { - const text = `Hi, I am ${character.display_name}. This is a preview of my voice.`; - + 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('http://localhost:8081/api/tts', { + 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('Failed to fetch Kokoro audio'); - - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const audio = new Audio(url); - audio.play(); + 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(() => { /* interrupted — stopPreview already handles cleanup */ }); } else { runBrowserTTS(text); } @@ -181,101 +254,143 @@ export default function CharacterManager() { 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 ( -
+
-

HomeAI Character Manager

-
-
- {error &&
{error}
} + {error && ( +
+ {error} +
+ )}
-
-

Basic Info

+ {/* Basic Info */} +
+

Basic Info

- - handleChange('name', e.target.value)} /> + + handleChange('name', e.target.value)} />
- - handleChange('display_name', e.target.value)} /> + + handleChange('display_name', e.target.value)} />
- - handleChange('description', e.target.value)} /> + + handleChange('description', e.target.value)} />
-
-

TTS Configuration

+ {/* TTS Configuration */} +
+

TTS Configuration

- - handleNestedChange('tts', 'engine', e.target.value)}>
+ {character.tts.engine === 'elevenlabs' && ( -
+
-
- setElevenLabsApiKey(e.target.value)} /> -
- -
-
- - {elevenLabsVoices.length > 0 ? ( - - ) : ( - handleNestedChange('tts', 'elevenlabs_voice_id', e.target.value)} placeholder="e.g. 21m00Tcm4TlvDq8ikWAM" /> - )} -
-
- - {elevenLabsModels.length > 0 ? ( - - ) : ( - handleNestedChange('tts', 'elevenlabs_model', e.target.value)} placeholder="e.g. eleven_monolingual_v1" /> - )} -
+
+ + {elevenLabsVoices.length > 0 ? ( + + ) : ( + handleNestedChange('tts', 'elevenlabs_voice_id', e.target.value)} placeholder="e.g. 21m00Tcm4TlvDq8ikWAM" /> + )} +
+
+ + {elevenLabsModels.length > 0 ? ( + + ) : ( + handleNestedChange('tts', 'elevenlabs_model', e.target.value)} placeholder="e.g. eleven_monolingual_v1" /> + )}
)} + {character.tts.engine === 'kokoro' && (
- - handleNestedChange('tts', 'kokoro_voice', e.target.value)}> @@ -307,54 +422,96 @@ export default function CharacterManager() {
)} + {character.tts.engine === 'chatterbox' && (
- - handleNestedChange('tts', 'voice_ref_path', e.target.value)} /> + + handleNestedChange('tts', 'voice_ref_path', e.target.value)} />
)} +
- - handleNestedChange('tts', 'speed', parseFloat(e.target.value))} /> + + handleNestedChange('tts', 'speed', parseFloat(e.target.value))} />
- -

Note: Kokoro previews are fetched from the local bridge. Others use browser TTS for speed testing.

+
+ + setPreviewText(e.target.value)} + placeholder={`Hi, I am ${character.display_name}. This is a preview of my voice.`} + /> +
+
+ + {ttsState !== 'idle' && ( + + )} +
+

+ {character.tts.engine === 'kokoro' + ? 'Previews via local Kokoro TTS bridge (port 8081 → Wyoming 10301).' + : 'Uses browser TTS for preview. Local TTS available with Kokoro engine.'} +

-
+ {/* System Prompt */} +
-

System Prompt

- {character.system_prompt.length} chars +

System Prompt

+ {character.system_prompt.length} chars
-