feat: character dashboard with TTS voice preview, fix Wyoming API compat
- Add HomeAI dashboard: service status monitor, character profile manager, character editor - Add TTS voice preview in character editor (Kokoro via OpenClaw bridge → Wyoming) - Custom preview text, loading/playing states, stop control, speed via playbackRate - Fix Wyoming API breaking changes: remove `version` from TtsVoice/TtsProgram, use SynthesizeVoice object instead of bare string in Synthesize calls - Vite dev server proxies /api/tts and /api/health to avoid CORS issues Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
4
TODO.md
4
TODO.md
@@ -92,6 +92,10 @@
|
|||||||
- [x] Record or source voice reference audio for Aria (`~/voices/aria.wav`)
|
- [x] Record or source voice reference audio for Aria (`~/voices/aria.wav`)
|
||||||
- [x] Pre-process audio with ffmpeg, test with Chatterbox
|
- [x] Pre-process audio with ffmpeg, test with Chatterbox
|
||||||
- [x] Update `aria.json` with voice clone path if quality is good
|
- [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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from pathlib import Path
|
|||||||
import wave
|
import wave
|
||||||
import io
|
import io
|
||||||
from wyoming.client import AsyncTcpClient
|
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.audio import AudioStart, AudioChunk, AudioStop
|
||||||
from wyoming.info import Info
|
from wyoming.info import Info
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
|
|||||||
await client.read_event()
|
await client.read_event()
|
||||||
|
|
||||||
# Send Synthesize 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()
|
audio_data = bytearray()
|
||||||
rate = 24000
|
rate = 24000
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>homeai-character</title>
|
<title>HomeAI Dashboard</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-gray-950 text-gray-100">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
58
homeai-character/package-lock.json
generated
58
homeai-character/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"ajv": "^8.18.0",
|
"ajv": "^8.18.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
"tailwindcss": "^4.2.1"
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1765,6 +1766,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2960,6 +2974,44 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -3034,6 +3086,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"ajv": "^8.18.0",
|
"ajv": "^8.18.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
"tailwindcss": "^4.2.1"
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,42 +1,22 @@
|
|||||||
#root {
|
/* Scrollbar styling for dark theme */
|
||||||
max-width: 1280px;
|
::-webkit-scrollbar {
|
||||||
margin: 0 auto;
|
width: 8px;
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
::-webkit-scrollbar-track {
|
||||||
height: 6em;
|
background: #0a0a0f;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes logo-spin {
|
::-webkit-scrollbar-thumb {
|
||||||
from {
|
background: #374151;
|
||||||
transform: rotate(0deg);
|
border-radius: 4px;
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
a:nth-of-type(2) .logo {
|
background: #4b5563;
|
||||||
animation: logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
/* Selection color */
|
||||||
padding: 2em;
|
::selection {
|
||||||
}
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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="min-h-screen bg-gray-950 flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col fixed h-full">
|
||||||
|
{/* 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="/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 ml-64 p-8">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 text-gray-900 py-8">
|
<BrowserRouter>
|
||||||
<CharacterManager />
|
<Layout>
|
||||||
</div>
|
<Routes>
|
||||||
)
|
<Route path="/" element={<ServiceStatus />} />
|
||||||
|
<Route path="/characters" element={<CharacterProfiles />} />
|
||||||
|
<Route path="/editor" element={<CharacterManager />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { validateCharacter } from './SchemaValidator';
|
import { validateCharacter } from './SchemaValidator';
|
||||||
|
|
||||||
const DEFAULT_CHARACTER = {
|
const DEFAULT_CHARACTER = {
|
||||||
@@ -38,8 +38,27 @@ const DEFAULT_CHARACTER = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function CharacterManager() {
|
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 [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
|
// ElevenLabs state
|
||||||
const [elevenLabsApiKey, setElevenLabsApiKey] = useState(localStorage.getItem('elevenlabs_api_key') || '');
|
const [elevenLabsApiKey, setElevenLabsApiKey] = useState(localStorage.getItem('elevenlabs_api_key') || '');
|
||||||
@@ -52,19 +71,15 @@ export default function CharacterManager() {
|
|||||||
setIsLoadingElevenLabs(true);
|
setIsLoadingElevenLabs(true);
|
||||||
try {
|
try {
|
||||||
const headers = { 'xi-api-key': key };
|
const headers = { 'xi-api-key': key };
|
||||||
|
|
||||||
const [voicesRes, modelsRes] = await Promise.all([
|
const [voicesRes, modelsRes] = await Promise.all([
|
||||||
fetch('https://api.elevenlabs.io/v1/voices', { headers }),
|
fetch('https://api.elevenlabs.io/v1/voices', { headers }),
|
||||||
fetch('https://api.elevenlabs.io/v1/models', { headers })
|
fetch('https://api.elevenlabs.io/v1/models', { headers })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!voicesRes.ok || !modelsRes.ok) {
|
if (!voicesRes.ok || !modelsRes.ok) {
|
||||||
throw new Error('Failed to fetch from ElevenLabs API (check API key)');
|
throw new Error('Failed to fetch from ElevenLabs API (check API key)');
|
||||||
}
|
}
|
||||||
|
|
||||||
const voicesData = await voicesRes.json();
|
const voicesData = await voicesRes.json();
|
||||||
const modelsData = await modelsRes.json();
|
const modelsData = await modelsRes.json();
|
||||||
|
|
||||||
setElevenLabsVoices(voicesData.voices || []);
|
setElevenLabsVoices(voicesData.voices || []);
|
||||||
setElevenLabsModels(modelsData.filter(m => m.can_do_text_to_speech) || []);
|
setElevenLabsModels(modelsData.filter(m => m.can_do_text_to_speech) || []);
|
||||||
localStorage.setItem('elevenlabs_api_key', key);
|
localStorage.setItem('elevenlabs_api_key', key);
|
||||||
@@ -75,24 +90,60 @@ export default function CharacterManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Automatically fetch if key exists on load
|
useEffect(() => {
|
||||||
React.useEffect(() => {
|
|
||||||
if (elevenLabsApiKey && character.tts.engine === 'elevenlabs') {
|
if (elevenLabsApiKey && character.tts.engine === 'elevenlabs') {
|
||||||
fetchElevenLabsData(elevenLabsApiKey);
|
fetchElevenLabsData(elevenLabsApiKey);
|
||||||
}
|
}
|
||||||
}, [character.tts.engine]);
|
}, [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 = () => {
|
const handleExport = () => {
|
||||||
try {
|
try {
|
||||||
validateCharacter(character);
|
validateCharacter(character);
|
||||||
setError(null);
|
setError(null);
|
||||||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(character, null, 2));
|
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(character, null, 2));
|
||||||
const downloadAnchorNode = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
downloadAnchorNode.setAttribute("href", dataStr);
|
a.href = dataStr;
|
||||||
downloadAnchorNode.setAttribute("download", `${character.name || 'character'}.json`);
|
a.download = `${character.name || 'character'}.json`;
|
||||||
document.body.appendChild(downloadAnchorNode);
|
document.body.appendChild(a);
|
||||||
downloadAnchorNode.click();
|
a.click();
|
||||||
downloadAnchorNode.remove();
|
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) {
|
} catch (err) {
|
||||||
setError(err.message);
|
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 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') {
|
if (character.tts.engine === 'kokoro') {
|
||||||
|
setTtsState('loading');
|
||||||
|
let blob;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:8081/api/tts', {
|
const response = await fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text, voice: character.tts.kokoro_voice })
|
body: JSON.stringify({ text, voice: character.tts.kokoro_voice })
|
||||||
});
|
});
|
||||||
|
if (!response.ok) throw new Error('TTS bridge returned ' + response.status);
|
||||||
if (!response.ok) throw new Error('Failed to fetch Kokoro audio');
|
blob = await response.blob();
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const audio = new Audio(url);
|
|
||||||
audio.play();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setTtsState('idle');
|
||||||
setError(`Kokoro preview failed: ${err.message}. Falling back to browser TTS.`);
|
setError(`Kokoro preview failed: ${err.message}. Falling back to browser TTS.`);
|
||||||
runBrowserTTS(text);
|
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 {
|
} else {
|
||||||
runBrowserTTS(text);
|
runBrowserTTS(text);
|
||||||
}
|
}
|
||||||
@@ -181,101 +254,143 @@ export default function CharacterManager() {
|
|||||||
const voices = window.speechSynthesis.getVoices();
|
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'));
|
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;
|
if (preferredVoice) utterance.voice = preferredVoice;
|
||||||
|
setTtsState('playing');
|
||||||
|
utterance.onend = () => setTtsState('idle');
|
||||||
window.speechSynthesis.cancel();
|
window.speechSynthesis.cancel();
|
||||||
window.speechSynthesis.speak(utterance);
|
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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h1 className="text-3xl font-bold">HomeAI Character Manager</h1>
|
<div>
|
||||||
<div className="space-x-4">
|
<h1 className="text-3xl font-bold text-gray-100">Character Editor</h1>
|
||||||
<label className="cursor-pointer bg-blue-500 text-white px-4 py-2 rounded shadow hover:bg-blue-600">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
Import JSON
|
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} />
|
<input type="file" accept=".json" className="hidden" onChange={handleImport} />
|
||||||
</label>
|
</label>
|
||||||
<button onClick={handleExport} className="bg-green-500 text-white px-4 py-2 rounded shadow hover:bg-green-600">
|
<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
|
Export JSON
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">{error}</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">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-4 bg-white p-4 shadow rounded">
|
{/* Basic Info */}
|
||||||
<h2 className="text-xl font-semibold">Basic Info</h2>
|
<div className={cardClass}>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-200">Basic Info</h2>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Name (ID)</label>
|
<label className={labelClass}>Name (ID)</label>
|
||||||
<input type="text" className="w-full border p-2 rounded" value={character.name} onChange={(e) => handleChange('name', e.target.value)} />
|
<input type="text" className={inputClass} value={character.name} onChange={(e) => handleChange('name', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Display Name</label>
|
<label className={labelClass}>Display Name</label>
|
||||||
<input type="text" className="w-full border p-2 rounded" value={character.display_name} onChange={(e) => handleChange('display_name', e.target.value)} />
|
<input type="text" className={inputClass} value={character.display_name} onChange={(e) => handleChange('display_name', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Description</label>
|
<label className={labelClass}>Description</label>
|
||||||
<input type="text" className="w-full border p-2 rounded" value={character.description} onChange={(e) => handleChange('description', e.target.value)} />
|
<input type="text" className={inputClass} value={character.description} onChange={(e) => handleChange('description', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 bg-white p-4 shadow rounded">
|
{/* TTS Configuration */}
|
||||||
<h2 className="text-xl font-semibold">TTS Configuration</h2>
|
<div className={cardClass}>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-200">TTS Configuration</h2>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Engine</label>
|
<label className={labelClass}>Engine</label>
|
||||||
<select className="w-full border p-2 rounded" value={character.tts.engine} onChange={(e) => handleNestedChange('tts', 'engine', e.target.value)}>
|
<select className={selectClass} value={character.tts.engine} onChange={(e) => handleNestedChange('tts', 'engine', e.target.value)}>
|
||||||
<option value="kokoro">Kokoro</option>
|
<option value="kokoro">Kokoro</option>
|
||||||
<option value="chatterbox">Chatterbox</option>
|
<option value="chatterbox">Chatterbox</option>
|
||||||
<option value="qwen3">Qwen3</option>
|
<option value="qwen3">Qwen3</option>
|
||||||
<option value="elevenlabs">ElevenLabs</option>
|
<option value="elevenlabs">ElevenLabs</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{character.tts.engine === 'elevenlabs' && (
|
{character.tts.engine === 'elevenlabs' && (
|
||||||
<div className="space-y-4 border p-4 rounded bg-gray-50">
|
<div className="space-y-4 border border-gray-700 p-4 rounded-lg bg-gray-800/50">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium mb-1 text-gray-500">ElevenLabs API Key (Local Use Only)</label>
|
<label className="block text-xs font-medium mb-1 text-gray-500">ElevenLabs API Key (Local Use Only)</label>
|
||||||
<div className="flex space-x-2">
|
<div className="flex gap-2">
|
||||||
<input type="password" placeholder="sk_..." className="w-full border p-2 rounded text-sm" value={elevenLabsApiKey} onChange={(e) => setElevenLabsApiKey(e.target.value)} />
|
<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-blue-500 text-white px-3 py-1 rounded text-sm whitespace-nowrap hover:bg-blue-600 disabled:opacity-50">
|
<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'}
|
{isLoadingElevenLabs ? 'Loading...' : 'Fetch'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Voice ID</label>
|
<label className={labelClass}>Voice ID</label>
|
||||||
{elevenLabsVoices.length > 0 ? (
|
{elevenLabsVoices.length > 0 ? (
|
||||||
<select className="w-full border p-2 rounded" value={character.tts.elevenlabs_voice_id || ''} onChange={(e) => handleNestedChange('tts', 'elevenlabs_voice_id', e.target.value)}>
|
<select className={selectClass} value={character.tts.elevenlabs_voice_id || ''} onChange={(e) => handleNestedChange('tts', 'elevenlabs_voice_id', e.target.value)}>
|
||||||
<option value="">-- Select Voice --</option>
|
<option value="">-- Select Voice --</option>
|
||||||
{elevenLabsVoices.map(v => (
|
{elevenLabsVoices.map(v => (
|
||||||
<option key={v.voice_id} value={v.voice_id}>{v.name} ({v.category})</option>
|
<option key={v.voice_id} value={v.voice_id}>{v.name} ({v.category})</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<input type="text" className="w-full border p-2 rounded" value={character.tts.elevenlabs_voice_id || ''} onChange={(e) => handleNestedChange('tts', 'elevenlabs_voice_id', e.target.value)} placeholder="e.g. 21m00Tcm4TlvDq8ikWAM" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Model</label>
|
<label className={labelClass}>Model</label>
|
||||||
{elevenLabsModels.length > 0 ? (
|
{elevenLabsModels.length > 0 ? (
|
||||||
<select className="w-full border p-2 rounded" value={character.tts.elevenlabs_model || 'eleven_monolingual_v1'} onChange={(e) => handleNestedChange('tts', 'elevenlabs_model', e.target.value)}>
|
<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>
|
<option value="">-- Select Model --</option>
|
||||||
{elevenLabsModels.map(m => (
|
{elevenLabsModels.map(m => (
|
||||||
<option key={m.model_id} value={m.model_id}>{m.name} ({m.model_id})</option>
|
<option key={m.model_id} value={m.model_id}>{m.name} ({m.model_id})</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<input type="text" className="w-full border p-2 rounded" value={character.tts.elevenlabs_model || 'eleven_monolingual_v1'} onChange={(e) => handleNestedChange('tts', 'elevenlabs_model', e.target.value)} placeholder="e.g. eleven_monolingual_v1" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{character.tts.engine === 'kokoro' && (
|
{character.tts.engine === 'kokoro' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Kokoro Voice</label>
|
<label className={labelClass}>Kokoro Voice</label>
|
||||||
<select className="w-full border p-2 rounded" value={character.tts.kokoro_voice || 'af_heart'} onChange={(e) => handleNestedChange('tts', 'kokoro_voice', e.target.value)}>
|
<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_heart">af_heart (American Female)</option>
|
||||||
<option value="af_alloy">af_alloy (American Female)</option>
|
<option value="af_alloy">af_alloy (American Female)</option>
|
||||||
<option value="af_aoede">af_aoede (American Female)</option>
|
<option value="af_aoede">af_aoede (American Female)</option>
|
||||||
@@ -307,54 +422,96 @@ export default function CharacterManager() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{character.tts.engine === 'chatterbox' && (
|
{character.tts.engine === 'chatterbox' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Voice Reference Path</label>
|
<label className={labelClass}>Voice Reference Path</label>
|
||||||
<input type="text" className="w-full border p-2 rounded" value={character.tts.voice_ref_path || ''} onChange={(e) => handleNestedChange('tts', 'voice_ref_path', e.target.value)} />
|
<input type="text" className={inputClass} value={character.tts.voice_ref_path || ''} onChange={(e) => handleNestedChange('tts', 'voice_ref_path', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Speed: {character.tts.speed}</label>
|
<label className={labelClass}>Speed: {character.tts.speed}</label>
|
||||||
<input type="range" min="0.5" max="2.0" step="0.1" className="w-full" value={character.tts.speed} onChange={(e) => handleNestedChange('tts', 'speed', parseFloat(e.target.value))} />
|
<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>
|
||||||
|
<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
|
<button
|
||||||
onClick={previewTTS}
|
onClick={previewTTS}
|
||||||
className="w-full bg-indigo-500 text-white px-4 py-2 rounded shadow hover:bg-indigo-600 transition"
|
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'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
🔊 Preview Voice Speed
|
{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>
|
</button>
|
||||||
<p className="text-xs text-gray-500 mt-1">Note: Kokoro previews are fetched from the local bridge. Others use browser TTS for speed testing.</p>
|
{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 → Wyoming 10301).'
|
||||||
|
: 'Uses browser TTS for preview. Local TTS available with Kokoro engine.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-4 shadow rounded space-y-2">
|
{/* System Prompt */}
|
||||||
|
<div className={cardClass}>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-xl font-semibold">System Prompt</h2>
|
<h2 className="text-lg font-semibold text-gray-200">System Prompt</h2>
|
||||||
<span className="text-xs text-gray-500">{character.system_prompt.length} chars</span>
|
<span className="text-xs text-gray-600">{character.system_prompt.length} chars</span>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full border p-2 rounded h-32"
|
className={inputClass + " h-32 resize-y"}
|
||||||
value={character.system_prompt}
|
value={character.system_prompt}
|
||||||
onChange={(e) => handleChange('system_prompt', e.target.value)}
|
onChange={(e) => handleChange('system_prompt', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="bg-white p-4 shadow rounded space-y-4">
|
{/* Live2D Expressions */}
|
||||||
<h2 className="text-xl font-semibold">Live2D Expressions</h2>
|
<div className={cardClass}>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-200">Live2D Expressions</h2>
|
||||||
{Object.entries(character.live2d_expressions).map(([key, val]) => (
|
{Object.entries(character.live2d_expressions).map(([key, val]) => (
|
||||||
<div key={key} className="flex justify-between items-center">
|
<div key={key} className="flex justify-between items-center gap-4">
|
||||||
<label className="text-sm font-medium w-1/3 capitalize">{key}</label>
|
<label className="text-sm font-medium text-gray-400 w-1/3 capitalize">{key}</label>
|
||||||
<input type="text" className="border p-1 rounded w-2/3" value={val} onChange={(e) => handleNestedChange('live2d_expressions', key, e.target.value)} />
|
<input type="text" className={inputClass + " w-2/3"} value={val} onChange={(e) => handleNestedChange('live2d_expressions', key, e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-4 shadow rounded space-y-4">
|
{/* Model Overrides */}
|
||||||
<h2 className="text-xl font-semibold">Model Overrides</h2>
|
<div className={cardClass}>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-200">Model Overrides</h2>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Primary Model</label>
|
<label className={labelClass}>Primary Model</label>
|
||||||
<select className="w-full border p-2 rounded" value={character.model_overrides?.primary || 'llama3.3:70b'} onChange={(e) => handleNestedChange('model_overrides', 'primary', e.target.value)}>
|
<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="llama3.3:70b">llama3.3:70b</option>
|
||||||
<option value="qwen2.5:7b">qwen2.5:7b</option>
|
<option value="qwen2.5:7b">qwen2.5:7b</option>
|
||||||
<option value="qwen3:32b">qwen3:32b</option>
|
<option value="qwen3:32b">qwen3:32b</option>
|
||||||
@@ -364,8 +521,8 @@ export default function CharacterManager() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Fast Model</label>
|
<label className={labelClass}>Fast Model</label>
|
||||||
<select className="w-full border p-2 rounded" value={character.model_overrides?.fast || 'qwen2.5:7b'} onChange={(e) => handleNestedChange('model_overrides', 'fast', e.target.value)}>
|
<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="qwen2.5:7b">qwen2.5:7b</option>
|
||||||
<option value="llama3.3:70b">llama3.3:70b</option>
|
<option value="llama3.3:70b">llama3.3:70b</option>
|
||||||
<option value="qwen3:32b">qwen3:32b</option>
|
<option value="qwen3:32b">qwen3:32b</option>
|
||||||
@@ -377,39 +534,45 @@ export default function CharacterManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-4 shadow rounded space-y-4">
|
{/* Custom Rules */}
|
||||||
|
<div className={cardClass}>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-xl font-semibold">Custom Rules</h2>
|
<h2 className="text-lg font-semibold text-gray-200">Custom Rules</h2>
|
||||||
<button onClick={addRule} className="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">
|
<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">
|
||||||
+ Add Rule
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(!character.custom_rules || character.custom_rules.length === 0) ? (
|
{(!character.custom_rules || character.custom_rules.length === 0) ? (
|
||||||
<p className="text-sm text-gray-500 italic">No custom rules defined.</p>
|
<p className="text-sm text-gray-600 italic">No custom rules defined.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{character.custom_rules.map((rule, idx) => (
|
{character.custom_rules.map((rule, idx) => (
|
||||||
<div key={idx} className="border p-4 rounded relative bg-gray-50">
|
<div key={idx} className="border border-gray-700 p-4 rounded-lg relative bg-gray-800/50">
|
||||||
<button
|
<button
|
||||||
onClick={() => removeRule(idx)}
|
onClick={() => removeRule(idx)}
|
||||||
className="absolute top-2 right-2 text-red-500 hover:text-red-700"
|
className="absolute top-3 right-3 text-gray-500 hover:text-red-400 transition-colors"
|
||||||
title="Remove Rule"
|
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>
|
</button>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-1">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium mb-1">Trigger</label>
|
<label className="block text-xs font-medium mb-1 text-gray-500">Trigger</label>
|
||||||
<input type="text" className="w-full border p-1 rounded text-sm" value={rule.trigger || ''} onChange={(e) => handleRuleChange(idx, 'trigger', e.target.value)} />
|
<input type="text" className={inputClass + " text-sm"} value={rule.trigger || ''} onChange={(e) => handleRuleChange(idx, 'trigger', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium mb-1">Condition (Optional)</label>
|
<label className="block text-xs font-medium mb-1 text-gray-500">Condition (Optional)</label>
|
||||||
<input type="text" className="w-full border p-1 rounded text-sm" value={rule.condition || ''} onChange={(e) => handleRuleChange(idx, 'condition', e.target.value)} placeholder="e.g. time_of_day == morning" />
|
<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>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-xs font-medium mb-1">Response</label>
|
<label className="block text-xs font-medium mb-1 text-gray-500">Response</label>
|
||||||
<textarea className="w-full border p-1 rounded text-sm h-16" value={rule.response || ''} onChange={(e) => handleRuleChange(idx, 'response', e.target.value)} />
|
<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>
|
||||||
@@ -417,7 +580,6 @@ export default function CharacterManager() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
297
homeai-character/src/CharacterProfiles.jsx
Normal file
297
homeai-character/src/CharacterProfiles.jsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { validateCharacter } from './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 CharacterProfiles() {
|
||||||
|
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) => {
|
||||||
|
// Store the profile data for the editor to pick up
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{/* Image upload overlay */}
|
||||||
|
<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>
|
||||||
|
{/* Active badge */}
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Meta chips */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
318
homeai-character/src/ServiceStatus.jsx
Normal file
318
homeai-character/src/ServiceStatus.jsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Open WebUI',
|
||||||
|
url: 'http://localhost:3030',
|
||||||
|
healthPath: '/',
|
||||||
|
uiUrl: 'http://localhost:3030',
|
||||||
|
description: 'Chat interface',
|
||||||
|
category: 'AI & LLM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'OpenClaw Gateway',
|
||||||
|
url: 'http://localhost:8080',
|
||||||
|
healthPath: '/',
|
||||||
|
uiUrl: null,
|
||||||
|
description: 'Agent gateway',
|
||||||
|
category: 'Agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'OpenClaw Bridge',
|
||||||
|
url: 'http://localhost:8081',
|
||||||
|
healthPath: '/',
|
||||||
|
uiUrl: null,
|
||||||
|
description: 'HTTP-to-CLI bridge',
|
||||||
|
category: 'Agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Wyoming STT',
|
||||||
|
url: 'http://localhost:10300',
|
||||||
|
healthPath: '/',
|
||||||
|
uiUrl: null,
|
||||||
|
description: 'Whisper speech-to-text',
|
||||||
|
category: 'Voice',
|
||||||
|
tcp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Wyoming TTS',
|
||||||
|
url: 'http://localhost:10301',
|
||||||
|
healthPath: '/',
|
||||||
|
uiUrl: null,
|
||||||
|
description: 'Kokoro text-to-speech',
|
||||||
|
category: 'Voice',
|
||||||
|
tcp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Wyoming Satellite',
|
||||||
|
url: 'http://localhost:10700',
|
||||||
|
healthPath: '/',
|
||||||
|
uiUrl: null,
|
||||||
|
description: 'Mac Mini mic/speaker satellite',
|
||||||
|
category: 'Voice',
|
||||||
|
tcp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'n8n',
|
||||||
|
url: 'http://localhost:5678',
|
||||||
|
healthPath: '/',
|
||||||
|
uiUrl: 'http://localhost:5678',
|
||||||
|
description: 'Workflow automation',
|
||||||
|
category: 'Infrastructure',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'code-server',
|
||||||
|
url: 'http://localhost:8090',
|
||||||
|
healthPath: '/',
|
||||||
|
uiUrl: 'http://localhost:8090',
|
||||||
|
description: 'Browser-based VS Code',
|
||||||
|
category: 'Infrastructure',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 ServiceStatus() {
|
||||||
|
const [statuses, setStatuses] = useState(() =>
|
||||||
|
Object.fromEntries(SERVICES.map(s => [s.name, { status: 'checking', lastCheck: null, responseTime: null }]))
|
||||||
|
);
|
||||||
|
const [lastRefresh, setLastRefresh] = useState(null);
|
||||||
|
|
||||||
|
const checkService = useCallback(async (service) => {
|
||||||
|
try {
|
||||||
|
// Route all checks through the server-side proxy to avoid CORS and
|
||||||
|
// self-signed SSL cert issues in the browser.
|
||||||
|
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 () => {
|
||||||
|
// Mark all as checking
|
||||||
|
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 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>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1,13 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: #030712;
|
||||||
|
color: #f3f4f6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
@@ -2,9 +2,100 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
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 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// TTS preview proxy — forwards POST to OpenClaw bridge, returns audio
|
||||||
|
server.middlewares.use('/api/tts', async (req, res) => {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.writeHead(405);
|
||||||
|
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/api/tts',
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': body.length }, timeout: 30000 },
|
||||||
|
(proxyRes) => {
|
||||||
|
res.writeHead(proxyRes.statusCode, {
|
||||||
|
'Content-Type': proxyRes.headers['content-type'] || 'audio/wav',
|
||||||
|
});
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
proxyRes.on('end', resolve);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
proxyReq.on('error', reject);
|
||||||
|
proxyReq.on('timeout', () => { proxyReq.destroy(); reject(new Error('timeout')); });
|
||||||
|
proxyReq.write(body);
|
||||||
|
proxyReq.end();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'TTS bridge unreachable' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
healthCheckPlugin(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
react(),
|
react(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ class KokoroEventHandler(AsyncEventHandler):
|
|||||||
url="https://github.com/thewh1teagle/kokoro-onnx",
|
url="https://github.com/thewh1teagle/kokoro-onnx",
|
||||||
),
|
),
|
||||||
installed=True,
|
installed=True,
|
||||||
version="1.0.0",
|
|
||||||
voices=[
|
voices=[
|
||||||
TtsVoice(
|
TtsVoice(
|
||||||
name=self._default_voice,
|
name=self._default_voice,
|
||||||
@@ -64,7 +63,6 @@ class KokoroEventHandler(AsyncEventHandler):
|
|||||||
attribution=Attribution(name="kokoro", url=""),
|
attribution=Attribution(name="kokoro", url=""),
|
||||||
installed=True,
|
installed=True,
|
||||||
languages=["en-us"],
|
languages=["en-us"],
|
||||||
version="1.0",
|
|
||||||
speakers=[TtsVoiceSpeaker(name=self._default_voice)],
|
speakers=[TtsVoiceSpeaker(name=self._default_voice)],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user