/** * Step 0 — Prompt + mode selection. * * Two required inputs (title, prompt) and one mode picker. Mode * options are static here to avoid a network roundtrip on first * paint; the planner backend tolerates any of the six known mode * strings and falls back to sfw_general otherwise. */ import React, { useEffect, useMemo, useState } from "react"; import { GitBranch, Image as ImageIcon, Users, Video } from "lucide-react"; import type { ExperienceMode } from "../types"; import type { RenderMediaType, WizardForm } from "../wizardState"; import { LS_PERSONA_CACHE } from "../../voice/personalityGating"; import { resolveBackendUrl } from "../../lib/backendUrl"; export interface Step0Props { form: WizardForm; setForm: (patch: Partial) => void; } const MODE_OPTIONS: Array<{ value: ExperienceMode; label: string; hint: string; matureOnly?: boolean }> = [ { value: "sfw_general", label: "General (SFW)", hint: "Safe-for-work default — broad audiences." }, { value: "sfw_education", label: "Education", hint: "Lessons, tutorials, explanations." }, { value: "language_learning", label: "Language learning", hint: "CEFR-aware exercises and conversation." }, { value: "enterprise_training", label: "Enterprise training", hint: "Onboarding, compliance, certification." }, { value: "social_romantic", label: "Social / Romantic", hint: "Casual social play, mood-aware companions." }, { value: "mature_gated", label: "Mature (gated)", hint: "Requires explicit viewer consent + region check.", matureOnly: true }, ]; // The "Mature (gated)" tier is only surfaced when Spicy Mode (NSFW) is // enabled under Settings → Advanced. This hook reads the same // localStorage key App.tsx writes (`homepilot_nsfw_mode`) and reacts to // cross-tab toggles via the native `storage` event, so flipping the // switch reflects here without a page reload. const NSFW_MODE_STORAGE_KEY = "homepilot_nsfw_mode"; function useNsfwMode(): boolean { const [enabled, setEnabled] = useState(() => { try { return localStorage.getItem(NSFW_MODE_STORAGE_KEY) === "true"; } catch { return false; } }); useEffect(() => { const onStorage = (e: StorageEvent) => { if (e.key === NSFW_MODE_STORAGE_KEY) { setEnabled(e.newValue === "true"); } }; // Same-tab toggles don't fire `storage`, so poll briefly on focus. const onFocus = () => { try { setEnabled(localStorage.getItem(NSFW_MODE_STORAGE_KEY) === "true"); } catch { /* ignore */ } }; window.addEventListener("storage", onStorage); window.addEventListener("focus", onFocus); return () => { window.removeEventListener("storage", onStorage); window.removeEventListener("focus", onFocus); }; }, []); return enabled; } export function Step0Prompt({ form, setForm }: Step0Props) { const spicyModeEnabled = useNsfwMode(); // When Spicy Mode is disabled, the gated-mature tier is hidden from // the picker entirely. If the form already carries `mature_gated` // (e.g. Spicy was flipped off mid-wizard), coerce it back to the // SFW default so the payload stays consistent with the visible UI. useEffect(() => { if (!spicyModeEnabled && form.experience_mode === "mature_gated") { setForm({ experience_mode: "sfw_general", policy_profile_id: "sfw_general" }); } }, [spicyModeEnabled, form.experience_mode, setForm]); const visibleModeOptions = useMemo( () => MODE_OPTIONS.filter((m) => !m.matureOnly || spicyModeEnabled), [spicyModeEnabled], ); // Persona options come from two sources merged on id: // // 1. ``LS_PERSONA_CACHE`` — populated by App.tsx writers when // the user explicitly enters a persona via Voice / Session // Hub. Cheap synchronous read; 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 Project workspace but // never opened them in voice mode saw "No personas yet." // even though the backend had several. // // The fallback fires unconditionally (not just on empty cache) // because a persona created after the cache was last written // would otherwise stay invisible until a Voice link warmed it. // ``setCacheOptions`` is intentionally unused: the cache is read // once on mount via the lazy initializer to render something on // first paint. The backend fetch is the authoritative refresh, // so there's no second-write path on the cache side. // eslint-disable-next-line @typescript-eslint/no-unused-vars const [cacheOptions, _setCacheOptions] = useState>(() => { 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 = resolveBackendUrl(); fetch(`${backend}/projects`, { signal: ctrl.signal, credentials: "include", }) .then((r) => (r.ok ? r.json() : null)) .then((body) => { // The /projects endpoint returns either ``{projects: [...]}`` // or just ``[...]`` depending on which auth wrapper served // the request — handle both shapes. 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(); }, []); // Merge: backend wins (authoritative + has avatar/archetype), // cache fills any gaps (e.g. backend fetch failed). De-duped on id. const personaOptions = useMemo(() => { const byId = new Map(); 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]); // The LS_PERSONA_CACHE writers in App.tsx persist label / persona_class // but do NOT include avatar_url or archetype — historical oversight. // That's why the wizard's persona preview card used to render an empty // grey swatch next to the selected persona name. Rather than touch every // cache writer, we resolve the missing fields from the backend at // selection time and keep them in component state. Cheap: one GET per // wizard session per selected persona. const [resolvedDetails, setResolvedDetails] = useState< Record >({}); useEffect(() => { const pid = form.persona_project_id; if (!pid) return; const cached = personaOptions.find((p) => p.id === pid); const needsAvatar = !(cached && cached.avatar_url); const needsArchetype = !(cached && cached.archetype); if (!needsAvatar && !needsArchetype) return; if (resolvedDetails[pid]) return; const ctrl = new AbortController(); const backend = resolveBackendUrl(); fetch(`${backend}/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 }; persona_agent?: { persona_class?: unknown; response_style?: { tone?: unknown }; }; }; const filename = String( project.persona_appearance?.selected_filename || "", ).trim(); const avatarUrl = filename ? `${backend}/files/${filename}` : ""; const archetype = String(project.persona_agent?.persona_class || "").trim() || String(project.persona_agent?.response_style?.tone || "").trim(); setResolvedDetails((prev) => ({ ...prev, [pid]: { avatar_url: avatarUrl, archetype }, })); }) .catch(() => { /* swallow — preview falls back to placeholder */ }); return () => ctrl.abort(); }, [form.persona_project_id, personaOptions, resolvedDetails]); return (
{/* Interaction type picker — mirrors the Animate/Voice dual-card pattern so Interactive inherits the same visual rhythm. */}
{form.interaction_type === "persona_live_play" && ( {personaOptions.length === 0 && (

No personas yet. Create one under the Persona workspace, then come back.

)} {form.persona_project_id && (() => { const selected = personaOptions.find((p) => p.id === form.persona_project_id); if (!selected) return null; const resolved = resolvedDetails[form.persona_project_id]; const avatarUrl = selected.avatar_url || resolved?.avatar_url || ""; const archetype = selected.archetype || resolved?.archetype || ""; // Hero-sized persona preview card. The previous 12×12 icon // was too small to read the persona's face / outfit / vibe // — operators were second-guessing whether they'd selected // the right persona. 32×32 (128 px) gives a portrait that // actually conveys identity at a glance, and the card uses // a vertical-on-mobile / horizontal-on-desktop layout so // it stays compact in the form column. return (
{avatarUrl ? ( {selected.label} ) : (
{(selected.label || "?").trim().charAt(0).toUpperCase()}
)}
Persona card
{selected.label}
{archetype || "Persona companion"}
Portrait is frozen into this experience for consistent playback.
); })()}
)} setForm({ title: e.target.value })} placeholder="e.g. Onboard new sales reps to our pricing tiers" maxLength={120} className="w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2.5 text-sm outline-none focus:border-[#3ea6ff] focus:ring-1 focus:ring-[#3ea6ff]/50" />