- 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>
298 lines
13 KiB
JavaScript
298 lines
13 KiB
JavaScript
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>
|
|
);
|
|
}
|