-
Custom Rules
-
- + Add Rule
+ Custom Rules
+
+
+
+
+ Add Rule
-
+
{(!character.custom_rules || character.custom_rules.length === 0) ? (
-
No custom rules defined.
+
No custom rules defined.
) : (
{character.custom_rules.map((rule, idx) => (
-
-
removeRule(idx)}
- className="absolute top-2 right-2 text-red-500 hover:text-red-700"
+
+
removeRule(idx)}
+ className="absolute top-3 right-3 text-gray-500 hover:text-red-400 transition-colors"
title="Remove Rule"
>
- ✕
+
+
+
-
@@ -417,7 +580,6 @@ export default function CharacterManager() {
)}
-
);
}
diff --git a/homeai-character/src/CharacterProfiles.jsx b/homeai-character/src/CharacterProfiles.jsx
new file mode 100644
index 0000000..3686221
--- /dev/null
+++ b/homeai-character/src/CharacterProfiles.jsx
@@ -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 (
+
+ {/* Header */}
+
+
+
Characters
+
+ {profiles.length} profile{profiles.length !== 1 ? 's' : ''} stored
+ {activeProfile && (
+
+ Active: {activeProfile.data.display_name || activeProfile.data.name}
+
+ )}
+
+
+
+
+
+
+ Import JSON
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Drop zone */}
+
{ 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'
+ }`}
+ >
+
+
+
+
Drop character JSON files here to import
+
+
+ {/* Profile grid */}
+ {profiles.length === 0 ? (
+
+
+
+
+
No character profiles yet. Import a JSON file to get started.
+
+ ) : (
+
+ {profiles.map(profile => {
+ const isActive = profile.id === activeId;
+ const char = profile.data;
+ return (
+
+ {/* Image area */}
+
+ {profile.image ? (
+
+ ) : (
+
+ {(char.display_name || char.name || '?')[0].toUpperCase()}
+
+ )}
+ {/* Image upload overlay */}
+
+
+ handleImageUpload(profile.id, e)}
+ />
+
+ {/* Active badge */}
+ {isActive && (
+
+ Active
+
+ )}
+
+
+ {/* Info */}
+
+
+
+ {char.display_name || char.name}
+
+
{char.description}
+
+
+ {/* Meta chips */}
+
+
+ {char.tts?.engine || 'kokoro'}
+
+
+ {char.model_overrides?.primary || 'default'}
+
+ {char.tts?.kokoro_voice && (
+
+ {char.tts.kokoro_voice}
+
+ )}
+
+
+ {/* Actions */}
+
+ {!isActive ? (
+
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
+
+ ) : (
+
+ Active
+
+ )}
+
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"
+ >
+
+
+
+
+
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"
+ >
+
+
+
+
+
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"
+ >
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/homeai-character/src/ServiceStatus.jsx b/homeai-character/src/ServiceStatus.jsx
new file mode 100644
index 0000000..0eaa861
--- /dev/null
+++ b/homeai-character/src/ServiceStatus.jsx
@@ -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': (
+
+
+
+ ),
+ 'Agent': (
+
+
+
+ ),
+ 'Voice': (
+
+
+
+ ),
+ 'Smart Home': (
+
+
+
+ ),
+ 'Infrastructure': (
+
+
+
+ ),
+};
+
+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 (
+
+ );
+}
+
+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 (
+
+ {/* Header */}
+
+
+
Service Status
+
+ {onlineCount}/{totalCount} services online
+ {lastRefresh && (
+
+ Last check: {lastRefresh.toLocaleTimeString()}
+
+ )}
+
+
+
+
+
+
+ Refresh
+
+
+
+ {/* Summary bar */}
+
+ {allOnline ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ {/* Service grid by category */}
+ {categories.map(category => (
+
+
+ {CATEGORY_ICONS[category]}
+
{category}
+
+
+ {SERVICES.filter(s => s.category === category).map(service => {
+ const st = statuses[service.name] || { status: 'unknown' };
+ return (
+
+
+
+
+
+
{service.name}
+
+
{service.description}
+ {st.responseTime !== null && (
+
{st.responseTime}ms
+ )}
+
+ {service.uiUrl && (
+
+ Open
+
+
+
+
+ )}
+
+
+ );
+ })}
+
+
+ ))}
+
+ );
+}
diff --git a/homeai-character/src/index.css b/homeai-character/src/index.css
index a461c50..877aeb7 100644
--- a/homeai-character/src/index.css
+++ b/homeai-character/src/index.css
@@ -1 +1,13 @@
-@import "tailwindcss";
\ No newline at end of file
+@import "tailwindcss";
+
+body {
+ margin: 0;
+ background-color: #030712;
+ color: #f3f4f6;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+#root {
+ min-height: 100vh;
+}
\ No newline at end of file
diff --git a/homeai-character/vite.config.js b/homeai-character/vite.config.js
index 108d422..f56a79b 100644
--- a/homeai-character/vite.config.js
+++ b/homeai-character/vite.config.js
@@ -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(),
],
diff --git a/homeai-voice/tts/wyoming_kokoro_server.py b/homeai-voice/tts/wyoming_kokoro_server.py
index 730af20..503109c 100644
--- a/homeai-voice/tts/wyoming_kokoro_server.py
+++ b/homeai-voice/tts/wyoming_kokoro_server.py
@@ -56,7 +56,6 @@ class KokoroEventHandler(AsyncEventHandler):
url="https://github.com/thewh1teagle/kokoro-onnx",
),
installed=True,
- version="1.0.0",
voices=[
TtsVoice(
name=self._default_voice,
@@ -64,7 +63,6 @@ class KokoroEventHandler(AsyncEventHandler):
attribution=Attribution(name="kokoro", url=""),
installed=True,
languages=["en-us"],
- version="1.0",
speakers=[TtsVoiceSpeaker(name=self._default_voice)],
)
],