Files
homeai/homeai-dashboard/src/pages/Characters.jsx
Aodhan Collins 3c0d905e64 feat: unified HomeAI dashboard — merge character + desktop into single app
Combines homeai-character (service status, character profiles, editor) and
homeai-desktop (chat with voice I/O) into homeai-dashboard on port 5173.

- 4-page sidebar layout: Dashboard, Chat, Characters, Editor
- Merged Vite middleware: health checks, service restart, bridge proxy
- Bridge upgraded to ThreadingHTTPServer (fixes LAN request queuing)
- TTS strips emojis before synthesis
- Updated start.sh with new launchd service names
- Added preload-models to startup sequence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:40:04 +00:00

293 lines
13 KiB
JavaScript

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { validateCharacter } from '../lib/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 Characters() {
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) => {
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>
)}
<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>
{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>
<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>
<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>
);
}