import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { validateCharacter } from '../lib/SchemaValidator'; const ACTIVE_KEY = 'homeai_active_character'; function getActiveId() { return localStorage.getItem(ACTIVE_KEY) || null; } function setActiveId(id) { localStorage.setItem(ACTIVE_KEY, id); } export default function Characters() { const [profiles, setProfiles] = useState([]); const [activeId, setActive] = useState(getActiveId); const [error, setError] = useState(null); const [dragOver, setDragOver] = useState(false); const [loading, setLoading] = useState(true); const [satMap, setSatMap] = useState({ default: '', satellites: {} }); const [newSatId, setNewSatId] = useState(''); const [newSatChar, setNewSatChar] = useState(''); const navigate = useNavigate(); // Load profiles and satellite map on mount useEffect(() => { Promise.all([ fetch('/api/characters').then(r => r.json()), fetch('/api/satellite-map').then(r => r.json()), ]) .then(([chars, map]) => { setProfiles(chars); setSatMap(map); setLoading(false); }) .catch(err => { setError(`Failed to load: ${err.message}`); setLoading(false); }); }, []); const saveSatMap = useCallback(async (updated) => { setSatMap(updated); await fetch('/api/satellite-map', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated), }); }, []); const saveProfile = useCallback(async (profile) => { const res = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profile), }); if (!res.ok) throw new Error('Failed to save profile'); }, []); const deleteProfile = useCallback(async (id) => { const safeId = id.replace(/[^a-zA-Z0-9_\-\.]/g, '_'); await fetch(`/api/characters/${safeId}`, { method: 'DELETE' }); }, []); 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 = async (ev) => { try { const data = JSON.parse(ev.target.result); validateCharacter(data); const id = data.name + '_' + Date.now(); const profile = { id, data, image: null, addedAt: new Date().toISOString() }; await saveProfile(profile); setProfiles(prev => [...prev, profile]); 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 = async (ev) => { const updated = profiles.map(p => p.id === profileId ? { ...p, image: ev.target.result } : p); const profile = updated.find(p => p.id === profileId); if (profile) await saveProfile(profile); setProfiles(updated); }; reader.readAsDataURL(file); }; const removeProfile = async (id) => { await deleteProfile(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); // Sync active character's TTS settings to chat settings const profile = profiles.find(p => p.id === id); if (profile?.data?.tts) { const tts = profile.data.tts; const engine = tts.engine || 'kokoro'; let voice; if (engine === 'kokoro') voice = tts.kokoro_voice || 'af_heart'; else if (engine === 'elevenlabs') voice = tts.elevenlabs_voice_id || ''; else if (engine === 'chatterbox') voice = tts.voice_ref_path || ''; else voice = ''; try { const raw = localStorage.getItem('homeai_dashboard_settings'); const settings = raw ? JSON.parse(raw) : {}; localStorage.setItem('homeai_dashboard_settings', JSON.stringify({ ...settings, ttsEngine: engine, voice: voice, })); } catch { /* ignore */ } } }; const exportProfile = (profile) => { const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(profile.data, null, 2)); const a = document.createElement('a'); a.href = dataStr; a.download = `${profile.data.name || 'character'}.json`; a.click(); }; const editProfile = (profile) => { sessionStorage.setItem('edit_character', JSON.stringify(profile.data)); sessionStorage.setItem('edit_character_profile_id', profile.id); navigate('/editor'); }; const activeProfile = profiles.find(p => p.id === activeId); return (
{/* Header */}

Characters

{profiles.length} profile{profiles.length !== 1 ? 's' : ''} stored {activeProfile && ( Active: {activeProfile.data.display_name || activeProfile.data.name} )}

{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 */} {loading ? (

Loading characters...

) : 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.display_name || char.name || '?')[0].toUpperCase()}
)} {isActive && ( Active )}
{/* Info */}

{char.display_name || char.name}

{char.description}

{char.tts?.engine || 'kokoro'} {char.model_overrides?.primary || 'default'} {char.tts?.engine === 'kokoro' && char.tts?.kokoro_voice && ( {char.tts.kokoro_voice} )} {char.tts?.engine === 'elevenlabs' && char.tts?.elevenlabs_voice_id && ( {char.tts.elevenlabs_voice_name || char.tts.elevenlabs_voice_id.slice(0, 8) + '…'} )} {char.tts?.engine === 'chatterbox' && char.tts?.voice_ref_path && ( {char.tts.voice_ref_path.split('/').pop()} )} {(() => { const defaultPreset = char.gaze_presets?.find(gp => gp.trigger === 'self-portrait')?.preset || char.gaze_presets?.[0]?.preset || char.gaze_preset || null; return defaultPreset ? ( {defaultPreset} ) : null; })()}
{!isActive ? ( ) : ( )}
); })}
)} {/* Satellite Assignment */} {!loading && profiles.length > 0 && (

Satellite Routing

Assign characters to voice satellites. Unmapped satellites use the default.

{/* Default character */}
{/* Per-satellite assignments */} {Object.entries(satMap.satellites || {}).map(([satId, charId]) => (
{satId}
))} {/* Add new satellite */}
setNewSatId(e.target.value)} placeholder="Satellite ID (from bridge log)" className="w-32 shrink-0 bg-gray-800 text-gray-200 text-sm rounded-lg px-3 py-2 border border-gray-700 focus:outline-none focus:border-indigo-500 font-mono" />
)}
); }