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"; // Polished chat bubbles + typewriter reveal for persona live chat. // Pure presentation layer — no state, no API assumptions. 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 = { // Stable id — used as the React key in ConversationOverlay. Without it, // the previous implementation keyed on ``${sender}-${index}-${text.slice(0,12)}`` // and ``prev.slice(-7)`` re-indexed every surviving bubble on every new // turn, which made React unmount + remount each one → the slide-in / // typewriter animations replayed on every send, producing the "all the // bubbles auto-populate again" behaviour the user reported. id: string; sender: "user" | "ai"; text: string; emotion?: string; }; function _newMessageId(): string { // crypto.randomUUID() is available in every modern browser + jsdom; fall // back to a timestamp + random suffix so the unit test harness never // fails on environments without it. try { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } } catch { /* fallthrough */ } 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 = { 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 (
{scene.icon || "📍"} {scene.label || "Unknown scene"}
); } function ConversationOverlay({ messages, pending }: { messages: ChatMessage[]; pending?: boolean }) { if (!messages.length && !pending) return null; // Index of the most-recent AI message — that's the one we typewriter-reveal. // Older AI replies render in full so scrolling through history doesn't // re-animate every bubble. let lastAiIdx = -1; for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].sender === "ai") { lastAiIdx = i; break; } } return (
{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 ( {/* * 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" ? ( ) : ( {message.text} )} ); })} {pending ? ( ) : null}
); } 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(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); // One mute button, two side-effects: hush the