feat: character system v2 — schema upgrade, memory system, per-character TTS routing
Character schema v2: background, dialogue_style, appearance, skills, gaze_presets with automatic v1→v2 migration. LLM-assisted character creation via Character MCP server. Two-tier memory system (personal per-character + general shared) with budget-based injection into LLM system prompt. Per-character TTS voice routing via state file — Wyoming TTS server reads active config to route between Kokoro (local) and ElevenLabs (cloud PCM 24kHz). Dashboard: memories page, conversation history, character profile on cards, auto-TTS engine selection from character config. Also includes VTube Studio expression bridge and ComfyUI API guide. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } 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;
|
||||
}
|
||||
@@ -27,15 +13,52 @@ function setActiveId(id) {
|
||||
}
|
||||
|
||||
export default function Characters() {
|
||||
const [profiles, setProfiles] = useState(loadProfiles);
|
||||
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(() => {
|
||||
saveProfiles(profiles);
|
||||
}, [profiles]);
|
||||
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 || []);
|
||||
@@ -47,12 +70,14 @@ export default function Characters() {
|
||||
files.forEach(file => {
|
||||
if (!file.name.endsWith('.json')) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
reader.onload = async (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() }]);
|
||||
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}`);
|
||||
@@ -73,15 +98,17 @@ export default function Characters() {
|
||||
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.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 = (id) => {
|
||||
const removeProfile = async (id) => {
|
||||
await deleteProfile(id);
|
||||
setProfiles(prev => prev.filter(p => p.id !== id));
|
||||
if (activeId === id) {
|
||||
setActive(null);
|
||||
@@ -92,6 +119,28 @@ export default function Characters() {
|
||||
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) => {
|
||||
@@ -125,13 +174,28 @@ export default function Characters() {
|
||||
)}
|
||||
</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 className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
sessionStorage.removeItem('edit_character');
|
||||
sessionStorage.removeItem('edit_character_profile_id');
|
||||
navigate('/editor');
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg 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>
|
||||
New Character
|
||||
</button>
|
||||
<label className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg cursor-pointer 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 JSON
|
||||
<input type="file" accept=".json" multiple className="hidden" onChange={handleImport} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -158,7 +222,11 @@ export default function Characters() {
|
||||
</div>
|
||||
|
||||
{/* Profile grid */}
|
||||
{profiles.length === 0 ? (
|
||||
{loading ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500">Loading characters...</p>
|
||||
</div>
|
||||
) : 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" />
|
||||
@@ -230,11 +298,32 @@ export default function Characters() {
|
||||
<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 && (
|
||||
{char.tts?.engine === 'kokoro' && 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>
|
||||
)}
|
||||
{char.tts?.engine === 'elevenlabs' && char.tts?.elevenlabs_voice_id && (
|
||||
<span className="px-2 py-0.5 bg-gray-700/70 text-gray-400 text-xs rounded-full" title={char.tts.elevenlabs_voice_id}>
|
||||
{char.tts.elevenlabs_voice_name || char.tts.elevenlabs_voice_id.slice(0, 8) + '…'}
|
||||
</span>
|
||||
)}
|
||||
{char.tts?.engine === 'chatterbox' && char.tts?.voice_ref_path && (
|
||||
<span className="px-2 py-0.5 bg-gray-700/70 text-gray-400 text-xs rounded-full" title={char.tts.voice_ref_path}>
|
||||
{char.tts.voice_ref_path.split('/').pop()}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const defaultPreset = char.gaze_presets?.find(gp => gp.trigger === 'self-portrait')?.preset
|
||||
|| char.gaze_presets?.[0]?.preset
|
||||
|| char.gaze_preset
|
||||
|| null;
|
||||
return defaultPreset ? (
|
||||
<span className="px-2 py-0.5 bg-violet-500/20 text-violet-300 text-xs rounded-full border border-violet-500/30" title={`GAZE: ${defaultPreset}`}>
|
||||
{defaultPreset}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
@@ -287,6 +376,96 @@ export default function Characters() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Satellite Assignment */}
|
||||
{!loading && profiles.length > 0 && (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-200">Satellite Routing</h2>
|
||||
<p className="text-xs text-gray-500 mt-1">Assign characters to voice satellites. Unmapped satellites use the default.</p>
|
||||
</div>
|
||||
|
||||
{/* Default character */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-400 w-32 shrink-0">Default</label>
|
||||
<select
|
||||
value={satMap.default || ''}
|
||||
onChange={(e) => saveSatMap({ ...satMap, default: e.target.value })}
|
||||
className="flex-1 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"
|
||||
>
|
||||
<option value="">-- None --</option>
|
||||
{profiles.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.data.display_name || p.data.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Per-satellite assignments */}
|
||||
{Object.entries(satMap.satellites || {}).map(([satId, charId]) => (
|
||||
<div key={satId} className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-300 w-32 shrink-0 truncate font-mono" title={satId}>{satId}</span>
|
||||
<select
|
||||
value={charId}
|
||||
onChange={(e) => {
|
||||
const updated = { ...satMap, satellites: { ...satMap.satellites, [satId]: e.target.value } };
|
||||
saveSatMap(updated);
|
||||
}}
|
||||
className="flex-1 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"
|
||||
>
|
||||
{profiles.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.data.display_name || p.data.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => {
|
||||
const { [satId]: _, ...rest } = satMap.satellites;
|
||||
saveSatMap({ ...satMap, satellites: rest });
|
||||
}}
|
||||
className="px-2 py-1.5 bg-gray-700 hover:bg-red-600 text-gray-400 hover:text-white rounded-lg transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
|
||||
{/* Add new satellite */}
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-gray-800">
|
||||
<input
|
||||
type="text"
|
||||
value={newSatId}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<select
|
||||
value={newSatChar}
|
||||
onChange={(e) => setNewSatChar(e.target.value)}
|
||||
className="flex-1 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"
|
||||
>
|
||||
<option value="">-- Select Character --</option>
|
||||
{profiles.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.data.display_name || p.data.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newSatId && newSatChar) {
|
||||
saveSatMap({ ...satMap, satellites: { ...satMap.satellites, [newSatId]: newSatChar } });
|
||||
setNewSatId('');
|
||||
setNewSatChar('');
|
||||
}
|
||||
}}
|
||||
disabled={!newSatId || !newSatChar}
|
||||
className="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:bg-gray-700 disabled:text-gray-500 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user