{m.text}
AI Tutor · SpatialNex
Welcome back
Sign in to learn with animated tutors and professional assistants.
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
AI Tutor · SpatialNex
Sign in to learn with animated tutors and professional assistants.
Animated AI Professionals
Teachers, tutors, technicians, doctors for general education, support agents, and other professionals can each use their own sprites, interface, model, voice, and limits.
{character.description}
Ask a question, request a lesson, or use the administrator-enabled talk button.
{m.text}
{state === 'listening' ? 'Listening…' : 'Thinking…'}
Character Builder
The prompt and knowledge stay private on the server.
Voice behavior is locked by the administrator; users do not receive an on/off switch.
Configure the screen independently for every professional.
Access control
Limits apply to normal messages and each Gemini Live user turn.
| Name | Role | Limit | Usage | Status | ||
|---|---|---|---|---|---|---|
| {u.name} | {u.email} | {u.role} | {u.interaction_period === 'unlimited' ? 'Unlimited' : `${u.interaction_limit} / ${u.interaction_period}`} | {u.quota.remaining === null ? '—' : `${u.quota.used} used · ${u.quota.remaining} left`} | {u.status} |
Integrations
The same Gemini API key is used to provision secure ephemeral Live tokens.
Character Builder
Each character can have its own model, voice behavior, interface, sprites, and conversation mode.