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:
Aodhan Collins
2026-03-11 20:40:11 +00:00
parent 6db8ae4492
commit 1bfd7fbd08
13 changed files with 1192 additions and 170 deletions

View File

@@ -4,9 +4,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>homeai-character</title>
<title>HomeAI Dashboard</title>
</head>
<body>
<body class="bg-gray-950 text-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>

View File

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

View File

@@ -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": {

View File

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

View File

@@ -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() {
return (
<div className="min-h-screen bg-gray-50 text-gray-900 py-8">
<CharacterManager />
</div>
)
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<ServiceStatus />} />
<Route path="/characters" element={<CharacterProfiles />} />
<Route path="/editor" element={<CharacterManager />} />
</Routes>
</Layout>
</BrowserRouter>
);
}
export default App
export default App;

View File

@@ -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 (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">HomeAI Character Manager</h1>
<div className="space-x-4">
<label className="cursor-pointer bg-blue-500 text-white px-4 py-2 rounded shadow hover:bg-blue-600">
Import JSON
<div>
<h1 className="text-3xl font-bold text-gray-100">Character Editor</h1>
<p className="text-sm text-gray-500 mt-1">
Editing: {character.display_name || character.name}
</p>
</div>
<div className="flex gap-3">
<label className="cursor-pointer flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg border border-gray-700 transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
Import
<input type="file" accept=".json" className="hidden" onChange={handleImport} />
</label>
<button onClick={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
</button>
</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="space-y-4 bg-white p-4 shadow rounded">
<h2 className="text-xl font-semibold">Basic Info</h2>
{/* Basic Info */}
<div className={cardClass}>
<h2 className="text-lg font-semibold text-gray-200">Basic Info</h2>
<div>
<label className="block text-sm font-medium mb-1">Name (ID)</label>
<input type="text" className="w-full border p-2 rounded" value={character.name} onChange={(e) => handleChange('name', e.target.value)} />
<label className={labelClass}>Name (ID)</label>
<input type="text" className={inputClass} value={character.name} onChange={(e) => handleChange('name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium mb-1">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)} />
<label className={labelClass}>Display Name</label>
<input type="text" className={inputClass} value={character.display_name} onChange={(e) => handleChange('display_name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<input type="text" className="w-full border p-2 rounded" value={character.description} onChange={(e) => handleChange('description', e.target.value)} />
<label className={labelClass}>Description</label>
<input type="text" className={inputClass} value={character.description} onChange={(e) => handleChange('description', e.target.value)} />
</div>
</div>
<div className="space-y-4 bg-white p-4 shadow rounded">
<h2 className="text-xl font-semibold">TTS Configuration</h2>
{/* TTS Configuration */}
<div className={cardClass}>
<h2 className="text-lg font-semibold text-gray-200">TTS Configuration</h2>
<div>
<label className="block text-sm font-medium mb-1">Engine</label>
<select className="w-full border p-2 rounded" value={character.tts.engine} onChange={(e) => handleNestedChange('tts', 'engine', e.target.value)}>
<label className={labelClass}>Engine</label>
<select className={selectClass} value={character.tts.engine} onChange={(e) => handleNestedChange('tts', 'engine', e.target.value)}>
<option value="kokoro">Kokoro</option>
<option value="chatterbox">Chatterbox</option>
<option value="qwen3">Qwen3</option>
<option value="elevenlabs">ElevenLabs</option>
</select>
</div>
{character.tts.engine === 'elevenlabs' && (
<div className="space-y-4 border p-4 rounded bg-gray-50">
<div className="space-y-4 border border-gray-700 p-4 rounded-lg bg-gray-800/50">
<div>
<label className="block text-xs font-medium mb-1 text-gray-500">ElevenLabs API Key (Local Use Only)</label>
<div className="flex space-x-2">
<input type="password" placeholder="sk_..." className="w-full border p-2 rounded 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">
<div className="flex gap-2">
<input type="password" placeholder="sk_..." className={inputClass + " text-sm"} value={elevenLabsApiKey} onChange={(e) => setElevenLabsApiKey(e.target.value)} />
<button onClick={() => fetchElevenLabsData(elevenLabsApiKey)} disabled={isLoadingElevenLabs} className="bg-indigo-600 text-white px-3 py-1 rounded-lg text-sm whitespace-nowrap hover:bg-indigo-500 disabled:opacity-50 transition-colors">
{isLoadingElevenLabs ? 'Loading...' : 'Fetch'}
</button>
</div>
</div>
<div className="space-y-2">
<div>
<label className="block text-sm font-medium mb-1">Voice ID</label>
{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)}>
<option value="">-- Select Voice --</option>
{elevenLabsVoices.map(v => (
<option key={v.voice_id} value={v.voice_id}>{v.name} ({v.category})</option>
))}
</select>
) : (
<input type="text" className="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" />
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Model</label>
{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)}>
<option value="">-- Select Model --</option>
{elevenLabsModels.map(m => (
<option key={m.model_id} value={m.model_id}>{m.name} ({m.model_id})</option>
))}
</select>
) : (
<input type="text" className="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" />
)}
</div>
<div>
<label className={labelClass}>Voice ID</label>
{elevenLabsVoices.length > 0 ? (
<select className={selectClass} value={character.tts.elevenlabs_voice_id || ''} onChange={(e) => handleNestedChange('tts', 'elevenlabs_voice_id', e.target.value)}>
<option value="">-- Select Voice --</option>
{elevenLabsVoices.map(v => (
<option key={v.voice_id} value={v.voice_id}>{v.name} ({v.category})</option>
))}
</select>
) : (
<input type="text" className={inputClass} value={character.tts.elevenlabs_voice_id || ''} onChange={(e) => handleNestedChange('tts', 'elevenlabs_voice_id', e.target.value)} placeholder="e.g. 21m00Tcm4TlvDq8ikWAM" />
)}
</div>
<div>
<label className={labelClass}>Model</label>
{elevenLabsModels.length > 0 ? (
<select className={selectClass} value={character.tts.elevenlabs_model || 'eleven_monolingual_v1'} onChange={(e) => handleNestedChange('tts', 'elevenlabs_model', e.target.value)}>
<option value="">-- Select Model --</option>
{elevenLabsModels.map(m => (
<option key={m.model_id} value={m.model_id}>{m.name} ({m.model_id})</option>
))}
</select>
) : (
<input type="text" className={inputClass} value={character.tts.elevenlabs_model || 'eleven_monolingual_v1'} onChange={(e) => handleNestedChange('tts', 'elevenlabs_model', e.target.value)} placeholder="e.g. eleven_monolingual_v1" />
)}
</div>
</div>
)}
{character.tts.engine === 'kokoro' && (
<div>
<label className="block text-sm font-medium mb-1">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)}>
<label className={labelClass}>Kokoro Voice</label>
<select className={selectClass} value={character.tts.kokoro_voice || 'af_heart'} onChange={(e) => handleNestedChange('tts', 'kokoro_voice', e.target.value)}>
<option value="af_heart">af_heart (American Female)</option>
<option value="af_alloy">af_alloy (American Female)</option>
<option value="af_aoede">af_aoede (American Female)</option>
@@ -307,54 +422,96 @@ export default function CharacterManager() {
</select>
</div>
)}
{character.tts.engine === 'chatterbox' && (
<div>
<label className="block text-sm font-medium mb-1">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)} />
<label className={labelClass}>Voice Reference Path</label>
<input type="text" className={inputClass} value={character.tts.voice_ref_path || ''} onChange={(e) => handleNestedChange('tts', 'voice_ref_path', e.target.value)} />
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">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))} />
<label className={labelClass}>Speed: {character.tts.speed}</label>
<input type="range" min="0.5" max="2.0" step="0.1" className="w-full accent-indigo-500" value={character.tts.speed} onChange={(e) => handleNestedChange('tts', 'speed', parseFloat(e.target.value))} />
</div>
<button
onClick={previewTTS}
className="w-full bg-indigo-500 text-white px-4 py-2 rounded shadow hover:bg-indigo-600 transition"
>
🔊 Preview Voice Speed
</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>
<div>
<label className={labelClass}>Preview Text</label>
<input
type="text"
className={inputClass}
value={previewText}
onChange={(e) => setPreviewText(e.target.value)}
placeholder={`Hi, I am ${character.display_name}. This is a preview of my voice.`}
/>
</div>
<div className="flex gap-2">
<button
onClick={previewTTS}
disabled={ttsState === 'loading'}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg transition-colors ${
ttsState === 'loading'
? 'bg-indigo-800 text-indigo-300 cursor-wait'
: ttsState === 'playing'
? 'bg-emerald-600 hover:bg-emerald-500 text-white'
: 'bg-indigo-600 hover:bg-indigo-500 text-white'
}`}
>
{ttsState === 'loading' && (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{ttsState === 'loading' ? 'Synthesizing...' : ttsState === 'playing' ? 'Playing...' : 'Preview Voice'}
</button>
{ttsState !== 'idle' && (
<button
onClick={stopPreview}
className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
>
Stop
</button>
)}
</div>
<p className="text-xs text-gray-600">
{character.tts.engine === 'kokoro'
? 'Previews via local Kokoro TTS bridge (port 8081 → Wyoming 10301).'
: 'Uses browser TTS for preview. Local TTS available with Kokoro engine.'}
</p>
</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">
<h2 className="text-xl font-semibold">System Prompt</h2>
<span className="text-xs text-gray-500">{character.system_prompt.length} chars</span>
<h2 className="text-lg font-semibold text-gray-200">System Prompt</h2>
<span className="text-xs text-gray-600">{character.system_prompt.length} chars</span>
</div>
<textarea
className="w-full border p-2 rounded h-32"
<textarea
className={inputClass + " h-32 resize-y"}
value={character.system_prompt}
onChange={(e) => handleChange('system_prompt', e.target.value)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-4 shadow rounded space-y-4">
<h2 className="text-xl font-semibold">Live2D Expressions</h2>
{/* Live2D Expressions */}
<div className={cardClass}>
<h2 className="text-lg font-semibold text-gray-200">Live2D Expressions</h2>
{Object.entries(character.live2d_expressions).map(([key, val]) => (
<div key={key} className="flex justify-between items-center">
<label className="text-sm font-medium 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)} />
<div key={key} className="flex justify-between items-center gap-4">
<label className="text-sm font-medium text-gray-400 w-1/3 capitalize">{key}</label>
<input type="text" className={inputClass + " w-2/3"} value={val} onChange={(e) => handleNestedChange('live2d_expressions', key, e.target.value)} />
</div>
))}
</div>
<div className="bg-white p-4 shadow rounded space-y-4">
<h2 className="text-xl font-semibold">Model Overrides</h2>
{/* Model Overrides */}
<div className={cardClass}>
<h2 className="text-lg font-semibold text-gray-200">Model Overrides</h2>
<div>
<label className="block text-sm font-medium mb-1">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)}>
<label className={labelClass}>Primary Model</label>
<select className={selectClass} value={character.model_overrides?.primary || 'llama3.3:70b'} onChange={(e) => handleNestedChange('model_overrides', 'primary', e.target.value)}>
<option value="llama3.3:70b">llama3.3:70b</option>
<option value="qwen2.5:7b">qwen2.5:7b</option>
<option value="qwen3:32b">qwen3:32b</option>
@@ -364,8 +521,8 @@ export default function CharacterManager() {
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">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)}>
<label className={labelClass}>Fast Model</label>
<select className={selectClass} value={character.model_overrides?.fast || 'qwen2.5:7b'} onChange={(e) => handleNestedChange('model_overrides', 'fast', e.target.value)}>
<option value="qwen2.5:7b">qwen2.5:7b</option>
<option value="llama3.3:70b">llama3.3:70b</option>
<option value="qwen3:32b">qwen3:32b</option>
@@ -377,39 +534,45 @@ export default function CharacterManager() {
</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">
<h2 className="text-xl font-semibold">Custom Rules</h2>
<button onClick={addRule} className="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">
+ Add Rule
<h2 className="text-lg font-semibold text-gray-200">Custom Rules</h2>
<button onClick={addRule} className="flex items-center gap-1 bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded-lg text-sm transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Rule
</button>
</div>
{(!character.custom_rules || character.custom_rules.length === 0) ? (
<p className="text-sm text-gray-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">
{character.custom_rules.map((rule, idx) => (
<div key={idx} className="border p-4 rounded relative bg-gray-50">
<button
onClick={() => removeRule(idx)}
className="absolute top-2 right-2 text-red-500 hover:text-red-700"
<div key={idx} className="border border-gray-700 p-4 rounded-lg relative bg-gray-800/50">
<button
onClick={() => removeRule(idx)}
className="absolute top-3 right-3 text-gray-500 hover:text-red-400 transition-colors"
title="Remove Rule"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-1">
<div>
<label className="block text-xs font-medium mb-1">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)} />
<label className="block text-xs font-medium mb-1 text-gray-500">Trigger</label>
<input type="text" className={inputClass + " text-sm"} value={rule.trigger || ''} onChange={(e) => handleRuleChange(idx, 'trigger', e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium mb-1">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" />
<label className="block text-xs font-medium mb-1 text-gray-500">Condition (Optional)</label>
<input type="text" className={inputClass + " text-sm"} value={rule.condition || ''} onChange={(e) => handleRuleChange(idx, 'condition', e.target.value)} placeholder="e.g. time_of_day == morning" />
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium mb-1">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)} />
<label className="block text-xs font-medium mb-1 text-gray-500">Response</label>
<textarea className={inputClass + " text-sm h-16 resize-y"} value={rule.response || ''} onChange={(e) => handleRuleChange(idx, 'response', e.target.value)} />
</div>
</div>
</div>
@@ -417,7 +580,6 @@ export default function CharacterManager() {
</div>
)}
</div>
</div>
);
}

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

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

View File

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

View File

@@ -2,9 +2,100 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
function healthCheckPlugin() {
return {
name: 'health-check-proxy',
configureServer(server) {
server.middlewares.use('/api/health', async (req, res) => {
const params = new URL(req.url, 'http://localhost').searchParams;
const url = params.get('url');
const mode = params.get('mode'); // 'tcp' for raw TCP port check
if (!url) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing url param' }));
return;
}
const start = Date.now();
const parsedUrl = new URL(url);
try {
if (mode === 'tcp') {
// TCP socket connect check for non-HTTP services (e.g. Wyoming)
const { default: net } = await import('net');
await new Promise((resolve, reject) => {
const socket = net.createConnection(
{ host: parsedUrl.hostname, port: parseInt(parsedUrl.port), timeout: 5000 },
() => { socket.destroy(); resolve(); }
);
socket.on('error', reject);
socket.on('timeout', () => { socket.destroy(); reject(new Error('timeout')); });
});
} else {
// HTTP/HTTPS health check
const { default: https } = await import('https');
const { default: http } = await import('http');
const client = parsedUrl.protocol === 'https:' ? https : http;
await new Promise((resolve, reject) => {
const reqObj = client.get(url, { rejectUnauthorized: false, timeout: 5000 }, (resp) => {
resp.resume();
resolve();
});
reqObj.on('error', reject);
reqObj.on('timeout', () => { reqObj.destroy(); reject(new Error('timeout')); });
});
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'online', responseTime: Date.now() - start }));
} catch {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'offline', responseTime: null }));
}
});
// 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/
export default defineConfig({
plugins: [
healthCheckPlugin(),
tailwindcss(),
react(),
],