Download frontend/src/ui/interactive/WizardAutoPreview.tsx from ruslanmv/HomePilot: direct link, hf CLI and curl.
- Browser
- Download file 57.7 kB
-
https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/interactive/WizardAutoPreview.tsx
- Command line
-
hf download hf://spaces/ruslanmv/HomePilot/frontend/src/ui/interactive/WizardAutoPreview.tsx
-
curl -L -o WizardAutoPreview.tsx https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/interactive/WizardAutoPreview.tsx
57.7 kB
| // 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<ExperienceMode, string> = { | |
| 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<PlanAutoForm["audience_level"], string> = { | |
| beginner: "Beginner", | |
| intermediate: "Intermediate", | |
| advanced: "Advanced", | |
| }; | |
| const LANG_LABELS: Record<string, string> = { | |
| 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<PlanAutoForm>({ | |
| ...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<string | null>(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<string>(""); | |
| const [resolvedArchetype, setResolvedArchetype] = useState<string>(""); | |
| // 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<boolean>(false); | |
| const [libraryAlreadyBuilt, setLibraryAlreadyBuilt] = useState<number>(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<string, unknown>; | |
| }; | |
| 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<HealthInfo>( | |
| (signal) => api.health(signal), | |
| [api], | |
| ); | |
| const renderEnabled = health.data?.playback?.render_enabled !== false; | |
| const patch = useCallback( | |
| <K extends keyof PlanAutoForm>(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 ( | |
| <div className="relative flex flex-col h-full w-full"> | |
| <div className="flex-1 min-h-0 overflow-y-auto"> | |
| <div className="max-w-2xl mx-auto px-6 py-8"> | |
| <header className="flex items-center justify-between gap-4 mb-6"> | |
| <button | |
| type="button" | |
| onClick={onStartOver} | |
| disabled={submitting} | |
| className="inline-flex items-center gap-1.5 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 py-0.5 disabled:opacity-50" | |
| > | |
| <ArrowLeft className="w-3.5 h-3.5" aria-hidden /> | |
| Start over | |
| </button> | |
| <SourceBadge source={initial.source} /> | |
| </header> | |
| <h1 className="text-xl font-semibold text-[#f1f1f1] flex items-center gap-2"> | |
| {personaLive && ( | |
| <Sparkles className="w-5 h-5 text-[#c4b5fd]" aria-hidden /> | |
| )} | |
| {personaLive ? "Your experience is ready" : "Review your project"} | |
| </h1> | |
| <p className="text-sm text-[#aaa] mt-1.5 leading-relaxed"> | |
| {personaLive ? ( | |
| <> | |
| AI shaped{" "} | |
| <span className="text-[#cfd8dc]"> | |
| "{originalIdea.slice(0, 80)} | |
| {originalIdea.length > 80 ? "…" : ""}" | |
| </span>{" "} | |
| into a persona-centered setup. Approve to launch, or customize first. | |
| </> | |
| ) : ( | |
| <> | |
| The AI turned{" "} | |
| <span className="text-[#cfd8dc]"> | |
| "{originalIdea.slice(0, 80)} | |
| {originalIdea.length > 80 ? "…" : ""}" | |
| </span> | |
| {" "}into the draft below. Edit anything you'd like before creating. | |
| </> | |
| )} | |
| </p> | |
| {error && ( | |
| <div className="mt-5"> | |
| <ErrorBanner | |
| title="Couldn't create the project" | |
| message={error} | |
| onRetry={onSubmit} | |
| /> | |
| </div> | |
| )} | |
| <div className="mt-6 flex flex-col gap-5"> | |
| {personaLive ? ( | |
| <ApproveHero | |
| form={form} | |
| initial={initial} | |
| avatarUrl={interaction.persona_avatar_url || resolvedAvatar} | |
| personaLabel={interaction.persona_label || "Selected persona"} | |
| archetype={resolvedArchetype || "Persona companion"} | |
| disabled={submitting} | |
| onTitleChange={(v) => patch("title", v)} | |
| /> | |
| ) : ( | |
| <StandardApproveHero | |
| form={form} | |
| initial={initial} | |
| disabled={submitting} | |
| onTitleChange={(v) => patch("title", v)} | |
| /> | |
| )} | |
| {!customizeOpen && ( | |
| <div className="flex justify-center"> | |
| <button | |
| type="button" | |
| onClick={() => setCustomizeOpen(true)} | |
| disabled={submitting} | |
| className="text-xs font-medium text-[#9f7fd1] hover:text-[#c4b5fd] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded px-2 py-1 disabled:opacity-50" | |
| > | |
| Customize (optional) | |
| </button> | |
| </div> | |
| )} | |
| {customizeOpen && personaLive && ( | |
| <CustomizePanel | |
| form={form} | |
| initial={initial} | |
| patch={patch} | |
| disabled={submitting} | |
| advancedOpen={advancedOpen} | |
| onToggleAdvanced={() => setAdvancedOpen((v) => !v)} | |
| renderEnabled={renderEnabled} | |
| personaAllowExplicit={personaAllowExplicit} | |
| libraryAlreadyBuilt={libraryAlreadyBuilt} | |
| onClose={() => setCustomizeOpen(false)} | |
| /> | |
| )} | |
| {customizeOpen && !personaLive && ( | |
| <StandardCustomizePanel | |
| form={form} | |
| initial={initial} | |
| patch={patch} | |
| disabled={submitting} | |
| advancedOpen={advancedOpen} | |
| onToggleAdvanced={() => setAdvancedOpen((v) => !v)} | |
| renderEnabled={renderEnabled} | |
| onClose={() => setCustomizeOpen(false)} | |
| /> | |
| )} | |
| </div> | |
| {/* Planner preview moved into the Customize panel for both | |
| flows — it's noise on the default Approve view. */} | |
| </div> | |
| </div> | |
| {/* | |
| * 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. */} | |
| <footer className="shrink-0 border-t border-[#2a2a2a] bg-[#0f0f0f] py-3"> | |
| <div className="max-w-2xl mx-auto px-6 flex items-center justify-end gap-3"> | |
| <PrimaryButton | |
| onClick={onSubmit} | |
| disabled={submitting || !form.title.trim()} | |
| loading={submitting} | |
| size="lg" | |
| icon={!submitting ? <Check className="w-4 h-4" aria-hidden /> : undefined} | |
| > | |
| Create project | |
| </PrimaryButton> | |
| </div> | |
| </footer> | |
| {/* | |
| * The "Generating scenes…" modal used to render here as a local | |
| * <GeneratingPanel>. It now lives in <WizardProgressOverlay> | |
| * 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. | |
| */} | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className="flex flex-col gap-1.5"> | |
| <label className="text-xs font-medium text-[#cfd8dc]">{label}</label> | |
| {hint && <p className="text-[11px] text-[#777] -mt-0.5">{hint}</p>} | |
| {children} | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className="bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2"> | |
| <div className="text-[10px] text-[#777] uppercase tracking-wide">{label}</div> | |
| <div className="mt-1 flex items-center justify-between gap-2"> | |
| <button | |
| type="button" | |
| onClick={dec} | |
| disabled={disabled || value <= min} | |
| aria-label={`Decrease ${label}`} | |
| className="w-6 h-6 rounded-md bg-white/5 hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed text-[#cfd8dc] text-sm font-medium" | |
| > | |
| − | |
| </button> | |
| <span className="text-lg font-semibold text-[#f1f1f1] tabular-nums">{value}</span> | |
| <button | |
| type="button" | |
| onClick={inc} | |
| disabled={disabled || value >= max} | |
| aria-label={`Increase ${label}`} | |
| className="w-6 h-6 rounded-md bg-white/5 hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed text-[#cfd8dc] text-sm font-medium" | |
| > | |
| + | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function AdvancedBlock({ | |
| open, | |
| onToggle, | |
| form, | |
| patch, | |
| disabled, | |
| personaLive, | |
| }: { | |
| open: boolean; | |
| onToggle: () => void; | |
| form: PlanAutoForm; | |
| patch: <K extends keyof PlanAutoForm>(k: K, v: PlanAutoForm[K]) => void; | |
| disabled: boolean; | |
| personaLive: boolean; | |
| }) { | |
| return ( | |
| <section className="rounded-lg border border-[#2a2a2a] bg-[#121212]/60"> | |
| <button | |
| type="button" | |
| onClick={onToggle} | |
| aria-expanded={open} | |
| className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-[#f1f1f1] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded-lg" | |
| > | |
| <span className="inline-flex items-center gap-2"> | |
| <Sparkles className="w-4 h-4 text-[#c4b5fd]" aria-hidden /> | |
| Advanced | |
| </span> | |
| {open ? ( | |
| <ChevronUp className="w-4 h-4 text-[#aaa]" aria-hidden /> | |
| ) : ( | |
| <ChevronDown className="w-4 h-4 text-[#aaa]" aria-hidden /> | |
| )} | |
| </button> | |
| {open && ( | |
| <div className="px-4 pb-4 grid grid-cols-2 gap-3 text-sm"> | |
| <Field label="Viewer role"> | |
| <input | |
| type="text" | |
| value={form.audience_role} | |
| onChange={(e) => patch("audience_role", e.target.value)} | |
| disabled={disabled} | |
| className={INPUT_CLS} | |
| /> | |
| </Field> | |
| <Field label="Language"> | |
| <select | |
| value={form.audience_language} | |
| onChange={(e) => patch("audience_language", e.target.value)} | |
| disabled={disabled} | |
| className={INPUT_CLS} | |
| > | |
| {Object.entries(LANG_LABELS).map(([v, l]) => ( | |
| <option key={v} value={v}> | |
| {l} ({v}) | |
| </option> | |
| ))} | |
| </select> | |
| </Field> | |
| <Field label="Locale hint" hint="Optional region cue."> | |
| <input | |
| type="text" | |
| value={form.audience_locale_hint} | |
| onChange={(e) => patch("audience_locale_hint", e.target.value)} | |
| placeholder="(optional, e.g. us-west)" | |
| maxLength={32} | |
| disabled={disabled} | |
| className={INPUT_CLS} | |
| /> | |
| </Field> | |
| <Field label="Policy profile"> | |
| <input | |
| type="text" | |
| value={form.policy_profile_id} | |
| onChange={(e) => patch("policy_profile_id", e.target.value)} | |
| disabled={disabled} | |
| className={INPUT_CLS} | |
| /> | |
| </Field> | |
| <Field label="Render media"> | |
| <select | |
| value={form.render_media_type || (personaLive ? "image" : "video")} | |
| onChange={(e) => patch("render_media_type", e.target.value as "image" | "video")} | |
| disabled={disabled} | |
| className={INPUT_CLS} | |
| > | |
| <option value="image">Image</option> | |
| <option value="video">Video</option> | |
| </select> | |
| </Field> | |
| </div> | |
| )} | |
| </section> | |
| ); | |
| } | |
| // 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 ( | |
| <span className="inline-flex items-center gap-1.5 rounded-full bg-[#8b5cf6]/15 border border-[#8b5cf6]/30 text-[#c4b5fd] text-[11px] font-medium px-2.5 py-1"> | |
| <Sparkles className="w-3 h-3" aria-hidden /> | |
| AI-generated draft | |
| </span> | |
| ); | |
| } | |
| return ( | |
| <span className="inline-flex items-center rounded-full bg-white/5 border border-white/10 text-[#aaa] text-[11px] font-medium px-2.5 py-1"> | |
| Smart defaults | |
| </span> | |
| ); | |
| } | |
| 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 ( | |
| <div className="shrink-0 border-t border-[#2a2a2a] bg-[#121212]/60"> | |
| <div className="max-w-2xl mx-auto px-6 py-3"> | |
| <div className="text-[11px] uppercase tracking-wider text-[#9f7fd1] mb-2"> | |
| Render plan | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-xs"> | |
| <PlanStat | |
| label="Scene graph" | |
| value={sceneCount} | |
| unit={sceneCount === 1 ? "scene" : "scenes"} | |
| hint={ | |
| personaLive | |
| ? "Authored spine — the live runtime doesn't traverse this." | |
| : (renderEnabled | |
| ? "All rendered as images / video clips." | |
| : "Scene render is OFF — no GPU work this pass.") | |
| } | |
| /> | |
| {personaLive && ( | |
| <PlanStat | |
| label="Persona library" | |
| value={libraryPlanned} | |
| unit={libraryPlanned === 1 ? "image" : "images"} | |
| hint={ | |
| libraryAlreadyBuilt > 0 | |
| ? `${libraryAlreadyBuilt} already cached → ${libraryToBuild} new this run.` | |
| : "Pre-rendered once per persona; reused on every session." | |
| } | |
| /> | |
| )} | |
| <PlanStat | |
| label="Total this run" | |
| value={totalThisRun} | |
| unit={totalThisRun === 1 ? "render" : "renders"} | |
| emphasised | |
| hint={ | |
| totalThisRun === 0 | |
| ? "No renders queued — wizard finishes in seconds." | |
| : "Each render typically takes 3–8 s on a capable GPU." | |
| } | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ── 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<string, string> = { | |
| 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<string>(); | |
| 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<React.SVGProps<SVGSVGElement>>; | |
| function SummaryRow({ | |
| icon: Icon, | |
| label, | |
| value, | |
| }: { | |
| icon: IconType; | |
| label: string; | |
| value: string; | |
| }) { | |
| return ( | |
| <div className="flex items-start gap-2.5 text-sm"> | |
| <Icon className="w-4 h-4 text-[#9f7fd1] mt-0.5 flex-shrink-0" aria-hidden /> | |
| <div className="min-w-0 flex-1 leading-relaxed"> | |
| <span className="text-[#9f7fd1] font-medium">{label}:</span>{" "} | |
| <span className="text-[#cfd8dc]">{value}</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function PersonaHeroCard({ | |
| avatarUrl, | |
| personaLabel, | |
| archetype, | |
| }: { | |
| avatarUrl: string; | |
| personaLabel: string; | |
| archetype: string; | |
| }) { | |
| return ( | |
| <div | |
| className={[ | |
| "rounded-lg border border-[#3a2a58]", | |
| "bg-gradient-to-br from-[#130f1f] to-[#1a0f24]", | |
| "p-4 flex flex-col sm:flex-row gap-4", | |
| "shadow-[0_0_24px_-12px_rgba(139,92,246,0.4)]", | |
| ].join(" ")} | |
| > | |
| {avatarUrl ? ( | |
| <img | |
| src={avatarUrl} | |
| alt={personaLabel} | |
| className={[ | |
| "w-32 h-32 rounded-md object-cover object-top", | |
| "border-2 border-[#51347f]", | |
| "shadow-md flex-shrink-0", | |
| "self-center sm:self-start bg-black", | |
| ].join(" ")} | |
| /> | |
| ) : ( | |
| <div | |
| className={[ | |
| "w-32 h-32 rounded-md bg-[#24173a]", | |
| "border-2 border-[#51347f]", | |
| "flex-shrink-0 self-center sm:self-start", | |
| "flex items-center justify-center", | |
| "text-[#7c3aed] text-2xl font-semibold", | |
| ].join(" ")} | |
| aria-label="No portrait available" | |
| > | |
| {(personaLabel || "?").trim().charAt(0).toUpperCase()} | |
| </div> | |
| )} | |
| <div className="min-w-0 flex flex-col justify-center gap-1"> | |
| <div className="text-[11px] uppercase tracking-wider text-[#9f7fd1]"> | |
| Persona | |
| </div> | |
| <div className="text-base font-medium text-[#f1f1f1] truncate"> | |
| {personaLabel} | |
| </div> | |
| <div className="text-xs text-[#b59ed9] truncate">{archetype}</div> | |
| <div className="text-[11px] text-[#7c3aed] mt-2"> | |
| Portrait is frozen into this experience for consistent playback. | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className="flex flex-col gap-5"> | |
| {/* Title — looks like a heading, edits inline. Kept editable | |
| (rather than click-to-edit) so the field is discoverable | |
| without a hover hint. */} | |
| <div className="flex items-center gap-2"> | |
| <Pencil | |
| className="w-4 h-4 text-[#777] flex-shrink-0" | |
| aria-hidden | |
| /> | |
| <input | |
| type="text" | |
| value={form.title} | |
| onChange={(e) => 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(" ")} | |
| /> | |
| </div> | |
| <PersonaHeroCard | |
| avatarUrl={avatarUrl} | |
| personaLabel={personaLabel} | |
| archetype={archetype} | |
| /> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-5 gap-y-2.5"> | |
| <SummaryRow icon={Target} label="Goal" value={goal} /> | |
| <SummaryRow icon={Heart} label="Vibe" value={vibe} /> | |
| <SummaryRow icon={Sparkles} label="Mode" value={modeLabel} /> | |
| <SummaryRow icon={Clock} label="Length" value="~5–7 min experience" /> | |
| </div> | |
| {journey.length > 0 && ( | |
| <div className="rounded-lg border border-[#2a2a2a] bg-[#121212]/60 px-4 py-3"> | |
| <div className="flex items-center gap-2 text-[11px] uppercase tracking-wider text-[#9f7fd1] mb-2"> | |
| <Compass className="w-3.5 h-3.5" aria-hidden /> | |
| Emotional journey | |
| </div> | |
| <div className="flex flex-wrap items-center gap-2 text-sm text-[#cfd8dc]"> | |
| {journey.map((stage, i) => ( | |
| <React.Fragment key={`${stage}-${i}`}> | |
| {i > 0 && ( | |
| <span className="text-[#555]" aria-hidden> | |
| → | |
| </span> | |
| )} | |
| <span>{stage}</span> | |
| </React.Fragment> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function CustomizePanel({ | |
| form, | |
| initial, | |
| patch, | |
| disabled, | |
| advancedOpen, | |
| onToggleAdvanced, | |
| renderEnabled, | |
| personaAllowExplicit, | |
| libraryAlreadyBuilt, | |
| onClose, | |
| }: { | |
| form: PlanAutoForm; | |
| initial: PlanAutoResult; | |
| patch: <K extends keyof PlanAutoForm>(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 ( | |
| <section | |
| className="rounded-lg border border-[#2a2a2a] bg-[#121212]/60 px-4 py-4 flex flex-col gap-4" | |
| aria-label="Customize your experience" | |
| > | |
| <div className="flex items-center justify-between gap-3"> | |
| <div className="flex items-center gap-2 text-sm font-medium text-[#f1f1f1]"> | |
| <Sparkles className="w-4 h-4 text-[#c4b5fd]" aria-hidden /> | |
| Customize your experience | |
| </div> | |
| <button | |
| type="button" | |
| onClick={onClose} | |
| disabled={disabled} | |
| className="text-[11px] text-[#aaa] hover:text-[#f1f1f1] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded px-1 py-0.5 disabled:opacity-50" | |
| > | |
| Hide | |
| </button> | |
| </div> | |
| <Field label="Mode"> | |
| <select | |
| value={form.experience_mode} | |
| onChange={(e) => { | |
| const next = e.target.value as ExperienceMode; | |
| patch("experience_mode", next); | |
| patch("policy_profile_id", next); | |
| }} | |
| disabled={disabled} | |
| className={INPUT_CLS} | |
| > | |
| {(Object.keys(MODE_LABELS) as ExperienceMode[]).map((m) => ( | |
| <option key={m} value={m}> | |
| {MODE_LABELS[m]} | |
| </option> | |
| ))} | |
| </select> | |
| </Field> | |
| <Field | |
| label="Session vibe" | |
| hint="Short brief the planner uses to flavor the conversation." | |
| > | |
| <textarea | |
| rows={3} | |
| value={form.prompt} | |
| onChange={(e) => patch("prompt", e.target.value)} | |
| disabled={disabled} | |
| className={`${INPUT_CLS} resize-y leading-relaxed`} | |
| /> | |
| </Field> | |
| {initial.seed_intents.length > 0 && ( | |
| <Field | |
| label="Story elements" | |
| hint="Seeds the planner uses for branches. Edit raw seeds in Advanced." | |
| > | |
| <div className="flex flex-wrap gap-1.5"> | |
| {initial.seed_intents.map((s) => ( | |
| <span | |
| key={s} | |
| className="inline-flex items-center gap-1 rounded-full bg-[#1f1f1f] border border-[#3f3f3f] text-[#cfd8dc] text-[11px] font-medium px-2 py-0.5" | |
| > | |
| <Check className="w-3 h-3 text-[#9f7fd1]" aria-hidden /> | |
| {titleCase(s)} | |
| </span> | |
| ))} | |
| </div> | |
| </Field> | |
| )} | |
| <Field label="Generation preview"> | |
| <RenderPlanPanel | |
| personaLive={true} | |
| renderEnabled={renderEnabled} | |
| sceneCount={PERSONA_LIVE_SCENE_COUNT} | |
| libraryPlanned={libraryPlanned} | |
| libraryAlreadyBuilt={libraryAlreadyBuilt} | |
| /> | |
| </Field> | |
| {(initial.objective || initial.topic || initial.success_metric) && ( | |
| <div className="rounded-md border border-[#2a2a2a] bg-[#0f0f0f]/60 px-3 py-2.5 text-[11px] leading-relaxed"> | |
| <div className="text-[#cfd8dc] font-medium mb-1">Planner preview</div> | |
| <dl className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-0.5 text-[#aaa]"> | |
| {initial.objective && ( | |
| <> | |
| <dt>Objective</dt> | |
| <dd className="text-[#cfd8dc]">{initial.objective}</dd> | |
| </> | |
| )} | |
| {initial.topic && ( | |
| <> | |
| <dt>Topic</dt> | |
| <dd className="text-[#cfd8dc]">{initial.topic}</dd> | |
| </> | |
| )} | |
| {initial.success_metric && ( | |
| <> | |
| <dt>Success</dt> | |
| <dd className="text-[#cfd8dc]">{initial.success_metric}</dd> | |
| </> | |
| )} | |
| </dl> | |
| </div> | |
| )} | |
| <AdvancedBlock | |
| open={advancedOpen} | |
| onToggle={onToggleAdvanced} | |
| form={form} | |
| patch={patch} | |
| disabled={disabled} | |
| personaLive={true} | |
| /> | |
| <div className="border-t border-[#2a2a2a] pt-3 -mb-1"> | |
| <ExpertModeToggle /> | |
| </div> | |
| </section> | |
| ); | |
| } | |
| function PlanStat({ | |
| label, | |
| value, | |
| unit, | |
| hint, | |
| emphasised, | |
| }: { | |
| label: string; | |
| value: number; | |
| unit: string; | |
| hint?: string; | |
| emphasised?: boolean; | |
| }) { | |
| return ( | |
| <div | |
| className={[ | |
| "rounded-md border px-3 py-2", | |
| emphasised | |
| ? "border-[#8b5cf6]/50 bg-[#8b5cf6]/10" | |
| : "border-[#2a2a2a] bg-[#0f0f0f]/60", | |
| ].join(" ")} | |
| > | |
| <div className="text-[10px] uppercase tracking-wider text-[#777]"> | |
| {label} | |
| </div> | |
| <div className="flex items-baseline gap-1.5 mt-0.5"> | |
| <span | |
| className={[ | |
| "font-semibold", | |
| emphasised ? "text-[#c4b5fd] text-lg" : "text-[#f1f1f1] text-base", | |
| ].join(" ")} | |
| > | |
| {value} | |
| </span> | |
| <span className="text-[11px] text-[#aaa]">{unit}</span> | |
| </div> | |
| {hint && ( | |
| <div className="text-[10px] text-[#777] mt-1 leading-snug"> | |
| {hint} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ── Standard interactive layered preview ──────────────────────────────── | |
| // | |
| // Same Approve / Customize / (deferred) Edit-flow split as persona-live, | |
| // but the framing is "structure of an interactive story" instead of a | |
| // single emotional arc. The Shape knobs (branch_count, depth, | |
| // scenes_per_branch) are mapped to friendly preset radios in Customize | |
| // — operators rarely understand "3 × 2 × 2", they understand | |
| // "Standard choices · Medium length · Balanced branching". | |
| type ChoicesPreset = "simple" | "standard" | "complex"; | |
| type LengthPreset = "short" | "medium" | "long"; | |
| type DepthPreset = "light" | "balanced" | "deep"; | |
| const CHOICES_VALUES: Record<ChoicesPreset, number> = { | |
| simple: 2, | |
| standard: 3, | |
| complex: 5, | |
| }; | |
| const LENGTH_VALUES: Record<LengthPreset, number> = { | |
| short: 1, | |
| medium: 2, | |
| long: 4, | |
| }; | |
| const DEPTH_VALUES: Record<DepthPreset, number> = { | |
| light: 1, | |
| balanced: 2, | |
| deep: 4, | |
| }; | |
| const CHOICES_LABELS: Record<ChoicesPreset, string> = { | |
| simple: "Simple (2 choices)", | |
| standard: "Standard (3–4 choices)", | |
| complex: "Complex (5+ choices)", | |
| }; | |
| const LENGTH_LABELS: Record<LengthPreset, string> = { | |
| short: "Short (4–5 scenes)", | |
| medium: "Medium (6–8 scenes)", | |
| long: "Long (10+ scenes)", | |
| }; | |
| const DEPTH_LABELS: Record<DepthPreset, string> = { | |
| light: "Light branching", | |
| balanced: "Balanced", | |
| deep: "Deep branching", | |
| }; | |
| function inferChoicesPreset(branch: number): ChoicesPreset { | |
| if (branch <= 2) return "simple"; | |
| if (branch <= 4) return "standard"; | |
| return "complex"; | |
| } | |
| function inferLengthPreset(scenes_per_branch: number): LengthPreset { | |
| if (scenes_per_branch <= 1) return "short"; | |
| if (scenes_per_branch <= 3) return "medium"; | |
| return "long"; | |
| } | |
| function inferDepthPreset(depth: number): DepthPreset { | |
| if (depth <= 1) return "light"; | |
| if (depth <= 2) return "balanced"; | |
| return "deep"; | |
| } | |
| function estimateSceneTotal(form: PlanAutoForm): number { | |
| return Math.max(1, form.branch_count * form.depth * form.scenes_per_branch); | |
| } | |
| function estimatePlaytime(scenes: number): string { | |
| // Rough: ~1 minute of reading + choice per scene. Range gives the | |
| // viewer a feel without false precision. | |
| const lo = Math.max(2, Math.round(scenes * 0.75)); | |
| const hi = Math.max(lo + 1, Math.round(scenes * 1.1)); | |
| return `~${lo}–${hi} min interactive experience`; | |
| } | |
| function StandardApproveHero({ | |
| form, | |
| initial, | |
| disabled, | |
| onTitleChange, | |
| }: { | |
| form: PlanAutoForm; | |
| initial: PlanAutoResult; | |
| disabled: boolean; | |
| onTitleChange: (next: string) => void; | |
| }) { | |
| const totalScenes = estimateSceneTotal(form); | |
| const modeLabel = MODE_LABELS[form.experience_mode] || form.experience_mode; | |
| const levelLabel = LEVEL_LABELS[form.audience_level] || form.audience_level; | |
| const goal = (initial.objective || "").trim() | |
| || "Build rapport through choices"; | |
| const vibe = (form.prompt || "").trim() | |
| || "An interactive scene-by-scene story"; | |
| const journey = journeyFromSeeds(initial.seed_intents); | |
| return ( | |
| <div className="flex flex-col gap-5"> | |
| <div className="flex items-center gap-2"> | |
| <Pencil | |
| className="w-4 h-4 text-[#777] flex-shrink-0" | |
| aria-hidden | |
| /> | |
| <input | |
| type="text" | |
| value={form.title} | |
| onChange={(e) => onTitleChange(e.target.value)} | |
| maxLength={80} | |
| disabled={disabled} | |
| placeholder="Name your project" | |
| aria-label="Project 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(" ")} | |
| /> | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-5 gap-y-2.5"> | |
| <SummaryRow | |
| icon={Sparkles} | |
| label="Mode" | |
| value={`${modeLabel} · ${levelLabel}`} | |
| /> | |
| <SummaryRow | |
| icon={Clock} | |
| label="Length" | |
| value={estimatePlaytime(totalScenes)} | |
| /> | |
| <SummaryRow icon={Target} label="Goal" value={goal} /> | |
| <SummaryRow icon={Heart} label="What players experience" value={vibe} /> | |
| </div> | |
| <div className="rounded-lg border border-[#2a2a2a] bg-[#121212]/60 px-4 py-3"> | |
| <div className="flex items-center gap-2 text-[11px] uppercase tracking-wider text-[#9f7fd1] mb-2"> | |
| <Compass className="w-3.5 h-3.5" aria-hidden /> | |
| Structure | |
| </div> | |
| <div className="text-sm text-[#cfd8dc]"> | |
| ~{totalScenes} scene{totalScenes === 1 ? "" : "s"} ·{" "} | |
| {form.branch_count} choice{form.branch_count === 1 ? "" : "s"} per step ·{" "} | |
| {form.depth}-step path{form.depth === 1 ? "" : "s"} | |
| </div> | |
| {journey.length > 0 && ( | |
| <div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[#cfd8dc]"> | |
| <span className="text-[#9f7fd1]">Players will:</span> | |
| {journey.map((stage, i) => ( | |
| <React.Fragment key={`${stage}-${i}`}> | |
| {i > 0 && ( | |
| <span className="text-[#555]" aria-hidden> | |
| → | |
| </span> | |
| )} | |
| <span>{stage}</span> | |
| </React.Fragment> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function PresetRadio<T extends string>({ | |
| legend, | |
| value, | |
| options, | |
| labels, | |
| onChange, | |
| disabled, | |
| }: { | |
| legend: string; | |
| value: T; | |
| options: T[]; | |
| labels: Record<T, string>; | |
| onChange: (next: T) => void; | |
| disabled: boolean; | |
| }) { | |
| return ( | |
| <fieldset className="flex flex-col gap-1.5"> | |
| <legend className="text-xs font-medium text-[#cfd8dc]">{legend}</legend> | |
| <div className="flex flex-col gap-1"> | |
| {options.map((opt) => { | |
| const selected = value === opt; | |
| return ( | |
| <label | |
| key={opt} | |
| className={[ | |
| "flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm cursor-pointer", | |
| "transition-colors", | |
| selected | |
| ? "border-[#8b5cf6]/60 bg-[#8b5cf6]/10 text-[#f1f1f1]" | |
| : "border-[#2a2a2a] bg-[#0f0f0f]/60 text-[#cfd8dc] hover:border-[#3f3f3f]", | |
| disabled ? "opacity-60 cursor-not-allowed" : "", | |
| ].join(" ")} | |
| > | |
| <input | |
| type="radio" | |
| name={legend} | |
| value={opt} | |
| checked={selected} | |
| onChange={() => onChange(opt)} | |
| disabled={disabled} | |
| className="accent-[#8b5cf6]" | |
| /> | |
| <span>{labels[opt]}</span> | |
| </label> | |
| ); | |
| })} | |
| </div> | |
| </fieldset> | |
| ); | |
| } | |
| function StoryFlowPreview({ | |
| form, | |
| initial, | |
| }: { | |
| form: PlanAutoForm; | |
| initial: PlanAutoResult; | |
| }) { | |
| // Procedural outline derived from the planner's depth + seeds. We | |
| // don't have actual scene drafts pre-create, so the outline names | |
| // generic phases ("Opening", "Reaction", "Connection builds") and | |
| // pulls choices from initial.seed_intents. This gives the operator a | |
| // human-readable feel for the branching without surfacing the graph. | |
| const sceneNames = [ | |
| "Opening scene", | |
| "Reaction", | |
| "Connection builds", | |
| "Turning point", | |
| "Resolution", | |
| ]; | |
| const choices = initial.seed_intents.length > 0 | |
| ? initial.seed_intents.slice(0, form.branch_count) | |
| : ["Be playful", "Be sincere", "Stay reserved"].slice(0, form.branch_count); | |
| const stepCount = Math.max(1, Math.min(form.depth, sceneNames.length - 1)); | |
| return ( | |
| <div className="rounded-md border border-[#2a2a2a] bg-[#0f0f0f]/60 px-3 py-3"> | |
| <div className="text-[11px] uppercase tracking-wider text-[#9f7fd1] mb-2"> | |
| Story flow preview | |
| </div> | |
| <ol className="flex flex-col gap-1 text-xs text-[#cfd8dc] leading-relaxed"> | |
| <li className="text-[#777] uppercase tracking-wider text-[10px]"> | |
| Start | |
| </li> | |
| {Array.from({ length: stepCount }).map((_, idx) => ( | |
| <React.Fragment key={idx}> | |
| <li className="text-[#555]" aria-hidden>↓</li> | |
| <li> | |
| <span className="text-[#9f7fd1]">Scene {idx + 1}</span> —{" "} | |
| {sceneNames[idx] || `Beat ${idx + 1}`} | |
| </li> | |
| <li className="text-[#555]" aria-hidden>↓</li> | |
| <li> | |
| <span className="text-[#9f7fd1]">Choose:</span> | |
| <ul className="mt-0.5 ml-3 flex flex-col gap-0.5"> | |
| {choices.map((c) => ( | |
| <li key={`${idx}-${c}`} className="text-[#cfd8dc]"> | |
| • {titleCase(c)} | |
| </li> | |
| ))} | |
| </ul> | |
| </li> | |
| </React.Fragment> | |
| ))} | |
| <li className="text-[#555]" aria-hidden>↓</li> | |
| <li className="text-[#777] uppercase tracking-wider text-[10px]"> | |
| Ending | |
| </li> | |
| </ol> | |
| </div> | |
| ); | |
| } | |
| function StandardCustomizePanel({ | |
| form, | |
| initial, | |
| patch, | |
| disabled, | |
| advancedOpen, | |
| onToggleAdvanced, | |
| renderEnabled, | |
| onClose, | |
| }: { | |
| form: PlanAutoForm; | |
| initial: PlanAutoResult; | |
| patch: <K extends keyof PlanAutoForm>(k: K, v: PlanAutoForm[K]) => void; | |
| disabled: boolean; | |
| advancedOpen: boolean; | |
| onToggleAdvanced: () => void; | |
| renderEnabled: boolean; | |
| onClose: () => void; | |
| }) { | |
| const choicesPreset = inferChoicesPreset(form.branch_count); | |
| const lengthPreset = inferLengthPreset(form.scenes_per_branch); | |
| const depthPreset = inferDepthPreset(form.depth); | |
| return ( | |
| <section | |
| className="rounded-lg border border-[#2a2a2a] bg-[#121212]/60 px-4 py-4 flex flex-col gap-4" | |
| aria-label="Customize your project" | |
| > | |
| <div className="flex items-center justify-between gap-3"> | |
| <div className="flex items-center gap-2 text-sm font-medium text-[#f1f1f1]"> | |
| <Sparkles className="w-4 h-4 text-[#c4b5fd]" aria-hidden /> | |
| Customize your project | |
| </div> | |
| <button | |
| type="button" | |
| onClick={onClose} | |
| disabled={disabled} | |
| className="text-[11px] text-[#aaa] hover:text-[#f1f1f1] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded px-1 py-0.5 disabled:opacity-50" | |
| > | |
| Hide | |
| </button> | |
| </div> | |
| <div className="flex flex-col gap-3"> | |
| <div className="text-[11px] uppercase tracking-wider text-[#9f7fd1]"> | |
| Title & concept | |
| </div> | |
| <Field label="Title"> | |
| <input | |
| type="text" | |
| value={form.title} | |
| onChange={(e) => patch("title", e.target.value)} | |
| maxLength={80} | |
| disabled={disabled} | |
| className={INPUT_CLS} | |
| /> | |
| </Field> | |
| <Field | |
| label="Concept" | |
| hint="Short brief the planner uses as the story seed." | |
| > | |
| <textarea | |
| rows={3} | |
| value={form.prompt} | |
| onChange={(e) => patch("prompt", e.target.value)} | |
| disabled={disabled} | |
| className={`${INPUT_CLS} resize-y leading-relaxed`} | |
| /> | |
| </Field> | |
| </div> | |
| <div className="flex flex-col gap-3"> | |
| <div className="text-[11px] uppercase tracking-wider text-[#9f7fd1]"> | |
| Audience & mode | |
| </div> | |
| <div className="grid grid-cols-2 gap-3"> | |
| <Field label="Mode"> | |
| <select | |
| value={form.experience_mode} | |
| onChange={(e) => { | |
| const next = e.target.value as ExperienceMode; | |
| patch("experience_mode", next); | |
| patch("policy_profile_id", next); | |
| }} | |
| disabled={disabled} | |
| className={INPUT_CLS} | |
| > | |
| {(Object.keys(MODE_LABELS) as ExperienceMode[]).map((m) => ( | |
| <option key={m} value={m}> | |
| {MODE_LABELS[m]} | |
| </option> | |
| ))} | |
| </select> | |
| </Field> | |
| <Field label="Audience level"> | |
| <select | |
| value={form.audience_level} | |
| onChange={(e) => | |
| patch( | |
| "audience_level", | |
| e.target.value as PlanAutoForm["audience_level"], | |
| ) | |
| } | |
| disabled={disabled} | |
| className={INPUT_CLS} | |
| > | |
| {(Object.keys(LEVEL_LABELS) as PlanAutoForm["audience_level"][]).map((lv) => ( | |
| <option key={lv} value={lv}> | |
| {LEVEL_LABELS[lv]} | |
| </option> | |
| ))} | |
| </select> | |
| </Field> | |
| </div> | |
| </div> | |
| <div className="flex flex-col gap-3"> | |
| <div className="text-[11px] uppercase tracking-wider text-[#9f7fd1]"> | |
| Experience structure | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> | |
| <PresetRadio<ChoicesPreset> | |
| legend="Choices per step" | |
| value={choicesPreset} | |
| options={["simple", "standard", "complex"]} | |
| labels={CHOICES_LABELS} | |
| onChange={(next) => patch("branch_count", CHOICES_VALUES[next])} | |
| disabled={disabled} | |
| /> | |
| <PresetRadio<LengthPreset> | |
| legend="Story length" | |
| value={lengthPreset} | |
| options={["short", "medium", "long"]} | |
| labels={LENGTH_LABELS} | |
| onChange={(next) => patch("scenes_per_branch", LENGTH_VALUES[next])} | |
| disabled={disabled} | |
| /> | |
| <PresetRadio<DepthPreset> | |
| legend="Path depth" | |
| value={depthPreset} | |
| options={["light", "balanced", "deep"]} | |
| labels={DEPTH_LABELS} | |
| onChange={(next) => patch("depth", DEPTH_VALUES[next])} | |
| disabled={disabled} | |
| /> | |
| </div> | |
| <p className="text-[11px] text-[#777]"> | |
| About{" "} | |
| <span className="text-[#cfd8dc]">{estimateSceneTotal(form)}</span>{" "} | |
| scenes total (the graph merges overlapping branches). | |
| </p> | |
| </div> | |
| <StoryFlowPreview form={form} initial={initial} /> | |
| {initial.seed_intents.length > 0 && ( | |
| <Field | |
| label="Player interactions" | |
| hint="Action types the planner can use. Edit raw seeds in Advanced." | |
| > | |
| <div className="flex flex-wrap gap-1.5"> | |
| {initial.seed_intents.map((s) => ( | |
| <span | |
| key={s} | |
| className="inline-flex items-center gap-1 rounded-full bg-[#1f1f1f] border border-[#3f3f3f] text-[#cfd8dc] text-[11px] font-medium px-2 py-0.5" | |
| > | |
| <Check className="w-3 h-3 text-[#9f7fd1]" aria-hidden /> | |
| {titleCase(s)} | |
| </span> | |
| ))} | |
| </div> | |
| </Field> | |
| )} | |
| <Field label="Render plan"> | |
| <RenderPlanPanel | |
| personaLive={false} | |
| renderEnabled={renderEnabled} | |
| sceneCount={estimateSceneTotal(form)} | |
| libraryPlanned={0} | |
| libraryAlreadyBuilt={0} | |
| /> | |
| </Field> | |
| {(initial.objective || initial.topic || initial.success_metric) && ( | |
| <div className="rounded-md border border-[#2a2a2a] bg-[#0f0f0f]/60 px-3 py-2.5 text-[11px] leading-relaxed"> | |
| <div className="text-[#cfd8dc] font-medium mb-1">Planner preview</div> | |
| <dl className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-0.5 text-[#aaa]"> | |
| {initial.objective && ( | |
| <> | |
| <dt>Objective</dt> | |
| <dd className="text-[#cfd8dc]">{initial.objective}</dd> | |
| </> | |
| )} | |
| {initial.topic && ( | |
| <> | |
| <dt>Topic</dt> | |
| <dd className="text-[#cfd8dc]">{initial.topic}</dd> | |
| </> | |
| )} | |
| {initial.success_metric && ( | |
| <> | |
| <dt>Success</dt> | |
| <dd className="text-[#cfd8dc]">{initial.success_metric}</dd> | |
| </> | |
| )} | |
| </dl> | |
| </div> | |
| )} | |
| <AdvancedBlock | |
| open={advancedOpen} | |
| onToggle={onToggleAdvanced} | |
| form={form} | |
| patch={patch} | |
| disabled={disabled} | |
| personaLive={false} | |
| /> | |
| <div className="border-t border-[#2a2a2a] pt-3 -mb-1"> | |
| <ExpertModeToggle /> | |
| </div> | |
| </section> | |
| ); | |
| } | |