const {useEffect, useRef, useState} = React; const API = '../api/index.php'; async function api(route, options = {}) { const [path, query = ''] = route.split('?'); const headers = new Headers(options.headers || {}); if (!(options.body instanceof FormData) && options.body !== undefined) headers.set('Content-Type', 'application/json'); const response = await fetch(`${API}?route=${encodeURIComponent(path)}${query ? `&${query}` : ''}`, { ...options, headers, credentials: 'include' }); const payload = await response.json().catch(() => ({ok: false, error: 'Invalid server response.'})); if (!response.ok || payload.ok === false) throw new Error(payload.error || `Request failed (${response.status})`); return payload; } function uid() { return window.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; } function go(path) { window.location.hash = path; } function useRoute() { const [route, setRoute] = useState(location.hash.slice(1) || '/'); useEffect(() => { const handler = () => setRoute(location.hash.slice(1) || '/'); addEventListener('hashchange', handler); return () => removeEventListener('hashchange', handler); }, []); return route; } function mergeTranscript(current, incoming) { const next = String(incoming || '').trim(); if (!next) return current; if (!current) return next; if (next.startsWith(current)) return next; if (current.endsWith(next)) return current; return `${current} ${next}`.replace(/\s+/g, ' ').trim(); } function QuotaBadge({quota}) { if (!quota) return null; if (quota.remaining === null) return Unlimited interactions; return {quota.remaining} of {quota.limit} {quota.period} interactions left ; } function SpritePlayer({character, state, className = ''}) { const animations = character?.animations || {}; let animation = animations[state]; if (!animation && ['talking', 'explaining', 'happy', 'encouraging', 'celebrating', 'warning'].includes(state)) animation = animations.talking; if (!animation && ['listening', 'thinking', 'confused', 'error'].includes(state)) animation = animations.idle; animation = animation || animations.idle || Object.values(animations)[0]; const [frame, setFrame] = useState(0); useEffect(() => { setFrame(0); if (!animation || Number(animation.frame_count) <= 1) return; const timer = setInterval(() => setFrame(current => { const next = current + 1; if (next < Number(animation.frame_count)) return next; return animation.loop_enabled ? 0 : current; }), Math.max(16, 1000 / Math.max(1, Number(animation.fps)))); return () => clearInterval(timer); }, [animation?.id, state]); if (!animation) return
Upload idle and talking sprites
; const aspect = Number(animation.frame_width) > 0 && Number(animation.frame_height) > 0 ? `${animation.frame_width} / ${animation.frame_height}` : '1 / 1'; if (animation.animation_type === 'frames') { const frames = animation.frame_paths || []; const path = frames[Math.min(frame, frames.length - 1)]; return
{path ? {`${character.name} : null}
; } const columns = Math.max(1, Number(animation.columns_count)); const rows = Math.max(1, Number(animation.rows_count)); const col = frame % columns; const row = Math.floor(frame / columns); const x = columns === 1 ? 0 : (col / (columns - 1)) * 100; const y = rows === 1 ? 0 : (row / (rows - 1)) * 100; return
; } function Login({onLogin}) { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); async function submit(e) { e.preventDefault(); setBusy(true); setError(''); try { const data = await api('auth/login', {method: 'POST', body: JSON.stringify({email, password})}); onLogin(data.user); } catch (err) { setError(err.message); } finally { setBusy(false); } } return
AI

AI Tutor · SpatialNex

Welcome back

Sign in to learn with animated tutors and professional assistants.

{error &&
{error}
}
; } function Home({user, onLogout}) { const [characters, setCharacters] = useState([]); const [error, setError] = useState(''); const [quota, setQuota] = useState(user.quota || null); useEffect(() => { api('characters').then(d => setCharacters(d.characters)).catch(e => setError(e.message)); api('quota').then(d => setQuota(d.quota)).catch(() => {}); }, []); return

Animated AI Professionals

Choose your expert

{user.role === 'admin' && }

One engine. Any profession.

Teachers, tutors, technicians, doctors for general education, support agents, and other professionals can each use their own sprites, interface, model, voice, and limits.

{error &&
{error}
}
{characters.map(character => )}
{!characters.length && !error &&
No published characters yet. Create one in Admin.
}
; } function Tutor({id}) { const [character, setCharacter] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [state, setState] = useState('idle'); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [conversationId, setConversationId] = useState(null); const [listening, setListening] = useState(false); const [quota, setQuota] = useState(null); const [chatState, setChatState] = useState('open'); const [liveConnected, setLiveConnected] = useState(false); const [liveMic, setLiveMic] = useState(false); const liveMicRef = useRef(false); const [liveCaption, setLiveCaption] = useState(''); const audioRef = useRef(null); const endRef = useRef(null); const liveRef = useRef(null); const liveEventRef = useRef(null); const liveInputRef = useRef(''); const liveOutputRef = useRef(''); const liveCompletingRef = useRef(false); const liveAudioPlayingRef = useRef(false); const conversationRef = useRef(null); useEffect(() => { api(`character?id=${id}`).then(d => { setCharacter(d.character); setQuota(d.quota); setChatState(d.character.chat_default_state || 'open'); }).catch(e => setError(e.message)); }, [id]); useEffect(() => { conversationRef.current = conversationId; }, [conversationId]); useEffect(() => endRef.current?.scrollIntoView({behavior: 'smooth'}), [messages, busy, liveCaption, chatState]); useEffect(() => () => { if (audioRef.current) audioRef.current.pause(); if (liveEventRef.current) api('live/cancel', {method: 'POST', body: JSON.stringify({eventUuid: liveEventRef.current})}).catch(() => {}); liveRef.current?.close?.(); }, []); function stopStandardAudio() { if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; audioRef.current = null; } setState('idle'); } async function speak(text, animation) { if (!character?.voice_reply_enabled || !text.trim()) { setState('idle'); return; } try { const data = await api('tts', {method: 'POST', body: JSON.stringify({characterId: character.id, text})}); const audio = new Audio(`data:${data.mimeType};base64,${data.audioBase64}`); audioRef.current = audio; audio.onplay = () => setState(animation || 'talking'); audio.onended = () => { audioRef.current = null; setState('idle'); }; audio.onerror = () => { audioRef.current = null; setState('idle'); }; await audio.play(); } catch (err) { setState('idle'); setError(`${err.message} The text answer is still available.`); } } async function sendStandardMessage(text = input) { if (!character || busy || !text.trim()) return; stopStandardAudio(); const clean = text.trim(); setInput(''); setError(''); setBusy(true); setState('thinking'); setMessages(items => [...items, {id: uid(), role: 'user', text: clean}]); try { const data = await api('chat', { method: 'POST', body: JSON.stringify({characterId: character.id, conversationId, message: clean, eventUuid: uid()}) }); setConversationId(data.conversationId); setQuota(data.quota); setMessages(items => [...items, {id: uid(), role: 'assistant', text: data.reply.displayText}]); await speak(data.reply.speechText, data.reply.animation); } catch (err) { setState('error'); setError(err.message); setTimeout(() => setState('idle'), 1200); } finally { setBusy(false); } } function startBrowserListening() { if (!character?.allow_voice_input) return; const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition; if (!Ctor) { setError('Speech recognition is not available in this browser. You can still type your question.'); return; } stopStandardAudio(); const recognition = new Ctor(); recognition.lang = character.voice_locale || 'en-US'; recognition.interimResults = true; recognition.continuous = false; recognition.onresult = e => setInput(Array.from(e.results).map(result => result[0].transcript).join(' ')); recognition.onend = () => { setListening(false); setState('idle'); }; recognition.onerror = recognition.onend; setListening(true); setState('listening'); recognition.start(); } async function finalizeLiveTurn() { if (!liveEventRef.current || liveCompletingRef.current) return; liveCompletingRef.current = true; const eventUuid = liveEventRef.current; const inputText = liveInputRef.current.trim(); const outputText = liveOutputRef.current.trim(); try { const data = await api('live/complete', { method: 'POST', body: JSON.stringify({ characterId: character.id, eventUuid, inputText, outputText, conversationId: conversationRef.current }) }); if (data.conversationId) setConversationId(data.conversationId); setQuota(data.quota); if (inputText) setMessages(items => [...items, {id: uid(), role: 'user', text: inputText}]); if (outputText) setMessages(items => [...items, {id: uid(), role: 'assistant', text: outputText}]); setLiveCaption(''); } catch (err) { setError(err.message); } finally { liveEventRef.current = null; liveInputRef.current = ''; liveOutputRef.current = ''; liveCompletingRef.current = false; if (!liveAudioPlayingRef.current) setState('idle'); } } async function ensureLiveConnected() { if (liveRef.current?.isReady?.()) return liveRef.current; if (!window.GeminiLiveSession) throw new Error('Gemini Live client did not load. Refresh the page and try again.'); const tokenData = await api('live/token', {method: 'POST', body: JSON.stringify({characterId: character.id})}); setQuota(tokenData.quota); const session = new window.GeminiLiveSession({ onOpen: () => setLiveConnected(true), onClose: () => { setLiveConnected(false); liveMicRef.current = false; setLiveMic(false); if (liveEventRef.current) { api('live/cancel', {method: 'POST', body: JSON.stringify({eventUuid: liveEventRef.current})}).catch(() => {}); liveEventRef.current = null; } setState('idle'); }, onError: err => setError(err.message), onNotice: message => setError(message), onMicStart: () => { liveMicRef.current = true; setLiveMic(true); setState('listening'); }, onMicStop: () => { liveMicRef.current = false; setLiveMic(false); setState('thinking'); }, onAudioStart: () => { liveAudioPlayingRef.current = true; setState('talking'); }, onAudioEnd: () => { liveAudioPlayingRef.current = false; if (!liveMicRef.current) setState('idle'); }, onInterrupted: () => setState(liveMicRef.current ? 'listening' : 'idle'), onInputTranscript: text => { liveInputRef.current = mergeTranscript(liveInputRef.current, text); setLiveCaption(liveInputRef.current); }, onOutputTranscript: text => { liveOutputRef.current = mergeTranscript(liveOutputRef.current, text); setLiveCaption(liveOutputRef.current); if (!character.voice_reply_enabled) setState('explaining'); }, onTurnComplete: finalizeLiveTurn }); await session.connect(tokenData.session.token, tokenData.session.setup); liveRef.current = session; return session; } async function startLiveTurn() { if (!character?.allow_voice_input || busy) return; setBusy(true); setError(''); try { const session = await ensureLiveConnected(); const eventUuid = uid(); const reserve = await api('live/reserve', {method: 'POST', body: JSON.stringify({characterId: character.id, eventUuid})}); setQuota(reserve.quota); liveEventRef.current = eventUuid; liveInputRef.current = ''; liveOutputRef.current = ''; setLiveCaption('Listening…'); await session.startMicrophone(); } catch (err) { if (liveEventRef.current) { await api('live/cancel', {method: 'POST', body: JSON.stringify({eventUuid: liveEventRef.current})}).catch(() => {}); liveEventRef.current = null; } setError(err.message); setState('idle'); } finally { setBusy(false); } } async function stopLiveTurn() { if (!liveRef.current || !liveMic) return; await liveRef.current.stopMicrophone(true); } async function sendLiveText(text = input) { if (!character || busy || !text.trim()) return; setBusy(true); setError(''); const clean = text.trim(); setInput(''); try { const session = await ensureLiveConnected(); const eventUuid = uid(); const reserve = await api('live/reserve', {method: 'POST', body: JSON.stringify({characterId: character.id, eventUuid})}); setQuota(reserve.quota); liveEventRef.current = eventUuid; liveInputRef.current = clean; liveOutputRef.current = ''; setState('thinking'); session.sendText(clean); } catch (err) { if (liveEventRef.current) { await api('live/cancel', {method: 'POST', body: JSON.stringify({eventUuid: liveEventRef.current})}).catch(() => {}); liveEventRef.current = null; } setError(err.message); setState('idle'); } finally { setBusy(false); } } function submitText(text = input) { if (character.interaction_mode === 'live') return sendLiveText(text); return sendStandardMessage(text); } if (!character) return
{error || 'Loading character…'}
; const isLiveAvailable = ['live', 'hybrid'].includes(character.interaction_mode); const showChat = character.show_text_chat && chatState === 'open'; const minimized = character.show_text_chat && chatState === 'minimized'; const shellClasses = [ 'tutor-shell', character.layout_mode === 'character_focus' ? 'character-focus' : 'tutor-split', `chat-${chatState}`, `framing-${character.character_framing}` ].join(' '); return
{state}
{character.profession}

{character.name}

{character.description}

{liveConnected && ● Live connected} {character.show_text_chat && chatState !== 'open' && }
{character.show_talk_button && isLiveAvailable && character.allow_voice_input && } {liveCaption &&
{liveCaption}
}
{showChat &&
{character.name} {character.interaction_mode === 'live' ? `Gemini Live · ${character.live_model}` : `Gemini chat · ${character.gemini_model}`}
{audioRef.current && }
{!messages.length &&

How can I help?

Ask a question, request a lesson, or use the administrator-enabled talk button.

} {messages.map(m =>
{m.role === 'assistant' ? character.name : 'You'}

{m.text}

)} {busy &&
{character.name}

{state === 'listening' ? 'Listening…' : 'Thinking…'}

}
{error &&
{error}
}
{ e.preventDefault(); submitText(); }}> {character.allow_voice_input && character.interaction_mode !== 'live' && }