/** * WizardAuto — one-box entry for the AI-first project wizard. * * Replaces the 5-step form for new users. They type a single * sentence, hit 'Generate project', and the LLM populates every * field the old wizard asked for individually. The enterprise * waiting panel covers the input while the AI is thinking, then * the parent swaps this component for with * the pre-filled form. * * Flow: * * [ sentence input ] * ↓ Generate project * [ GeneratingPanel steps: Planning → Ready ] * ↓ PlanAutoResult * onPlanned(result) * * Failure paths: * - Empty / whitespace idea → button disabled, no request * - Backend 4xx → inline error banner + retry * - Network / LLM down → backend's heuristic fallback * still returns 200; result.source * is 'heuristic' so the preview * can quietly label the origin * * Power users can reveal the classic 5-step form via the * 'Advanced settings' affordance — wired in AUTO-9 at the * InteractiveHost level so one component handles both modes. */ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Sparkles, Users, Wand2 } from "lucide-react"; import { createInteractiveApi } from "./api"; import type { PlanAutoResult } from "./types"; import { InteractiveApiError } from "./types"; import { ErrorBanner, PrimaryButton } from "./ui"; import { GeneratingPanel } from "./GeneratingPanel"; import { LS_PERSONA_CACHE } from "../voice/personalityGating"; import { resolveBackendUrl } from "../lib/backendUrl"; const IDEA_PLACEHOLDER = "train new sales reps on pricing tiers"; const AI_STEPS = [ { label: "Understanding your idea" }, { label: "Picking mode + audience" }, { label: "Drafting project shape" }, { label: "Ready to review" }, ]; /** Interaction mode + optional persona link, carried alongside the * PlanAutoResult when the user moves from WizardAuto to Preview. */ export interface AutoInteractionSelection { interaction_type: "standard_project" | "persona_live_play"; persona_project_id?: string; persona_label?: string; /** * Which scene-render pipeline the player uses: * "video" — full Animate/SVD clips (default) * "image" — still frames (fast feasibility path) */ render_media_type?: "video" | "image"; } export interface WizardAutoProps { backendUrl: string; apiKey?: string; /** Called when the LLM / heuristic returns a pre-filled form. * The parent transitions to the editable preview step. */ onPlanned: ( result: PlanAutoResult, idea: string, interaction: AutoInteractionSelection, ) => void; /** Called when the user asks for the classic 5-step wizard. */ onSwitchToAdvanced: () => void; /** Called when the user cancels / leaves the wizard. */ onCancel: () => void; } export function WizardAuto({ backendUrl, apiKey, onPlanned, onSwitchToAdvanced, onCancel, }: WizardAutoProps) { const api = useMemo( () => createInteractiveApi(backendUrl, apiKey), [backendUrl, apiKey], ); const [idea, setIdea] = useState(""); const [generating, setGenerating] = useState(false); const [step, setStep] = useState(0); const [error, setError] = useState(null); // Interaction type — defaults to the standard path. 'persona_live_play' // unlocks the persona selector and gates generation on picking one. const [interactionType, setInteractionType] = useState<"standard_project" | "persona_live_play">("standard_project"); const [personaId, setPersonaId] = useState(""); const [personaLabel, setPersonaLabel] = useState(""); // Image vs video scene render. Orthogonal to interactionType — // both persona and standard projects can run either pipeline; // image mode is the feasibility path for low-VRAM setups. const [renderMediaType, setRenderMediaType] = useState<"video" | "image">("video"); // Persona dropdown sources, merged on id: // // 1. ``LS_PERSONA_CACHE`` — cheap synchronous read, populated by // Voice / Session Hub when the user enters a persona there. // Lets the dropdown render something on first paint. // // 2. ``GET /projects`` filtered to ``project_type === "persona"`` // — the AUTHORITATIVE list. Without this fallback, users who // created personas via the main Projects workspace but never // opened them in Voice mode saw "No personas yet." here even // though their personas were visible everywhere else. // // Mirrors the Step0Prompt loader so the one-box wizard and the // classic 5-step wizard agree on which personas are selectable. const cacheOptions = useMemo(() => { try { const raw = localStorage.getItem(LS_PERSONA_CACHE); if (!raw) return []; const parsed = JSON.parse(raw) as Array<{ id?: unknown; label?: unknown; avatar_url?: unknown; archetype?: unknown; }>; return parsed .map((item) => ({ id: typeof item.id === "string" ? item.id : "", label: typeof item.label === "string" ? item.label : "", avatar_url: typeof item.avatar_url === "string" ? item.avatar_url : "", archetype: typeof item.archetype === "string" ? item.archetype : "", })) .filter((item) => item.id && item.label); } catch { return []; } }, []); const [backendOptions, setBackendOptions] = useState>([]); useEffect(() => { const ctrl = new AbortController(); const backend = (backendUrl && backendUrl.trim()) || resolveBackendUrl(); fetch(`${backend}/projects`, { signal: ctrl.signal, credentials: "include", }) .then((r) => (r.ok ? r.json() : null)) .then((body) => { // ``/projects`` returns either ``{projects: [...]}`` or just // ``[...]`` depending on the auth wrapper — handle both. const list: Array> = Array.isArray(body) ? body : Array.isArray(body?.projects) ? body.projects : []; const personas = list .filter((p) => String(p.project_type || "").trim().toLowerCase() === "persona") .map((p) => { const agent = (p.persona_agent && typeof p.persona_agent === "object") ? (p.persona_agent as Record) : {}; const appearance = (p.persona_appearance && typeof p.persona_appearance === "object") ? (p.persona_appearance as Record) : {}; const filename = String(appearance.selected_filename || "").trim(); return { id: String(p.id || "").trim(), label: String(p.name || agent.label || "Persona").trim() || "Persona", avatar_url: filename ? `${backend}/files/${filename}` : "", archetype: String(agent.persona_class || "").trim() || "Persona companion", }; }) .filter((p) => p.id); if (!ctrl.signal.aborted) setBackendOptions(personas); }) .catch(() => { /* swallow — dropdown falls back to cache */ }); return () => ctrl.abort(); }, [backendUrl]); const personaOptions = useMemo(() => { const byId = new Map(); for (const p of cacheOptions) byId.set(p.id, p); // Backend wins — authoritative + carries avatar/archetype. for (const p of backendOptions) byId.set(p.id, p); return Array.from(byId.values()).sort((a, b) => a.label.localeCompare(b.label)); }, [cacheOptions, backendOptions]); const needsPersona = interactionType === "persona_live_play" && !personaId; const canSubmit = idea.trim().length > 0 && !generating && !needsPersona; const runPlan = useCallback(async () => { if (!canSubmit) return; const text = idea.trim(); setError(null); setGenerating(true); setStep(0); // Advance the visible 'steps' list on a light timer so the // user feels progress even when the LLM is a one-shot call. // Does not fake completion — the real plan response // overwrites the timer-driven value below. const tick = window.setInterval(() => { setStep((s) => Math.min(s + 1, AI_STEPS.length - 2)); }, 900); try { const result = await api.planAuto({ idea: text }); window.clearInterval(tick); setStep(AI_STEPS.length - 1); // Tiny beat so the final "Ready to review" tick is visible. await new Promise((r) => window.setTimeout(r, 180)); onPlanned(result, text, { interaction_type: interactionType, persona_project_id: personaId || undefined, persona_label: personaLabel || undefined, render_media_type: renderMediaType, }); } catch (err) { window.clearInterval(tick); const apiErr = err as InteractiveApiError; setError(apiErr.message || "Couldn't generate a plan — try again."); setGenerating(false); } }, [api, canSubmit, idea, interactionType, personaId, personaLabel, renderMediaType, onPlanned]); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); runPlan(); } }, [runPlan], ); return (
{/* Scrollable body (three-row wizard shell pattern) */}

{interactionType === "persona_live_play" ? "Describe the vibe of this live play session" : "Describe your interactive video"}

{interactionType === "persona_live_play" ? "Use a short vibe prompt (teasing, playful, romantic, etc). We'll build a persona-centered progression session." : "A single sentence is enough. The planner will expand it into a full project — title, audience, branching shape, policy — that you can tweak before creating."}

{/* Interaction type — compact two-card picker so the user can switch between the standard branching project and the persona live-play flow before generating. */}
{/* Render-media toggle — small dropdown that lets operators flip between the full video pipeline and the still-image feasibility path without leaving the one-box flow. */}
Render media
Image = fast still frames (low GPU). Video = full Animate/SVD clips.
{interactionType === "persona_live_play" && (
{personaOptions.length === 0 && (

No personas yet. Create one in the Avatar tab, then come back — this mode needs a persona to anchor the chat + animation.

)} {personaId && (() => { const selected = personaOptions.find((p) => p.id === personaId); if (!selected) return null; return (
{selected.avatar_url ? ( {selected.label} ) : (
)}
{selected.label}
{selected.archetype || "Persona companion"}
); })()}
)}