| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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" }, |
| ]; |
|
|
|
|
| |
| |
| export interface AutoInteractionSelection { |
| interaction_type: "standard_project" | "persona_live_play"; |
| persona_project_id?: string; |
| persona_label?: string; |
| |
| |
| |
| |
| |
| render_media_type?: "video" | "image"; |
| } |
|
|
| export interface WizardAutoProps { |
| backendUrl: string; |
| apiKey?: string; |
| |
| |
| onPlanned: ( |
| result: PlanAutoResult, |
| idea: string, |
| interaction: AutoInteractionSelection, |
| ) => void; |
| |
| onSwitchToAdvanced: () => void; |
| |
| 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<string | null>(null); |
|
|
| |
| |
| const [interactionType, setInteractionType] = |
| useState<"standard_project" | "persona_live_play">("standard_project"); |
| const [personaId, setPersonaId] = useState(""); |
| const [personaLabel, setPersonaLabel] = useState(""); |
| |
| |
| |
| const [renderMediaType, setRenderMediaType] = |
| useState<"video" | "image">("video"); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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<Array<{ |
| id: string; |
| label: string; |
| avatar_url: string; |
| archetype: string; |
| }>>([]); |
|
|
| 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) => { |
| |
| |
| const list: Array<Record<string, unknown>> = 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<string, unknown>) |
| : {}; |
| const appearance = (p.persona_appearance && typeof p.persona_appearance === "object") |
| ? (p.persona_appearance as Record<string, unknown>) |
| : {}; |
| 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(() => { }); |
| return () => ctrl.abort(); |
| }, [backendUrl]); |
|
|
| const personaOptions = useMemo(() => { |
| const byId = new Map<string, { |
| id: string; |
| label: string; |
| avatar_url: string; |
| archetype: string; |
| }>(); |
| for (const p of cacheOptions) byId.set(p.id, p); |
| |
| 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); |
|
|
| |
| |
| |
| |
| 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); |
| |
| 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<HTMLTextAreaElement>) => { |
| if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { |
| e.preventDefault(); |
| runPlan(); |
| } |
| }, |
| [runPlan], |
| ); |
|
|
| return ( |
| <div className="relative flex flex-col h-full w-full"> |
| {/* Scrollable body (three-row wizard shell pattern) */} |
| <div className="flex-1 min-h-0 overflow-y-auto"> |
| <div className="max-w-2xl mx-auto px-6 py-10"> |
| <header className="text-center mb-8"> |
| <div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[#8b5cf6]/15 border border-[#8b5cf6]/30 flex items-center justify-center"> |
| <Wand2 className="w-7 h-7 text-[#c4b5fd]" aria-hidden /> |
| </div> |
| <h1 className="text-2xl font-semibold text-[#f1f1f1]"> |
| {interactionType === "persona_live_play" |
| ? "Describe the vibe of this live play session" |
| : "Describe your interactive video"} |
| </h1> |
| <p className="text-sm text-[#aaa] mt-2 max-w-md mx-auto leading-relaxed"> |
| {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."} |
| </p> |
| </header> |
| |
| {/* Interaction type — compact two-card picker so the user |
| can switch between the standard branching project and |
| the persona live-play flow before generating. */} |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-5"> |
| <button |
| type="button" |
| onClick={() => { |
| setInteractionType("standard_project"); |
| setPersonaId(""); |
| setPersonaLabel(""); |
| }} |
| aria-pressed={interactionType === "standard_project"} |
| disabled={generating} |
| className={[ |
| "text-left bg-[#121212] border rounded-lg p-3 transition-colors", |
| "focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff]", |
| "disabled:opacity-60 disabled:cursor-not-allowed", |
| interactionType === "standard_project" |
| ? "border-[#3ea6ff] bg-[rgba(62,166,255,0.08)] ring-1 ring-[#3ea6ff]" |
| : "border-[#3f3f3f] hover:border-[#555] hover:bg-[#1a1a1a]", |
| ].join(" ")} |
| > |
| <div className="text-sm font-medium text-[#f1f1f1]"> |
| Standard interactive project |
| </div> |
| <div className="text-[11px] text-[#aaa] mt-0.5"> |
| Branching AI video with scenes, choices, and endings. |
| </div> |
| </button> |
| <button |
| type="button" |
| onClick={() => setInteractionType("persona_live_play")} |
| aria-pressed={interactionType === "persona_live_play"} |
| disabled={generating} |
| className={[ |
| "text-left bg-[#121212] border rounded-lg p-3 transition-colors", |
| "focus:outline-none focus-visible:ring-2 focus-visible:ring-[#8b5cf6]", |
| "disabled:opacity-60 disabled:cursor-not-allowed", |
| interactionType === "persona_live_play" |
| ? "border-[#8b5cf6] bg-[rgba(139,92,246,0.08)] ring-1 ring-[#8b5cf6]" |
| : "border-[#3f3f3f] hover:border-[#555] hover:bg-[#1a1a1a]", |
| ].join(" ")} |
| > |
| <div className="text-sm font-medium text-[#f1f1f1] inline-flex items-center gap-1.5"> |
| <Users className="w-3.5 h-3.5 text-[#c4b5fd]" aria-hidden /> |
| Persona live play |
| </div> |
| <div className="text-[11px] text-[#aaa] mt-0.5"> |
| Pick one of your personas — chat + video revolve around them. |
| </div> |
| </button> |
| </div> |
| |
| {/* 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. */} |
| <div className="mb-5 flex items-center justify-between gap-3 bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2.5"> |
| <div className="min-w-0"> |
| <div className="text-xs font-medium text-[#cfd8dc]">Render media</div> |
| <div className="text-[11px] text-[#777] truncate"> |
| Image = fast still frames (low GPU). Video = full Animate/SVD clips. |
| </div> |
| </div> |
| <select |
| aria-label="Render media type" |
| value={renderMediaType} |
| onChange={(e) => setRenderMediaType( |
| (e.target.value === "image" ? "image" : "video") as "video" | "image", |
| )} |
| disabled={generating} |
| className="shrink-0 bg-[#0f0f0f] border border-[#3f3f3f] rounded-md px-2 py-1.5 text-xs outline-none focus:border-[#8b5cf6] focus:ring-1 focus:ring-[#8b5cf6]/50 disabled:opacity-60" |
| > |
| <option value="video">Video (full)</option> |
| <option value="image">Image (feasibility)</option> |
| </select> |
| </div> |
| |
| {interactionType === "persona_live_play" && ( |
| <div className="mb-5"> |
| <label htmlFor="ix_auto_persona" className="text-xs font-medium text-[#cfd8dc]"> |
| Persona <span className="text-[#8b5cf6] ml-0.5" aria-label="required">*</span> |
| </label> |
| <select |
| id="ix_auto_persona" |
| value={personaId} |
| onChange={(e) => { |
| const selected = personaOptions.find((p) => p.id === e.target.value); |
| setPersonaId(e.target.value); |
| setPersonaLabel(selected?.label || ""); |
| }} |
| disabled={generating} |
| className="mt-1.5 w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2.5 text-sm outline-none focus:border-[#8b5cf6] focus:ring-1 focus:ring-[#8b5cf6]/50 disabled:opacity-60" |
| > |
| <option value="">Select persona…</option> |
| {personaOptions.map((p) => ( |
| <option key={p.id} value={p.id}>{p.label}</option> |
| ))} |
| </select> |
| {personaOptions.length === 0 && ( |
| <p className="text-[11px] text-amber-300 mt-1.5"> |
| No personas yet. Create one in the Avatar tab, then come back — this mode needs a |
| persona to anchor the chat + animation. |
| </p> |
| )} |
| {personaId && (() => { |
| const selected = personaOptions.find((p) => p.id === personaId); |
| if (!selected) return null; |
| return ( |
| <div className="mt-2.5 flex items-center gap-2.5 rounded-md border border-[#3a2a58] bg-[#130f1f] p-2.5"> |
| {selected.avatar_url ? ( |
| <img src={selected.avatar_url} alt={selected.label} className="w-12 h-12 rounded-md object-cover border border-[#51347f]" /> |
| ) : ( |
| <div className="w-12 h-12 rounded-md bg-[#24173a] border border-[#51347f]" /> |
| )} |
| <div className="min-w-0"> |
| <div className="text-xs font-medium text-[#f1f1f1] truncate">{selected.label}</div> |
| <div className="text-[11px] text-[#b59ed9] truncate">{selected.archetype || "Persona companion"}</div> |
| </div> |
| </div> |
| ); |
| })()} |
| </div> |
| )} |
| |
| <label htmlFor="ix_auto_idea" className="sr-only"> |
| Your interactive video idea |
| </label> |
| <textarea |
| id="ix_auto_idea" |
| value={idea} |
| onChange={(e) => setIdea(e.target.value)} |
| onKeyDown={onKeyDown} |
| placeholder={`e.g. ${IDEA_PLACEHOLDER}`} |
| rows={4} |
| disabled={generating} |
| className={[ |
| "w-full bg-[#121212] border border-[#3f3f3f] rounded-xl", |
| "px-5 py-4 text-base outline-none resize-y leading-relaxed", |
| "focus:border-[#8b5cf6] focus:ring-2 focus:ring-[#8b5cf6]/30", |
| "disabled:opacity-60 disabled:cursor-not-allowed", |
| "placeholder:text-[#555]", |
| ].join(" ")} |
| /> |
| <p className="text-xs text-[#777] mt-2"> |
| Tip: press <kbd className="px-1.5 py-0.5 rounded border border-[#3f3f3f] bg-[#1a1a1a] text-[#cfd8dc] font-mono text-[10px]">⌘/Ctrl</kbd> + <kbd className="px-1.5 py-0.5 rounded border border-[#3f3f3f] bg-[#1a1a1a] text-[#cfd8dc] font-mono text-[10px]">Enter</kbd> to submit. |
| </p> |
| |
| {error && ( |
| <div className="mt-5"> |
| <ErrorBanner |
| title="Couldn't plan your project" |
| message={error} |
| onRetry={runPlan} |
| /> |
| </div> |
| )} |
| |
| <div className="mt-6 flex items-center justify-between"> |
| <button |
| type="button" |
| onClick={onSwitchToAdvanced} |
| className="text-xs font-medium text-[#aaa] hover:text-[#f1f1f1] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded px-1" |
| > |
| Advanced settings → |
| </button> |
| <PrimaryButton |
| onClick={runPlan} |
| disabled={!canSubmit} |
| loading={generating} |
| size="lg" |
| icon={!generating ? <Sparkles className="w-4 h-4" aria-hidden /> : undefined} |
| > |
| Generate project |
| </PrimaryButton> |
| </div> |
| |
| <div className="mt-10 rounded-lg border border-[#2a2a2a] bg-[#121212]/60 px-4 py-3 text-[11px] text-[#777] leading-relaxed"> |
| <div className="text-[#cfd8dc] font-medium mb-1">Examples that work well</div> |
| <ul className="space-y-0.5 list-disc ml-4"> |
| <li>teach beginners basic Spanish conversation practice</li> |
| <li>walk new sales reps through our 3 pricing tiers with quizzes</li> |
| <li>explain photosynthesis with a short branching story</li> |
| <li>compliance onboarding for a new hire — 4 decisions, 3 endings</li> |
| </ul> |
| </div> |
| </div> |
| </div> |
| |
| {/* Footer spacer so the scrollable area never butts against |
| the host's fixed Cancel bar. */} |
| <div className="shrink-0 h-3" /> |
| |
| {/* Enterprise waiting overlay */} |
| {generating && ( |
| <GeneratingPanel |
| title="Generating your project" |
| description={ |
| <> |
| Turning <span className="text-white/80">"{idea.trim().slice(0, 70)}{idea.trim().length > 70 ? "…" : ""}"</span> |
| {" "}into a complete starting point. |
| </> |
| } |
| steps={AI_STEPS} |
| activeStep={step} |
| accentClassName="text-[#c4b5fd]" |
| /> |
| )} |
|
|
| { |
| |
| |
| } |
| <span className="sr-only" aria-hidden> |
| <button type="button" onClick={onCancel}>cancel</button> |
| </span> |
| </div> |
| ); |
| } |
|
|