// frontend/src/ui/interactive/WizardAutoPreview.tsx /** * WizardAutoPreview — editable review of the AI-filled plan. * * Second step of the AUTO wizard. Receives a PlanAutoResult from * WizardAuto (the one-box entry), renders every field as an * editable control, then on 'Create project': * * 1. POST /experiences with the final edited values * 2. POST /experiences/{id}/auto-generate * 3. onCreated(id) → InteractiveHost swaps to editor * * Rename rules follow the UX spec: * - Branches → Choices * - Depth → Steps * - Scenes per branch → Scenes per path * * The 'source' badge shows 'AI' when the LLM composed the plan, * 'Smart defaults' when the heuristic fallback fired — honest * labeling so the viewer knows what's editable versus generated. * * Failure modes: * - Create fails → inline error, stay on preview. * - Generate fails → project still exists (created in step 1); * we surface a toast + still transition the * editor so the user can seed manually. * * Production fixes in this version: * - Persona-linked experiences now persist the exact selected * persona image URLs when available. * - Persona live defaults to image rendering for lower-VRAM / * faster first-run behavior unless explicitly overridden. * - Payload generation is centralized and trimmed consistently. */ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { ArrowLeft, Check, ChevronDown, ChevronUp, Clock, Compass, Heart, Pencil, Sparkles, Target, } from "lucide-react"; import { createInteractiveApi } from "./api"; import { ExpertModeToggle } from "./ExpertLogPanel"; import type { ExperienceMode, HealthInfo, PlanAutoForm, PlanAutoResult, } from "./types"; import { InteractiveApiError } from "./types"; import { ErrorBanner, PrimaryButton, useAsyncResource, useToast, } from "./ui"; import { startGeneration, useWizardProgress, dismissOverlay, } from "./wizardProgressStore"; const MODE_LABELS: Record = { sfw_general: "General (safe for work)", sfw_education: "Education", language_learning: "Language learning", enterprise_training: "Enterprise training", social_romantic: "Social / romantic", mature_gated: "Mature (gated)", }; const LEVEL_LABELS: Record = { beginner: "Beginner", intermediate: "Intermediate", advanced: "Advanced", }; const LANG_LABELS: Record = { en: "English", es: "Spanish", fr: "French", de: "German", it: "Italian", pt: "Portuguese", ja: "Japanese", zh: "Chinese", }; const GENERATE_STEPS = [ { label: "Saving your project" }, { label: "Drafting the scene graph" }, { label: "Writing dialogue + choices" }, { label: "Rendering scenes" }, { label: "Opening the editor" }, ]; export interface InteractionSelection { interaction_type: "standard_project" | "persona_live_play"; persona_project_id?: string; persona_label?: string; persona_avatar_url?: string; persona_portrait_url?: string; persona_image_fit?: "cover" | "contain"; persona_image_position?: string; render_media_type?: "video" | "image"; } export interface WizardAutoPreviewProps { backendUrl: string; apiKey?: string; initial: PlanAutoResult; originalIdea: string; interaction: InteractionSelection; onCreated: (experienceId: string) => void; onStartOver: () => void; } export function WizardAutoPreview({ backendUrl, apiKey, initial, originalIdea, interaction, onCreated, onStartOver, }: WizardAutoPreviewProps) { const api = useMemo( () => createInteractiveApi(backendUrl, apiKey), [backendUrl, apiKey], ); const toast = useToast(); const [form, setForm] = useState({ ...initial.form, render_media_type: initial.form.render_media_type || interaction.render_media_type || (interaction.interaction_type === "persona_live_play" ? "image" : "video"), }); const [advancedOpen, setAdvancedOpen] = useState(false); // Persona-live default view is the Approve hero; Customize is an // inline expansion that reveals the existing fields. Non-persona- // live keeps the legacy single-form layout. const [customizeOpen, setCustomizeOpen] = useState(false); const [error, setError] = useState(null); // Resolve the persona's portrait when the interaction selection // didn't include one. Same pattern Step 0 uses (`Step0Prompt.tsx`) // — the LS persona cache that feeds these screens doesn't store // ``avatar_url`` reliably (App.tsx writers omit it), so we hit // ``GET /projects/{id}`` and pull ``persona_appearance.selected_filename`` // to build a working ``/files/...`` URL. Without this, the persona // preview card on the WizardAutoPreview surface kept rendering an // empty grey square + the bare label — operators couldn't tell at // a glance whose persona they had selected. const [resolvedAvatar, setResolvedAvatar] = useState(""); const [resolvedArchetype, setResolvedArchetype] = useState(""); // Pulled from the persona project so the Render plan panel can // show the right Tier-2 library count (17 SFW, 23 with NSFW). const [personaAllowExplicit, setPersonaAllowExplicit] = useState(false); const [libraryAlreadyBuilt, setLibraryAlreadyBuilt] = useState(0); useEffect(() => { const pid = interaction.persona_project_id; if (!pid) return; const ctrl = new AbortController(); const base = backendUrl.replace(/\/+$/, ""); fetch(`${base}/projects/${encodeURIComponent(pid)}`, { signal: ctrl.signal, credentials: "include", }) .then((r) => (r.ok ? r.json() : null)) .then((body) => { if (!body || !body.ok || !body.project) return; const project = body.project as { persona_appearance?: { selected_filename?: unknown; asset_library?: Record; }; persona_agent?: { persona_class?: unknown; response_style?: { tone?: unknown }; safety?: { allow_explicit?: unknown }; }; }; const filename = String( project.persona_appearance?.selected_filename || "", ).trim(); if (filename && !interaction.persona_avatar_url) { setResolvedAvatar(`${base}/files/${filename}`); } // Allow_explicit drives the NSFW Tier 2 row count (+6 specs). setPersonaAllowExplicit( Boolean(project.persona_agent?.safety?.allow_explicit), ); // Already-built library count — Phase 3 build is idempotent // so this many specs will SKIP rendering on this run. Lets // the Render plan show "23 planned · 11 already cached → 12 // new renders this run" instead of misleading the operator // about how long the wizard will take. const lib = project.persona_appearance?.asset_library; if (lib && typeof lib === "object") { setLibraryAlreadyBuilt(Object.keys(lib).length); } const archetype = String(project.persona_agent?.persona_class || "").trim() || String(project.persona_agent?.response_style?.tone || "").trim(); if (archetype) setResolvedArchetype(archetype); }) .catch(() => { /* swallow — card falls back to monogram */ }); return () => ctrl.abort(); }, [backendUrl, interaction.persona_project_id, interaction.persona_avatar_url]); // Progress lives in a module-level store so the modal survives // tab switches mid-generation. WizardAutoPreview unmounts when // the user clicks Chat / Imagine / Voice; the SSE stream and // counters keep running, and the global overlay rendered at App // level (see App.tsx) stays visible the whole time. const progress = useWizardProgress(); const submitting = progress.active; const genStep = progress.genStep; const renderTotal = progress.renderTotal; const renderDone = progress.renderDone; const renderSkipped = progress.renderSkipped; const currentSceneTitle = progress.currentSceneTitle; const personaLive = interaction.interaction_type === "persona_live_play"; const health = useAsyncResource( (signal) => api.health(signal), [api], ); const renderEnabled = health.data?.playback?.render_enabled !== false; const patch = useCallback( (key: K, value: PlanAutoForm[K]) => { setForm((prev) => ({ ...prev, [key]: value })); }, [], ); const buildCreatePayload = useCallback(() => { const renderMediaType = form.render_media_type || interaction.render_media_type || (personaLive ? "image" : "video"); return { title: (form.title || "").trim(), description: (form.prompt || "").trim(), experience_mode: form.experience_mode, policy_profile_id: form.policy_profile_id, project_type: personaLive ? "persona_live" : "standard", audience_profile: { role: form.audience_role, level: form.audience_level, language: form.audience_language, locale_hint: (form.audience_locale_hint || "").trim(), interaction_type: interaction.interaction_type, persona_project_id: cleanOptional(interaction.persona_project_id), persona_label: cleanOptional(interaction.persona_label), persona_avatar_url: cleanOptional(interaction.persona_avatar_url), persona_portrait_url: cleanOptional(interaction.persona_portrait_url), persona_image_fit: interaction.persona_image_fit || (personaLive ? "contain" : undefined), persona_image_position: cleanOptional( interaction.persona_image_position || (personaLive ? "center" : undefined), ), render_media_type: renderMediaType, }, }; }, [form, interaction, personaLive]); const onSubmit = useCallback(async () => { setError(null); // Delegate to the module-level store. The store owns the SSE // stream + all progress counters; this lets the global overlay // keep showing the modal even if the user navigates to another // tab while generation is in flight. WizardAutoPreview just // observes via useWizardProgress() and waits for completion. let createdId = ""; const result = await startGeneration({ api, payload: buildCreatePayload(), totalSteps: GENERATE_STEPS.length, onCreated: (id) => { createdId = id; }, }); if (!result) { // Generation failed — pull the error out of the store so the // wizard's inline error banner can show it. The overlay also // renders the failure state, but the in-form banner gives // the user a "Retry" button without leaving the wizard. const errMsg = useWizardProgressStateError() || "Couldn't create the project."; setError(errMsg); return; } toast.toast({ variant: result.source === "llm" ? "success" : "info", title: result.source === "existing" ? "Project already has scenes" : "Project ready", message: result.source === "existing" ? "Opening the editor." : `${result.node_count} scenes · ${result.edge_count} transitions${ result.action_count > 0 ? ` · ${result.action_count} choices` : "" }`, }); await new Promise((r) => window.setTimeout(r, 220)); if (createdId) { // Dismiss the overlay BEFORE navigating so the GeneratingPanel // doesn't briefly flash on the editor screen. dismissOverlay(); onCreated(createdId); } }, [api, buildCreatePayload, onCreated, toast]); // Tiny helper — reads the store's error one-shot for the toast above. // Defined inside the component so it never participates in subscription // loops (the main `progress` hook already subscribes for re-renders). function useWizardProgressStateError(): string | null { return progress.error; } return (

{personaLive && ( )} {personaLive ? "Your experience is ready" : "Review your project"}

{personaLive ? ( <> AI shaped{" "} "{originalIdea.slice(0, 80)} {originalIdea.length > 80 ? "…" : ""}" {" "} into a persona-centered setup. Approve to launch, or customize first. ) : ( <> The AI turned{" "} "{originalIdea.slice(0, 80)} {originalIdea.length > 80 ? "…" : ""}" {" "}into the draft below. Edit anything you'd like before creating. )}

{error && (
)}
{personaLive ? ( patch("title", v)} /> ) : ( patch("title", v)} /> )} {!customizeOpen && (
)} {customizeOpen && personaLive && ( setAdvancedOpen((v) => !v)} renderEnabled={renderEnabled} personaAllowExplicit={personaAllowExplicit} libraryAlreadyBuilt={libraryAlreadyBuilt} onClose={() => setCustomizeOpen(false)} /> )} {customizeOpen && !personaLive && ( setAdvancedOpen((v) => !v)} renderEnabled={renderEnabled} onClose={() => setCustomizeOpen(false)} /> )}
{/* Planner preview moved into the Customize panel for both flows — it's noise on the default Approve view. */}
{/* * Render plan panel — shown right above the Create CTA so the * operator can see exactly how many GPU renders this wizard * run will fire before clicking. Three numbers: * * * Scene graph — fixed at 7 for Persona Live (intro + * 4 reactions + followup + ending); * branch_count × depth × scenes_per_branch * for Standard. * * Persona library — 17 (SFW) or 23 (NSFW + allow_explicit) * at Tier 2. Persona Live only. * * Already cached — library rows that exist on the persona * from a prior run; idempotent skip. * * "Total this run" subtracts already-cached so the wizard * progress bar lines up with the count shown here. */} {/* RenderPlanPanel now lives inside the Customize panel for both flows. The default Approve view is render-cost-free to keep the 80% path zero-noise. */}
: undefined} > Create project
{/* * The "Generating scenes…" modal used to render here as a local * . It now lives in * mounted at App.tsx — which makes it a global overlay portaled * to document.body. That's the fix for the * "modal disappears when I switch tabs" bug: when the user * navigates from Interactive → Chat mid-generation, this * component unmounts (along with its local state) but the * module-level wizardProgressStore keeps the SSE running and * the global overlay keeps showing on top of the new route. */}
); } const INPUT_CLS = [ "w-full bg-[#121212] border border-[#3f3f3f] rounded-md", "px-3 py-2 text-sm outline-none", "focus:border-[#3ea6ff] focus:ring-1 focus:ring-[#3ea6ff]/50", "disabled:opacity-60 disabled:cursor-not-allowed", ].join(" "); function Field({ label, hint, children, }: { label: string; hint?: string; children: React.ReactNode; }) { return (
{hint &&

{hint}

} {children}
); } function NumberPill({ label, value, min, max, onChange, disabled, }: { label: string; value: number; min: number; max: number; onChange: (n: number) => void; disabled?: boolean; }) { const dec = () => onChange(Math.max(min, value - 1)); const inc = () => onChange(Math.min(max, value + 1)); return (
{label}
{value}
); } function AdvancedBlock({ open, onToggle, form, patch, disabled, personaLive, }: { open: boolean; onToggle: () => void; form: PlanAutoForm; patch: (k: K, v: PlanAutoForm[K]) => void; disabled: boolean; personaLive: boolean; }) { return (
{open && (
patch("audience_role", e.target.value)} disabled={disabled} className={INPUT_CLS} /> patch("audience_locale_hint", e.target.value)} placeholder="(optional, e.g. us-west)" maxLength={32} disabled={disabled} className={INPUT_CLS} /> patch("policy_profile_id", e.target.value)} disabled={disabled} className={INPUT_CLS} />
)}
); } // Note: _panelTitle / _panelDescription / _stepIndexForPhase used to // live here. They moved into wizardProgressStore + WizardProgressOverlay // when progress state was lifted out of this component. The store owns // step→phase mapping; the overlay owns title/description rendering. function SourceBadge({ source }: { source: "llm" | "heuristic" }) { if (source === "llm") { return ( AI-generated draft ); } return ( Smart defaults ); } function cleanOptional(value?: string) { const v = String(value || "").trim(); return v ? v : undefined; } // ── Render plan constants ──────────────────────────────────────────────── // // Mirrors the persona_asset_library Tier 2 manifest. Kept in sync with // ``backend/app/interactive/playback/persona_asset_library.py`` — // changing the manifest there should bump these counts here too. // // Tier 2 SFW: 9 (Tier 1 base) + 6 (extra outfits / poses / cameras) // + 2 (outfit×expression composites) = 17 // Tier 2 NSFW: +6 (2 expr at Tier 1, 2 pose at Tier 2, // 2 outfit at Tier 2) = 23 total when allow_explicit const PERSONA_LIBRARY_TIER2_SFW_COUNT = 17; const PERSONA_LIBRARY_TIER2_NSFW_COUNT = 23; // Persona Live's scene graph is deterministic (lina_intro_start + // 4 reaction scenes + lina_followup + lina_epilogue = 7). See // ``backend/app/interactive/planner/autogen_llm._persona_live_graph``. const PERSONA_LIVE_SCENE_COUNT = 7; function RenderPlanPanel({ personaLive, renderEnabled, sceneCount, libraryPlanned, libraryAlreadyBuilt, }: { personaLive: boolean; renderEnabled: boolean; sceneCount: number; libraryPlanned: number; libraryAlreadyBuilt: number; }) { // Library renders use the persona portrait as anchor, so for non- // Persona-Live projects there's no library pass at all. const libraryToBuild = personaLive ? Math.max(0, libraryPlanned - libraryAlreadyBuilt) : 0; // Scene-graph renders only fire when the playback render flag is // on AND the project type uses scene assets. Persona Live skips // scene rendering entirely (the live runtime serves library // images, not the scene tree). const sceneRendersThisRun = personaLive ? 0 : (renderEnabled ? sceneCount : 0); const totalThisRun = sceneRendersThisRun + libraryToBuild; return (
Render plan
{personaLive && ( 0 ? `${libraryAlreadyBuilt} already cached → ${libraryToBuild} new this run.` : "Pre-rendered once per persona; reused on every session." } /> )}
); } // ── Persona-live layered preview ──────────────────────────────────────── // // Three layers mapped to the spec: // 1. ApproveHero — the default Approve view (1-click create) // 2. CustomizePanel — inline expansion (mode, vibe, story bits, // generation preview, advanced) // 3. (deferred) — Edit story details lives off Customize once // the planner emits scene drafts pre-create. // // Non-persona-live projects keep the legacy single-form layout above. const SEED_TO_STAGE: Record = { greeting: "Curiosity", flirt: "Flirt", compliment: "Connection", tease: "Playfulness", ask_personal: "Intimacy", followup: "Reflection", learn: "Discovery", question: "Discovery", challenge: "Tension", }; function titleCase(s: string): string { return s .replace(/_/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); } function journeyFromSeeds(seeds: string[]): string[] { if (!seeds || seeds.length === 0) return []; const seen = new Set(); const out: string[] = []; for (const raw of seeds) { const key = String(raw || "").trim().toLowerCase(); if (!key) continue; const stage = SEED_TO_STAGE[key] || titleCase(key); if (!seen.has(stage)) { seen.add(stage); out.push(stage); } } return out; } type IconType = React.ComponentType>; function SummaryRow({ icon: Icon, label, value, }: { icon: IconType; label: string; value: string; }) { return (
{label}:{" "} {value}
); } function PersonaHeroCard({ avatarUrl, personaLabel, archetype, }: { avatarUrl: string; personaLabel: string; archetype: string; }) { return (
{avatarUrl ? ( {personaLabel} ) : (
{(personaLabel || "?").trim().charAt(0).toUpperCase()}
)}
Persona
{personaLabel}
{archetype}
Portrait is frozen into this experience for consistent playback.
); } function ApproveHero({ form, initial, avatarUrl, personaLabel, archetype, disabled, onTitleChange, }: { form: PlanAutoForm; initial: PlanAutoResult; avatarUrl: string; personaLabel: string; archetype: string; disabled: boolean; onTitleChange: (next: string) => void; }) { const journey = journeyFromSeeds(initial.seed_intents); const modeLabel = MODE_LABELS[form.experience_mode] || form.experience_mode; const goal = (initial.objective || "").trim() || "Build rapport through a branching conversation"; const vibe = (form.prompt || "").trim() || "Open, curious, easygoing"; return (
{/* Title — looks like a heading, edits inline. Kept editable (rather than click-to-edit) so the field is discoverable without a hover hint. */}
onTitleChange(e.target.value)} maxLength={80} disabled={disabled} placeholder="Name your experience" aria-label="Experience title" className={[ "flex-1 bg-transparent border-b border-transparent", "hover:border-[#3f3f3f] focus:border-[#3ea6ff] focus:outline-none", "text-xl font-semibold text-[#f1f1f1] py-1", "disabled:opacity-60 disabled:cursor-not-allowed", "placeholder:text-[#555]", ].join(" ")} />
{journey.length > 0 && (
Emotional journey
{journey.map((stage, i) => ( {i > 0 && ( )} {stage} ))}
)}
); } function CustomizePanel({ form, initial, patch, disabled, advancedOpen, onToggleAdvanced, renderEnabled, personaAllowExplicit, libraryAlreadyBuilt, onClose, }: { form: PlanAutoForm; initial: PlanAutoResult; patch: (k: K, v: PlanAutoForm[K]) => void; disabled: boolean; advancedOpen: boolean; onToggleAdvanced: () => void; renderEnabled: boolean; personaAllowExplicit: boolean; libraryAlreadyBuilt: number; onClose: () => void; }) { const libraryPlanned = personaAllowExplicit ? PERSONA_LIBRARY_TIER2_NSFW_COUNT : PERSONA_LIBRARY_TIER2_SFW_COUNT; return (
Customize your experience