| import React, { useEffect, useMemo, useState } from "react"; |
| import { |
| Eye, |
| EyeOff, |
| Loader2, |
| Lock, |
| Menu, |
| Send, |
| Volume2, |
| VolumeX, |
| X, |
| Zap, |
| } from "lucide-react"; |
| import type { InteractiveApi } from "../api"; |
| import type { Experience } from "../types"; |
| |
| |
| import { AnimatedBubble, RichDialogueText, TypingDots } from "./LiveChatPolish"; |
| import { usePersonaSpeech } from "./usePersonaSpeech"; |
|
|
| type RuntimeAction = { |
| id: string; |
| label: string; |
| category?: "expression" | "pose" | "scene" | "outfit" | string; |
| unlock_level?: number; |
| }; |
| type VersionThumb = { id: string; thumb_url?: string; active?: boolean }; |
|
|
| type SceneContext = { |
| id: string; |
| label: string; |
| icon?: string; |
| prompt?: string; |
| category?: "indoor" | "outdoor" | "public" | "private" | string; |
| }; |
|
|
| type SceneMemory = { |
| current_scene: string; |
| previous_scenes: string[]; |
| last_actions: string[]; |
| emotional_state: { mood: string; intensity: number }; |
| }; |
|
|
| type EmotionalState = { |
| trust: number; |
| intensity: number; |
| mood: string; |
| }; |
|
|
| type CharacterState = { |
| emotion: "neutral" | "happy" | "playful" | "flirty" | "shy" | "guarded" | "warm"; |
| expression: "idle" | "smile" | "smirk" | "blush"; |
| camera: "full" | "medium" | "close"; |
| gaze: "center" | "left" | "right"; |
| speaking: boolean; |
| }; |
|
|
| type ChatMessage = { |
| |
| |
| |
| |
| |
| |
| id: string; |
| sender: "user" | "ai"; |
| text: string; |
| emotion?: string; |
| }; |
|
|
| function _newMessageId(): string { |
| |
| |
| |
| try { |
| if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { |
| return crypto.randomUUID(); |
| } |
| } catch { |
| |
| } |
| return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; |
| } |
|
|
| type PersonaRuntimeState = { |
| persona: { |
| id: string; |
| name: string; |
| avatarUrl: string; |
| archetype: string; |
| mode: "image" | "video"; |
| }; |
| session: { |
| id: string; |
| level: number; |
| xp: number; |
| xpToNext: number; |
| currentVersionId: string; |
| }; |
| currentMedia: { |
| type: "image" | "video"; |
| url: string; |
| status: "idle" | "rendering" | "ready" | "error"; |
| }; |
| dialogue: { text: string }; |
| sceneContext: SceneContext; |
| sceneMemory: SceneMemory; |
| emotionalState: EmotionalState; |
| actions: { available: RuntimeAction[]; locked: RuntimeAction[] }; |
| versions: VersionThumb[]; |
| }; |
|
|
| const CAMERA_PRESETS: Record<CharacterState["camera"], { zoom: number; yOffset: number }> = { |
| close: { zoom: 1.15, yOffset: 8 }, |
| medium: { zoom: 1.0, yOffset: 0 }, |
| full: { zoom: 0.9, yOffset: -8 }, |
| }; |
|
|
| function mapEmotionToExpression(emotion: string): CharacterState["expression"] { |
| const e = String(emotion || "").toLowerCase(); |
| if (e.includes("play") || e.includes("flirt")) return "smirk"; |
| if (e.includes("warm") || e.includes("happy")) return "smile"; |
| if (e.includes("shy")) return "blush"; |
| return "idle"; |
| } |
|
|
| function SceneBadge({ scene }: { scene: SceneContext }) { |
| return ( |
| <div className="absolute top-3 left-3 px-3 py-1 bg-black/50 rounded-full text-xs border border-white/10"> |
| {scene.icon || "📍"} {scene.label || "Unknown scene"} |
| </div> |
| ); |
| } |
|
|
| function ConversationOverlay({ messages, pending }: { messages: ChatMessage[]; pending?: boolean }) { |
| if (!messages.length && !pending) return null; |
| |
| |
| |
| let lastAiIdx = -1; |
| for (let i = messages.length - 1; i >= 0; i--) { |
| if (messages[i].sender === "ai") { lastAiIdx = i; break; } |
| } |
| return ( |
| <div className="space-y-2"> |
| {messages.map((message, index) => { |
| const role: "user" | "assistant" = message.sender === "user" ? "user" : "assistant"; |
| // Stable id lets React keep the same DOM node across list shifts |
| // (prev.slice(-7) re-indexes entries on every send). Without this |
| // every bubble unmounted + remounted each turn and the slide-in |
| // animation replayed on all of them — the "auto-populate again" |
| // behaviour. Index is kept as a fallback only for historic dev |
| // data that predates the id field. |
| const key = message.id || `${message.sender}-${index}-${message.text.slice(0, 12)}`; |
| // ``lastAiIdx`` is computed by the caller and used to be |
| // referenced as ``isLatestAi`` to gate the typewriter |
| // animation. RichDialogueText handles all AI bubbles now; |
| // the index-tracking flag is intentionally dropped — kept |
| // commented so future "highlight the latest line" features |
| // know where the cheapest hook is. |
| // const isLatestAi = role === "assistant" && index === lastAiIdx; |
| void lastAiIdx; |
| return ( |
| <AnimatedBubble key={key} role={role}> |
| {/* |
| * Visual-novel rendering for the AI persona's bubbles: |
| * narration / action description ("Lina blushed playfully |
| * and said") renders italic + muted, the quoted speech |
| * ("I'm so glad you found my surprise") renders bright |
| * + normal weight. Makes the "she does X, then says Y" |
| * pattern read at a glance. |
| * |
| * User bubbles stay plain — what the player typed isn't |
| * narrated. ``isLatestAi`` is preserved as a flag for |
| * future "highlight latest" treatment without affecting |
| * the parser today. |
| */} |
| {role === "assistant" ? ( |
| <RichDialogueText text={message.text} /> |
| ) : ( |
| <span>{message.text}</span> |
| )} |
| </AnimatedBubble> |
| ); |
| })} |
| {pending ? ( |
| <AnimatedBubble role="assistant"> |
| <TypingDots /> |
| </AnimatedBubble> |
| ) : null} |
| </div> |
| ); |
| } |
|
|
| function useSceneTransition(type: "soft" | "scene" | "hard") { |
| if (type === "scene") return "duration-300 ease-out scale-[1.01] saturate-105"; |
| if (type === "hard") return "duration-300 ease-in opacity-95"; |
| return "duration-150 ease-linear"; |
| } |
|
|
| export function RuntimeShell({ api, experience, onExit }: { api: InteractiveApi; experience: Experience; onExit: () => void }) { |
| const personaId = String(experience.audience_profile?.persona_project_id || ""); |
| const [state, setState] = useState<PersonaRuntimeState | null>(null); |
| const [loading, setLoading] = useState(true); |
| const [error, setError] = useState(""); |
| const [pendingJobId, setPendingJobId] = useState(""); |
| const [chat, setChat] = useState(""); |
| const [sending, setSending] = useState(false); |
| const [transitionType, setTransitionType] = useState<"soft" | "scene" | "hard">("soft"); |
| const [showMobileDrawer, setShowMobileDrawer] = useState(false); |
| const [mobileTab, setMobileTab] = useState<"actions" | "history">("actions"); |
| const [overlaysHidden, setOverlaysHidden] = useState(false); |
| const [muted, setMuted] = useState(true); |
|
|
| |
| |
| |
| |
| |
| |
| const [messages, setMessages] = useState<ChatMessage[]>([]); |
| const [characterState, setCharacterState] = useState<CharacterState>({ |
| emotion: "neutral", |
| expression: "idle", |
| camera: "medium", |
| gaze: "center", |
| speaking: false, |
| }); |
|
|
| const sessionId = state?.session.id || ""; |
| const transitionClass = useSceneTransition(transitionType); |
| const overlayMessages = useMemo((): ChatMessage[] => { |
| const recent = messages.slice(-3); |
| if (recent.length > 0) return recent; |
| const dialogueText = state?.dialogue.text?.trim(); |
| |
| |
| |
| return dialogueText |
| ? [{ id: `dlg-${dialogueText.length}-${dialogueText.slice(0, 24)}`, sender: "ai" as const, text: dialogueText }] |
| : []; |
| }, [messages, state?.dialogue.text]); |
|
|
| useEffect(() => { |
| let alive = true; |
| async function boot() { |
| if (!personaId) { |
| setError("Persona not linked to this project."); |
| setLoading(false); |
| return; |
| } |
| try { |
| const sess = await api.personaLiveStart({ |
| persona_id: personaId, |
| mode: String(experience.audience_profile?.render_media_type || "image") === "video" ? "video" : "image", |
| }); |
| const payload = await api.personaLiveSession(sess.id); |
| if (!alive) return; |
| setState(_mapRuntime(payload)); |
| } catch (e) { |
| if (!alive) return; |
| setError((e as Error).message || "Failed to start session"); |
| } finally { |
| if (alive) setLoading(false); |
| } |
| } |
| boot(); |
| return () => { alive = false; }; |
| }, [api, personaId, experience.audience_profile?.render_media_type]); |
|
|
| useEffect(() => { |
| if (!pendingJobId || !sessionId) return; |
| const t = window.setInterval(async () => { |
| try { |
| const job = await api.personaLiveJob(pendingJobId); |
| |
| |
| |
| |
| const s = String(job.status || "").toLowerCase(); |
| if (s !== "completed" && s !== "ready") return; |
| setPendingJobId(""); |
| const latest = await api.personaLiveSession(sessionId); |
| setTransitionType("soft"); |
| setState(_mapRuntime(latest)); |
| } catch { |
| |
| } |
| }, 1200); |
| return () => window.clearInterval(t); |
| }, [api, pendingJobId, sessionId]); |
|
|
| const xpPct = useMemo(() => { |
| if (!state) return 0; |
| const progressCap = Math.max(1, state.session.xpToNext + state.session.xp); |
| return Math.max(0, Math.min(100, Math.round((state.session.xp / progressCap) * 100))); |
| }, [state]); |
| |
| |
| |
| const cameraPreset = CAMERA_PRESETS.medium; |
|
|
| |
| |
| |
| |
| |
| |
| const speech = usePersonaSpeech(); |
|
|
| |
| |
| |
| |
| |
| useEffect(() => { |
| if (muted && speech.enabled) speech.setEnabled(false); |
| |
| |
| }, []); |
|
|
| const toggleMute = React.useCallback(() => { |
| setMuted((prev) => { |
| const nextMuted = !prev; |
| |
| |
| speech.setEnabled(!nextMuted); |
| return nextMuted; |
| }); |
| }, [speech]); |
|
|
| useEffect(() => { |
| const text = state?.dialogue.text; |
| if (!text) return; |
| |
| |
| |
| speech.speak(text); |
| setCharacterState((prev) => ({ |
| ...prev, |
| speaking: true, |
| expression: mapEmotionToExpression(state.emotionalState.mood), |
| emotion: (state.emotionalState.mood as CharacterState["emotion"]) || "neutral", |
| })); |
| const t = window.setTimeout(() => { |
| setCharacterState((prev) => ({ ...prev, speaking: false })); |
| }, 1800); |
| return () => window.clearTimeout(t); |
| |
| |
| |
| |
| |
| }, [state?.dialogue.text, state?.emotionalState.mood]); |
|
|
| function applyCharacterTurn(opts: { userText?: string; aiText: string; emotion?: string }) { |
| const userText = opts.userText?.trim() || ""; |
| if (userText) { |
| setMessages((prev) => [...prev.slice(-7), { id: _newMessageId(), sender: "user", text: userText }]); |
| } |
| setMessages((prev) => [...prev.slice(-7), { id: _newMessageId(), sender: "ai", text: opts.aiText, emotion: opts.emotion }]); |
| setCharacterState((prev) => ({ |
| ...prev, |
| emotion: (opts.emotion as CharacterState["emotion"]) || prev.emotion, |
| expression: mapEmotionToExpression(opts.emotion || prev.emotion), |
| speaking: true, |
| })); |
| } |
|
|
| if (loading) { |
| return <div className="h-full flex items-center justify-center"><Loader2 className="w-5 h-5 animate-spin" /></div>; |
| } |
| if (error || !state) { |
| return ( |
| <div className="h-full p-6"> |
| <button type="button" onClick={onExit} className="text-xs text-[#aaa] hover:text-white">← Back</button> |
| <p className="mt-3 text-red-400 text-sm">{error || "Unable to load runtime."}</p> |
| </div> |
| ); |
| } |
|
|
| async function fireAction(actionId: string) { |
| if (!sessionId || sending) return; |
| setSending(true); |
| setState((prev) => (prev ? { ...prev, currentMedia: { ...prev.currentMedia, status: "rendering" } } : prev)); |
| try { |
| const res = await api.personaLiveAction(sessionId, { action_id: actionId, message: chat.trim() || undefined }); |
| setTransitionType((res.scene_context ? "scene" : "soft") as "soft" | "scene"); |
| const dialogueText = typeof res.dialogue === "string" ? res.dialogue : (res.dialogue?.text || state?.dialogue.text || ""); |
| applyCharacterTurn({ |
| userText: actionId.replaceAll("_", " "), |
| aiText: dialogueText, |
| emotion: ((res.emotional_state as EmotionalState | undefined)?.mood || state?.emotionalState.mood || "neutral"), |
| }); |
| |
| |
| |
| |
| |
| |
| |
| const media = (res.media || {}) as Record<string, unknown>; |
| const mediaUrl = String(media.url || ""); |
| const mediaStatus = String(media.status || "").toLowerCase(); |
| const mediaType = String(media.type || "image") === "video" ? "video" : "image"; |
| const libraryHit = mediaStatus === "ready" && !!mediaUrl; |
| |
| |
| |
| |
| |
| const nextXp = typeof res.xp === "number" ? res.xp : null; |
| const nextLevel = typeof res.level === "number" ? res.level : null; |
| const nextXpToNext = typeof res.xp_to_next === "number" ? res.xp_to_next : null; |
| setState((prev) => { |
| if (!prev) return prev; |
| return { |
| ...prev, |
| dialogue: { text: dialogueText }, |
| sceneContext: (res.scene_context as SceneContext) || prev.sceneContext, |
| sceneMemory: (res.scene_memory as SceneMemory) || prev.sceneMemory, |
| emotionalState: (res.emotional_state as EmotionalState) || prev.emotionalState, |
| currentMedia: libraryHit |
| ? { type: mediaType, url: mediaUrl, status: "ready" } |
| : prev.currentMedia, |
| session: { |
| ...prev.session, |
| xp: nextXp ?? prev.session.xp, |
| level: nextLevel ?? prev.session.level, |
| xpToNext: nextXpToNext ?? prev.session.xpToNext, |
| }, |
| }; |
| }); |
| if (libraryHit || res.render_skipped) { |
| setState((prev) => (prev ? { ...prev, currentMedia: { ...prev.currentMedia, status: "ready" } } : prev)); |
| } else { |
| setPendingJobId(res.job_id || ""); |
| } |
| setChat(""); |
| } catch (err) { |
| |
| |
| |
| |
| |
| console.error("[PersonaLive] action failed:", err); |
| setState((prev) => (prev ? { ...prev, currentMedia: { ...prev.currentMedia, status: "error" } } : prev)); |
| } finally { |
| setSending(false); |
| } |
| } |
|
|
| async function sendChat() { |
| if (!chat.trim() || !sessionId || sending) return; |
| const userText = chat.trim(); |
| |
| |
| setMessages((prev) => [...prev.slice(-7), { id: _newMessageId(), sender: "user", text: userText }]); |
| setChat(""); |
| setSending(true); |
| try { |
| const res = await api.personaLiveChat(sessionId, userText); |
| const maybeScene = (res.scene_context as SceneContext | undefined) || undefined; |
| |
| |
| |
| const aiText = String(res.dialogue?.text || ""); |
| const emotion = ((res.emotional_state as EmotionalState | undefined)?.mood |
| || state?.emotionalState.mood |
| || "neutral"); |
| setMessages((prev) => [...prev.slice(-7), { id: _newMessageId(), sender: "ai", text: aiText, emotion }]); |
| setCharacterState((prev) => ({ |
| ...prev, |
| emotion: emotion as CharacterState["emotion"], |
| expression: mapEmotionToExpression(emotion), |
| speaking: true, |
| })); |
| |
| |
| |
| |
| |
| const chatNextXp = typeof res.xp === "number" ? res.xp : null; |
| const chatNextLevel = typeof res.level === "number" ? res.level : null; |
| const chatNextXpToNext = typeof res.xp_to_next === "number" ? res.xp_to_next : null; |
| setState((prev) => { |
| if (!prev) return prev; |
| setTransitionType(maybeScene && maybeScene.id !== prev.sceneContext.id ? "scene" : "soft"); |
| return { |
| ...prev, |
| dialogue: res.dialogue, |
| sceneContext: maybeScene || prev.sceneContext, |
| sceneMemory: (res.scene_memory as SceneMemory) || prev.sceneMemory, |
| emotionalState: (res.emotional_state as EmotionalState) || prev.emotionalState, |
| session: { |
| ...prev.session, |
| xp: chatNextXp ?? prev.session.xp, |
| level: chatNextLevel ?? prev.session.level, |
| xpToNext: chatNextXpToNext ?? prev.session.xpToNext, |
| }, |
| }; |
| }); |
| |
| |
| |
| } catch (err) { |
| |
| |
| setMessages((prev) => { |
| if (!prev.length) return prev; |
| const copy = [...prev]; |
| const last = copy[copy.length - 1]; |
| if (last.sender === "user") { |
| copy[copy.length - 1] = { ...last, text: last.text + " ⚠︎" }; |
| } |
| return copy; |
| }); |
| } finally { |
| setSending(false); |
| } |
| } |
|
|
| async function restore(versionId: string) { |
| if (!sessionId) return; |
| await api.personaLiveRestore(sessionId, versionId); |
| const latest = await api.personaLiveSession(sessionId); |
| setTransitionType("hard"); |
| setState(_mapRuntime(latest)); |
| } |
|
|
| return ( |
| <div className="h-full min-h-0 flex flex-col bg-black"> |
| <div className="hidden lg:block px-4 py-2 border-b border-[#2a2a2a] text-xs text-[#aaa]">Persona Live • {state.persona.name}</div> |
| |
| {/* Mobile immersive runtime */} |
| <div className="lg:hidden relative flex-1 min-h-0 overflow-hidden"> |
| <div className={`absolute inset-0 transition-all ${transitionClass}`} style={{ transform: `scale(${cameraPreset.zoom}) translateY(${cameraPreset.yOffset}%)` }}> |
| {/* |
| * ``object-cover`` fills the viewport (no letterbox bars on |
| * mobile — phone players want immersive) but anchored |
| * ``object-top`` so the face stays visible when the source |
| * is squarer than the phone's aspect. Without ``object-top`` |
| * the default center-crop chopped the eyes off on tall |
| * phones. |
| */} |
| {state.currentMedia.url ? ( |
| state.currentMedia.type === "video" ? ( |
| <video src={state.currentMedia.url} className="absolute inset-0 w-full h-full object-cover object-top" autoPlay loop muted={muted} playsInline /> |
| ) : ( |
| <img src={state.currentMedia.url} alt={state.persona.name} className="absolute inset-0 w-full h-full object-cover object-top" /> |
| ) |
| ) : ( |
| <div className="absolute inset-0 grid place-items-center text-[#888] text-sm">No render yet</div> |
| )} |
| </div> |
| <div className="absolute inset-0 bg-gradient-to-b from-black/35 via-transparent to-black/60 pointer-events-none" /> |
| |
| {!overlaysHidden && ( |
| <> |
| <div className="absolute top-4 left-3 z-20"> |
| <div className="inline-flex items-center gap-2 px-2.5 py-1.5 rounded-full bg-black/50 border border-white/15 backdrop-blur"> |
| {state.persona.avatarUrl ? <img src={state.persona.avatarUrl} alt={state.persona.name} className="w-6 h-6 rounded-full object-cover" /> : <div className="w-6 h-6 rounded-full bg-[#333]" />} |
| <span className="text-xs font-medium">{state.persona.name}</span> |
| </div> |
| <div className="mt-2"> |
| <SceneBadge scene={state.sceneContext} /> |
| </div> |
| </div> |
| |
| <div className="absolute top-4 left-1/2 -translate-x-1/2 z-20"> |
| <div className="px-3 py-1 rounded-full bg-black/55 border border-white/15 text-[11px] backdrop-blur"> |
| L{state.session.level} · {state.session.xp} XP |
| </div> |
| </div> |
| |
| <div className="absolute top-4 right-3 z-20 flex flex-col gap-2"> |
| <button |
| type="button" |
| onClick={toggleMute} |
| aria-label={muted ? "Unmute (turn on AI voice)" : "Mute"} |
| aria-pressed={muted} |
| title={muted ? "Unmute" : "Mute"} |
| className="w-9 h-9 rounded-full bg-black/55 border border-white/15 grid place-items-center focus:outline-none focus-visible:ring-2 focus-visible:ring-white" |
| > |
| {muted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />} |
| </button> |
| <button type="button" onClick={() => setOverlaysHidden((v) => !v)} className="w-9 h-9 rounded-full bg-black/55 border border-white/15 grid place-items-center"> |
| {overlaysHidden ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />} |
| </button> |
| <button type="button" onClick={() => setShowMobileDrawer((v) => !v)} className="w-9 h-9 rounded-full bg-black/55 border border-white/15 grid place-items-center"> |
| <Menu className="w-4 h-4" /> |
| </button> |
| <button type="button" onClick={onExit} className="w-9 h-9 rounded-full bg-black/55 border border-white/15 grid place-items-center"> |
| <X className="w-4 h-4" /> |
| </button> |
| </div> |
| |
| <div className="absolute left-3 right-3 bottom-28 z-20"> |
| <ConversationOverlay messages={overlayMessages} pending={sending} /> |
| </div> |
| </> |
| )} |
| |
| <button |
| type="button" |
| onClick={() => setShowMobileDrawer(true)} |
| className="absolute right-3 bottom-24 z-20 inline-flex items-center gap-1.5 px-3 py-2 rounded-full bg-[#8b5cf6]/90 text-white text-xs shadow-lg" |
| > |
| <Zap className="w-3.5 h-3.5" /> Live Action |
| </button> |
| |
| <div className="absolute left-0 right-0 bottom-0 z-20 p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"> |
| <div className="flex items-center gap-2 rounded-2xl bg-black/55 backdrop-blur border border-white/15 px-2 py-2"> |
| <input |
| value={chat} |
| onChange={(e) => setChat(e.target.value)} |
| onKeyDown={(e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| void sendChat(); |
| } |
| }} |
| placeholder="Message her..." |
| disabled={sending} |
| maxLength={500} |
| className="flex-1 bg-transparent text-sm outline-none px-2 disabled:opacity-60" |
| /> |
| <button |
| type="button" |
| onClick={sendChat} |
| disabled={!chat.trim() || sending} |
| aria-label="Send" |
| className="w-9 h-9 rounded-full bg-[#ffffff1a] border border-white/20 grid place-items-center disabled:opacity-50 disabled:cursor-not-allowed" |
| > |
| {sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />} |
| </button> |
| </div> |
| </div> |
| |
| {showMobileDrawer && ( |
| <div className="absolute inset-0 z-30"> |
| <button type="button" className="absolute inset-0 bg-black/45" onClick={() => setShowMobileDrawer(false)} /> |
| <div className="absolute left-0 right-0 bottom-0 rounded-t-2xl border-t border-white/10 bg-[#101010] p-4 max-h-[65vh] overflow-y-auto"> |
| <div className="flex items-center justify-between mb-3"> |
| <div className="text-sm font-medium">Live Action</div> |
| <button type="button" onClick={() => setShowMobileDrawer(false)} className="w-7 h-7 rounded-full border border-white/15 grid place-items-center"><X className="w-3.5 h-3.5" /></button> |
| </div> |
| <div className="flex gap-2 mb-3"> |
| <button type="button" onClick={() => setMobileTab("actions")} className={`px-3 py-1.5 rounded-full text-xs ${mobileTab === "actions" ? "bg-[#8b5cf6] text-white" : "bg-[#1c1c1c] text-[#bbb]"}`}>Actions</button> |
| <button type="button" onClick={() => setMobileTab("history")} className={`px-3 py-1.5 rounded-full text-xs ${mobileTab === "history" ? "bg-[#8b5cf6] text-white" : "bg-[#1c1c1c] text-[#bbb]"}`}>History</button> |
| </div> |
| {mobileTab === "actions" ? ( |
| <> |
| <div className="text-[11px] text-[#aaa] mb-2">Level {state.session.level} · {state.session.xp} XP ({state.session.xpToNext} to next)</div> |
| <div className="h-2 rounded-full bg-[#222] overflow-hidden mb-3"><div className="h-full bg-gradient-to-r from-[#8b5cf6] to-[#3ea6ff]" style={{ width: `${xpPct}%` }} /></div> |
| <div className="text-xs text-[#aaa] mb-2">Available</div> |
| <div className="grid grid-cols-1 gap-2"> |
| {state.actions.available.map((a) => ( |
| <button key={a.id} type="button" onClick={() => { void fireAction(a.id); setShowMobileDrawer(false); }} disabled={sending || !!pendingJobId} className="text-left rounded-md border border-[#3a3a3a] bg-[#1a1a1a] px-3 py-2 text-sm disabled:opacity-50">{a.label}</button> |
| ))} |
| </div> |
| <div className="text-xs text-[#aaa] mt-4 mb-2">Locked</div> |
| <div className="grid grid-cols-1 gap-2"> |
| {state.actions.locked.map((a) => ( |
| <div key={a.id} className="rounded-md border border-[#2d2d2d] bg-[#151515] px-3 py-2 text-sm text-[#7c7c7c] flex items-center justify-between"> |
| <span>{a.label}</span> |
| <span className="inline-flex items-center gap-1 text-[10px]"><Lock className="w-3 h-3" /> L{a.unlock_level}</span> |
| </div> |
| ))} |
| </div> |
| </> |
| ) : ( |
| <> |
| <div className="text-xs text-[#aaa] mb-2">Version history</div> |
| <div className="grid grid-cols-4 gap-2"> |
| {state.versions.slice(0, 8).map((v) => ( |
| <button |
| key={v.id} |
| type="button" |
| onClick={() => { void restore(v.id); setShowMobileDrawer(false); }} |
| className={["relative w-full aspect-square rounded border overflow-hidden", v.active ? "border-[#8b5cf6]" : "border-[#3a3a3a]"].join(" ")} |
| > |
| {v.thumb_url ? <img src={v.thumb_url} alt={v.id} className="w-full h-full object-cover" /> : <div className="w-full h-full bg-[#222]" />} |
| </button> |
| ))} |
| </div> |
| </> |
| )} |
| </div> |
| </div> |
| )} |
| </div> |
|
|
| {} |
| <div className="hidden lg:grid flex-1 min-h-0 lg:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.65fr)] gap-4 p-4"> |
| <div className="min-h-0 flex flex-col gap-3"> |
| <div className="relative flex-1 min-h-[320px] rounded-2xl border border-[#343434] overflow-hidden bg-[#101010]"> |
| <div className={`absolute inset-0 transition-all ${transitionClass}`} style={{ transform: `scale(${cameraPreset.zoom}) translateY(${cameraPreset.yOffset}%)` }}> |
| {/* |
| * Desktop fits the WHOLE image inside the panel |
| * (``object-contain``) so the face is never trimmed — |
| * the portrait is the focus of Persona Live, and |
| * cropping the head off the top is the worst possible |
| * default. Letterboxing the rounded panel with a dark |
| * background reads as intentional matting; the user |
| * sees Lina end-to-end. Mobile keeps ``object-cover`` |
| * for the immersive full-screen takeover. |
| */} |
| {state.currentMedia.url ? ( |
| state.currentMedia.type === "video" ? ( |
| <video src={state.currentMedia.url} className="absolute inset-0 w-full h-full object-contain" autoPlay loop muted playsInline /> |
| ) : ( |
| <img src={state.currentMedia.url} alt={state.persona.name} className="absolute inset-0 w-full h-full object-contain" /> |
| ) |
| ) : ( |
| <div className="absolute inset-0 grid place-items-center text-[#666] text-sm">No render yet</div> |
| )} |
| </div> |
| <div className="absolute inset-0 bg-gradient-to-b from-black/35 via-transparent to-black/65 pointer-events-none" /> |
| |
| <div className="absolute top-3 left-3 z-20 inline-flex items-center gap-2 px-2.5 py-1.5 rounded-full bg-black/50 border border-white/15 backdrop-blur"> |
| {state.persona.avatarUrl ? <img src={state.persona.avatarUrl} alt={state.persona.name} className="w-7 h-7 rounded-full object-cover" /> : <div className="w-7 h-7 rounded-full bg-[#333]" />} |
| <span className="text-sm font-medium">{state.persona.name}</span> |
| </div> |
| <div className="absolute top-3 left-[220px] z-20 px-3 py-1 rounded-full bg-black/55 border border-white/15 text-xs backdrop-blur"> |
| Level {state.session.level} |
| </div> |
| <div className="absolute top-3 right-3 z-20 flex flex-col gap-2"> |
| <button type="button" onClick={onExit} className="w-10 h-10 rounded-full bg-black/55 border border-white/15 grid place-items-center"><X className="w-4 h-4" /></button> |
| <button |
| type="button" |
| onClick={toggleMute} |
| aria-label={muted ? "Unmute (turn on AI voice)" : "Mute"} |
| aria-pressed={muted} |
| title={muted ? "Unmute" : "Mute"} |
| className="w-10 h-10 rounded-full bg-black/55 border border-white/15 grid place-items-center focus:outline-none focus-visible:ring-2 focus-visible:ring-white" |
| > |
| {muted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />} |
| </button> |
| <button type="button" onClick={() => setOverlaysHidden((v) => !v)} className="w-10 h-10 rounded-full bg-black/55 border border-white/15 grid place-items-center">{overlaysHidden ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}</button> |
| <button type="button" className="w-10 h-10 rounded-full bg-black/55 border border-white/15 grid place-items-center"><Menu className="w-4 h-4" /></button> |
| </div> |
| |
| {!overlaysHidden && ( |
| <> |
| <SceneBadge scene={state.sceneContext} /> |
| <div className="absolute left-4 right-4 bottom-24"> |
| <ConversationOverlay messages={overlayMessages} pending={sending} /> |
| </div> |
| </> |
| )} |
| <div className="absolute left-4 right-4 bottom-4 flex items-center gap-2 rounded-full bg-black/55 border border-white/15 backdrop-blur px-2 py-2"> |
| <input |
| value={chat} |
| onChange={(e) => setChat(e.target.value)} |
| onKeyDown={(e) => { |
| // Enter sends; Shift+Enter is a no-op on single-line <input> |
| // but we still block it so a future upgrade to <textarea> |
| // Just Works. |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| void sendChat(); |
| } |
| }} |
| placeholder="Ask anything" |
| disabled={sending} |
| maxLength={500} |
| className="flex-1 bg-transparent text-sm outline-none px-2 disabled:opacity-60" |
| /> |
| <button |
| type="button" |
| onClick={sendChat} |
| disabled={!chat.trim() || sending} |
| aria-label="Send" |
| className="w-9 h-9 rounded-full bg-[#ffffff1a] border border-white/20 grid place-items-center disabled:opacity-50 disabled:cursor-not-allowed" |
| > |
| {sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />} |
| </button> |
| </div> |
| {state.currentMedia.status === "rendering" && ( |
| <div className="absolute inset-0 bg-black/35 grid place-items-center pointer-events-none"> |
| <div className="inline-flex items-center gap-2 text-xs bg-[#111] border border-[#3a3a3a] rounded-full px-3 py-1.5"> |
| <Loader2 className="w-3.5 h-3.5 animate-spin" /> Rendering… |
| </div> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| <div className="min-h-0 overflow-y-auto rounded-2xl border border-[#2f2f2f] bg-[#121212] p-5 space-y-4"> |
| <div className="flex items-center justify-between border-b border-[#262626] pb-3"> |
| <div className="text-[30px] font-semibold">Live Action</div> |
| <span className="text-xs border border-white/20 rounded-full px-2 py-1 text-[#d0d0d0]">Beta v2</span> |
| </div> |
| |
| <div className="rounded-xl bg-[#1d1d1d] border border-[#2e2e2e] p-4"> |
| <div className="flex items-center justify-between text-sm"> |
| <div className="inline-flex items-center gap-2"><span className="w-7 h-7 rounded-full bg-black/60 border border-white/20 grid place-items-center text-xs">{state.session.level}</span>Level {state.session.level}</div> |
| <div className="text-xs text-[#bbb]">{state.session.xpToNext} to next</div> |
| </div> |
| <div className="mt-3 h-2 rounded-full bg-[#313131] overflow-hidden"><div className="h-full bg-gradient-to-r from-[#8b5cf6] to-[#3ea6ff]" style={{ width: `${xpPct}%` }} /></div> |
| <div className="mt-2 text-xs text-[#cacaca]">{state.session.xp} / {state.session.xp + state.session.xpToNext} XP</div> |
| </div> |
| |
| <div className="space-y-2"> |
| {state.actions.available.map((a) => ( |
| <button |
| key={a.id} |
| type="button" |
| onClick={() => fireAction(a.id)} |
| disabled={sending || !!pendingJobId} |
| className="w-full text-left rounded-xl border border-[#2f2f2f] bg-[#1a1a1a] hover:border-[#8b5cf6] px-4 py-3 text-sm disabled:opacity-50 flex items-center justify-between" |
| > |
| <span>{a.label}</span> |
| <span className="w-8 h-8 rounded-full bg-[#ec4899] text-white grid place-items-center">▶</span> |
| </button> |
| ))} |
| {state.actions.locked.map((a) => ( |
| <div key={a.id} className="w-full rounded-xl border border-[#2a2a2a] bg-[#171717] px-4 py-3 text-sm text-[#8d8d8d] flex items-center justify-between"> |
| <span>{a.label}</span> |
| <span className="inline-flex items-center gap-2 text-xs"><span className="w-7 h-7 rounded-full bg-[#4338ca] text-white grid place-items-center"><Lock className="w-3 h-3" /></span>Level {a.unlock_level}</span> |
| </div> |
| ))} |
| </div> |
| |
| <div className="pt-2"> |
| <div className="text-xs text-[#aaa] mb-2">History</div> |
| <div className="grid grid-cols-4 gap-2"> |
| {state.versions.slice(0, 8).map((v) => ( |
| <button |
| key={v.id} |
| type="button" |
| onClick={() => restore(v.id)} |
| className={["relative w-full aspect-square rounded border overflow-hidden", v.active ? "border-[#8b5cf6]" : "border-[#3a3a3a]"].join(" ")} |
| > |
| {v.thumb_url ? <img src={v.thumb_url} alt={v.id} className="w-full h-full object-cover" /> : <div className="w-full h-full bg-[#222]" />} |
| </button> |
| ))} |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function _mapRuntime(payload: Record<string, unknown>): PersonaRuntimeState { |
| const persona = (payload.persona || {}) as Record<string, unknown>; |
| const session = (payload.session || {}) as Record<string, unknown>; |
| const currentMedia = (payload.current_media || {}) as Record<string, unknown>; |
| const actions = (payload.actions || {}) as Record<string, unknown>; |
| const dialogue = (payload.dialogue || {}) as Record<string, unknown>; |
| const versions = Array.isArray(payload.versions) ? payload.versions : []; |
| const sceneContext = (payload.scene_context || {}) as Record<string, unknown>; |
| const sceneMemory = (payload.scene_memory || {}) as Record<string, unknown>; |
| const emotionalState = (payload.emotional_state || {}) as Record<string, unknown>; |
|
|
| return { |
| persona: { |
| id: String(persona.id || ""), |
| name: String(persona.name || "Persona"), |
| avatarUrl: String(persona.avatar_url || ""), |
| archetype: String(persona.archetype || ""), |
| mode: String(persona.mode || "image") === "video" ? "video" : "image", |
| }, |
| session: { |
| id: String(session.id || ""), |
| level: Number(session.level || 1), |
| xp: Number(session.xp || 0), |
| xpToNext: Number(session.xp_to_next || 35), |
| currentVersionId: String(session.current_version_id || ""), |
| }, |
| currentMedia: { |
| type: String(currentMedia.type || "image") === "video" ? "video" : "image", |
| url: String(currentMedia.url || ""), |
| status: String(currentMedia.status || "idle") as "idle" | "rendering" | "ready" | "error", |
| }, |
| dialogue: { text: String(dialogue.text || "") }, |
| sceneContext: { |
| id: String(sceneContext.id || "apartment"), |
| label: String(sceneContext.label || "Her apartment"), |
| icon: String(sceneContext.icon || "🏠"), |
| prompt: String(sceneContext.prompt || ""), |
| category: String(sceneContext.category || "private"), |
| }, |
| sceneMemory: { |
| current_scene: String(sceneMemory.current_scene || "apartment"), |
| previous_scenes: (Array.isArray(sceneMemory.previous_scenes) ? sceneMemory.previous_scenes : []).map(String), |
| last_actions: (Array.isArray(sceneMemory.last_actions) ? sceneMemory.last_actions : []).map(String), |
| emotional_state: { |
| mood: String((sceneMemory.emotional_state as Record<string, unknown> | undefined)?.mood || "guarded"), |
| intensity: Number((sceneMemory.emotional_state as Record<string, unknown> | undefined)?.intensity || 25), |
| }, |
| }, |
| emotionalState: { |
| trust: Number(emotionalState.trust || 35), |
| intensity: Number(emotionalState.intensity || 25), |
| mood: String(emotionalState.mood || "guarded"), |
| }, |
| actions: { |
| available: (Array.isArray(actions.available) ? actions.available : []) as RuntimeAction[], |
| locked: (Array.isArray(actions.locked) ? actions.locked : []) as RuntimeAction[], |
| }, |
| versions: versions as VersionThumb[], |
| }; |
| } |
|
|