Download frontend/src/ui/interactive/WizardAuto.tsx from ruslanmv/HomePilot: direct link, hf CLI and curl.
- Browser
- Download file 21.2 kB
-
https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/interactive/WizardAuto.tsx
- Command line
-
hf download hf://spaces/ruslanmv/HomePilot/frontend/src/ui/interactive/WizardAuto.tsx
-
curl -L -o WizardAuto.tsx https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/interactive/WizardAuto.tsx
21.2 kB
| /** | |
| * 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 <WizardAutoPreview /> 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<string | null>(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<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) => { | |
| // ``/projects`` returns either ``{projects: [...]}`` or just | |
| // ``[...]`` depending on the auth wrapper β handle both. | |
| 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(() => { /* swallow β dropdown falls back to cache */ }); | |
| 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); | |
| // 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<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]" | |
| /> | |
| )} | |
| {/* Hidden button for the host's TopBar Cancel affordance to | |
| reference if the embed wants to hook into onCancel from | |
| outside. Not visually rendered β onCancel is the exported | |
| hook. Kept so the component's prop surface stays declared. */} | |
| <span className="sr-only" aria-hidden> | |
| <button type="button" onClick={onCancel}>cancel</button> | |
| </span> | |
| </div> | |
| ); | |
| } | |