Download frontend/src/ui/interactive/wizardSteps/Step0Prompt.tsx from ruslanmv/HomePilot: direct link, hf CLI and curl.
- Browser
- Download file 30.1 kB
-
https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/interactive/wizardSteps/Step0Prompt.tsx
- Command line
-
hf download hf://spaces/ruslanmv/HomePilot/frontend/src/ui/interactive/wizardSteps/Step0Prompt.tsx
-
curl -L -o Step0Prompt.tsx https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/interactive/wizardSteps/Step0Prompt.tsx
30.1 kB
| /** | |
| * 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<WizardForm>) => 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<boolean>(() => { | |
| 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<Array<{ | |
| id: string; | |
| label: string; | |
| avatar_url: string; | |
| archetype: string; | |
| }>>(() => { | |
| 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 = 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<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(); | |
| }, []); | |
| // 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<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]); | |
| // 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<string, { avatar_url: string; archetype: string }> | |
| >({}); | |
| 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 ( | |
| <div className="flex flex-col gap-5"> | |
| {/* Interaction type picker β mirrors the Animate/Voice dual-card | |
| pattern so Interactive inherits the same visual rhythm. */} | |
| <FieldLabel label="Interaction type" hint="Choose what kind of interactive video to build."> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => setForm({ | |
| interaction_type: "standard_project", | |
| persona_project_id: "", | |
| persona_label: "", | |
| })} | |
| aria-pressed={form.interaction_type === "standard_project"} | |
| className={[ | |
| "text-left bg-[#121212] border rounded-md p-3 transition-colors", | |
| "focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff]", | |
| form.interaction_type === "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] inline-flex items-center gap-1.5"> | |
| <GitBranch className="w-3.5 h-3.5 text-[#7dd3fc]" aria-hidden /> | |
| Standard interactive project | |
| </div> | |
| <div className="text-xs text-[#aaa] mt-0.5"> | |
| Branching AI video with scenes, choices, and endings. | |
| </div> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => setForm({ interaction_type: "persona_live_play" })} | |
| aria-pressed={form.interaction_type === "persona_live_play"} | |
| className={[ | |
| "text-left bg-[#121212] border rounded-md p-3 transition-colors", | |
| "focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff]", | |
| form.interaction_type === "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-xs text-[#aaa] mt-0.5"> | |
| Pick one of your personas β chat + video revolve around them. | |
| </div> | |
| </button> | |
| </div> | |
| </FieldLabel> | |
| {form.interaction_type === "persona_live_play" && ( | |
| <FieldLabel | |
| htmlFor="ix_persona_pick" | |
| label="Persona" | |
| required | |
| hint="Select the persona that should drive live-play animation and conversation." | |
| > | |
| <select | |
| id="ix_persona_pick" | |
| value={form.persona_project_id} | |
| onChange={(e) => { | |
| const selected = personaOptions.find((p) => p.id === e.target.value); | |
| setForm({ | |
| persona_project_id: e.target.value, | |
| persona_label: selected?.label || "", | |
| }); | |
| }} | |
| className="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" | |
| > | |
| <option value="">Select personaβ¦</option> | |
| {personaOptions.map((persona) => ( | |
| <option key={persona.id} value={persona.id}>{persona.label}</option> | |
| ))} | |
| </select> | |
| {personaOptions.length === 0 && ( | |
| <p className="text-[11px] text-amber-300 mt-1"> | |
| No personas yet. Create one under the Persona workspace, then come back. | |
| </p> | |
| )} | |
| {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 ( | |
| <div | |
| className={[ | |
| "mt-3 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={selected.label} | |
| className={[ | |
| "w-32 h-32 rounded-md object-cover", | |
| "border-2 border-[#51347f]", | |
| "shadow-md flex-shrink-0", | |
| "self-center sm:self-start", | |
| ].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" | |
| > | |
| {(selected.label || "?").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 card | |
| </div> | |
| <div className="text-base font-medium text-[#f1f1f1] truncate"> | |
| {selected.label} | |
| </div> | |
| <div className="text-xs text-[#b59ed9] truncate"> | |
| {archetype || "Persona companion"} | |
| </div> | |
| <div className="text-[11px] text-[#7c3aed] mt-1"> | |
| Portrait is frozen into this experience for consistent playback. | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })()} | |
| </FieldLabel> | |
| )} | |
| <FieldLabel htmlFor="ix_title" label="Project title" required> | |
| <input | |
| id="ix_title" | |
| type="text" | |
| value={form.title} | |
| onChange={(e) => 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" | |
| /> | |
| </FieldLabel> | |
| <FieldLabel | |
| htmlFor="ix_prompt" | |
| label={form.interaction_type === "persona_live_play" ? "Session vibe" : "Prompt"} | |
| required | |
| hint={form.interaction_type === "persona_live_play" | |
| ? "Describe the live-play vibe in plain language (e.g., playful tease, romantic late-night, dominant banter)." | |
| : "Describe the experience in plain language. The planner uses this to design branches and pick scene topics."} | |
| > | |
| <textarea | |
| id="ix_prompt" | |
| rows={5} | |
| value={form.prompt} | |
| onChange={(e) => setForm({ prompt: e.target.value })} | |
| placeholder={form.interaction_type === "persona_live_play" | |
| ? "e.g. teasing and flirt with progressive unlocks, playful dominant tone" | |
| : "e.g. Walk a new hire through our 3 pricing tiers in 4 branches; each branch ends with a quiz question."} | |
| 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 resize-y" | |
| /> | |
| <PromptCounter value={form.prompt} /> | |
| </FieldLabel> | |
| <FieldLabel | |
| label="Render media" | |
| hint="Image = fast still-frame scenes (low GPU, good for feasibility tests). Video = full Animate/SVD clips." | |
| > | |
| <RenderMediaSelect | |
| value={form.render_media_type} | |
| onChange={(v) => setForm({ render_media_type: v })} | |
| /> | |
| </FieldLabel> | |
| <FieldLabel label="Experience mode" hint="Selects the policy profile + scene templates downstream."> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> | |
| {visibleModeOptions.map((m) => { | |
| const selected = form.experience_mode === m.value; | |
| return ( | |
| <button | |
| key={m.value} | |
| type="button" | |
| onClick={() => setForm({ experience_mode: m.value, policy_profile_id: m.value })} | |
| aria-pressed={selected} | |
| className={[ | |
| "text-left bg-[#121212] border rounded-md p-3 transition-colors", | |
| "focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff]", | |
| selected | |
| ? "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]">{m.label}</div> | |
| <div className="text-xs text-[#aaa] mt-0.5">{m.hint}</div> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </FieldLabel> | |
| {/* | |
| * Storyteller LLM picker β only visible in Mature (gated) mode. | |
| * The default Llama 3 / 3.2 models refuse explicit content | |
| * with "I cannot create content that describes explicit | |
| * sexual situations." Operators who picked Mature need to | |
| * point this experience at one of the abliterated / | |
| * uncensored Ollama models the Models tab lists. Empty | |
| * selection = use the server default (the toggle just lets | |
| * power users override per-experience). | |
| */} | |
| {form.experience_mode === "mature_gated" && ( | |
| <AdultLlmPicker | |
| value={form.adult_llm} | |
| onChange={(v) => setForm({ adult_llm: v })} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function FieldLabel({ | |
| label, hint, required, htmlFor, children, | |
| }: { | |
| label: string; | |
| hint?: string; | |
| required?: boolean; | |
| htmlFor?: string; | |
| children: React.ReactNode; | |
| }) { | |
| return ( | |
| <div className="flex flex-col gap-1.5"> | |
| <label htmlFor={htmlFor} className="text-xs font-medium text-[#cfd8dc]"> | |
| {label} | |
| {required && <span className="text-[#3ea6ff] ml-0.5" aria-label="required">*</span>} | |
| </label> | |
| {hint && <p className="text-xs text-[#777] -mt-0.5">{hint}</p>} | |
| {children} | |
| </div> | |
| ); | |
| } | |
| function PromptCounter({ value }: { value: string }) { | |
| const len = value.trim().length; | |
| const ok = len >= 1; | |
| return ( | |
| <div className="mt-0.5" aria-live="polite"> | |
| <div className={["text-[11px]", ok ? "text-[#777]" : "text-amber-400"].join(" ")}> | |
| {len} characters{ok ? " β" : ""} | |
| </div> | |
| {!ok && ( | |
| <div className="text-[11px] text-[#777] mt-1"> | |
| Type at least one character to enable the Next button. | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function RenderMediaSelect({ | |
| value, onChange, | |
| }: { | |
| value: RenderMediaType; | |
| onChange: (v: RenderMediaType) => void; | |
| }) { | |
| // Compact two-option picker styled like the existing interaction- | |
| // type cards so the wizard stays visually consistent. Keeping the | |
| // control small matches the user's request ("add a new small | |
| // dropdown") β a full-width two-card picker would steal focus from | |
| // the more important interaction-type + mode decisions above. | |
| const options: Array<{ | |
| value: RenderMediaType; | |
| label: string; | |
| sub: string; | |
| icon: React.ReactNode; | |
| }> = [ | |
| { | |
| value: "video", | |
| label: "Video (full pipeline)", | |
| sub: "Uses the Animate / SVD workflow. Needs a capable GPU.", | |
| icon: <Video className="w-4 h-4" aria-hidden />, | |
| }, | |
| { | |
| value: "image", | |
| label: "Image (feasibility mode)", | |
| sub: "Still frames via txt2img. Fast, low-VRAM; same UX everywhere else.", | |
| icon: <ImageIcon className="w-4 h-4" aria-hidden />, | |
| }, | |
| ]; | |
| return ( | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> | |
| {options.map((opt) => { | |
| const selected = value === opt.value; | |
| return ( | |
| <button | |
| key={opt.value} | |
| type="button" | |
| onClick={() => onChange(opt.value)} | |
| aria-pressed={selected} | |
| className={[ | |
| "text-left bg-[#121212] border rounded-md p-3 transition-colors", | |
| "focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff]", | |
| selected | |
| ? "border-[#3ea6ff] bg-[rgba(62,166,255,0.08)] ring-1 ring-[#3ea6ff]" | |
| : "border-[#3f3f3f] hover:border-[#555] hover:bg-[#1a1a1a]", | |
| ].join(" ")} | |
| > | |
| <div className="flex items-center gap-2 text-sm font-medium text-[#f1f1f1]"> | |
| <span className="text-[#3ea6ff]">{opt.icon}</span> | |
| {opt.label} | |
| </div> | |
| <div className="text-xs text-[#aaa] mt-0.5">{opt.sub}</div> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| /** Validation hook used by the parent Wizard to enable Next. */ | |
| export function step0Valid(f: WizardForm): boolean { | |
| // Persona live play additionally requires a persona selection so | |
| // the live-play engine has a character to animate + chat as. | |
| if (f.interaction_type === "persona_live_play" && !f.persona_project_id.trim()) { | |
| return false; | |
| } | |
| return f.title.trim().length >= 3 && f.prompt.trim().length >= 1; | |
| } | |
| // ββ Storyteller LLM picker (Mature only) ββββββββββββββββββββββββββββββββ | |
| /** | |
| * Heuristic β does this Ollama model id look like an | |
| * abliterated / uncensored / NSFW-permissive variant? | |
| * | |
| * Source of truth for the substrings is the curated catalog in | |
| * Models.tsx (rows tagged ``nsfw: true`` or ``recommended_nsfw``). | |
| * Pulling the full catalog would force a /model-catalog round-trip | |
| * just for this dropdown, so we name-match instead β same set of | |
| * model families, no extra fetch. | |
| */ | |
| const ADULT_LLM_NEEDLES = [ | |
| "abliterat", // huihui_ai/qwen3-abliterated, mannix/llama3.1-8b-abliterated, etc. | |
| "dolphin", // dolphin-mistral, dolphin-llama3, dolphin3 | |
| "uncensored", // llama2-uncensored, wizardlm-uncensored, wizard-vicuna-uncensored | |
| "josiefied", // goekdenizguelmez/JOSIEFIED-Qwen3, JOSIEFIED-Llama | |
| "samantha", // samantha-mistral | |
| "hermes", // hermes3, OpenHermes | |
| "wizardlm", // wizardlm2, wizardlm-uncensored | |
| "wizard-vicuna", | |
| "neural-chat", | |
| ]; | |
| interface OllamaTag { | |
| /** Ollama-style id, e.g. ``llama3:8b`` or ``huihui_ai/qwen3-abliterated:4b``. */ | |
| id?: string; | |
| name?: string; | |
| model?: string; | |
| } | |
| function _looksAdult(modelId: string): boolean { | |
| const lc = modelId.toLowerCase(); | |
| return ADULT_LLM_NEEDLES.some((needle) => lc.includes(needle)); | |
| } | |
| interface AdultLlmPickerProps { | |
| value: string; | |
| onChange: (next: string) => void; | |
| } | |
| function AdultLlmPicker({ value, onChange }: AdultLlmPickerProps) { | |
| const [installed, setInstalled] = useState<string[]>([]); | |
| const [loading, setLoading] = useState<boolean>(true); | |
| const [error, setError] = useState<string | null>(null); | |
| useEffect(() => { | |
| const ctrl = new AbortController(); | |
| const backend = resolveBackendUrl(); | |
| fetch(`${backend}/models?provider=ollama`, { | |
| signal: ctrl.signal, | |
| credentials: "include", | |
| }) | |
| .then((r) => (r.ok ? r.json() : null)) | |
| .then((body) => { | |
| if (!body) { | |
| setError("Couldn't reach Ollama."); | |
| setLoading(false); | |
| return; | |
| } | |
| // ``GET /models?provider=ollama`` returns the raw Ollama | |
| // model-id list as plain strings (the backend's | |
| // model_catalog.list_models_for_provider extracts ``.name`` | |
| // from /api/tags and sorts them). Older OpenAI-compat | |
| // endpoints return ``{data: [{id}]}`` and Ollama itself | |
| // sometimes returns ``{models: [{model}]}`` β handle every | |
| // shape so the picker works regardless of which surface | |
| // the backend is proxying. The previous parser assumed | |
| // object entries only and silently produced an empty list | |
| // when the backend returned strings, which made the picker | |
| // claim "no abliterated models found" even when 3 were | |
| // installed. | |
| const rawList: unknown[] = Array.isArray(body?.data) | |
| ? body.data | |
| : Array.isArray(body?.models) | |
| ? body.models | |
| : []; | |
| const ids: string[] = rawList | |
| .map((m) => { | |
| if (typeof m === "string") return m.trim(); | |
| if (m && typeof m === "object") { | |
| const o = m as Record<string, unknown>; | |
| return String(o.id || o.name || o.model || "").trim(); | |
| } | |
| return ""; | |
| }) | |
| .filter(Boolean); | |
| setInstalled(ids); | |
| setLoading(false); | |
| }) | |
| .catch((err) => { | |
| if (err?.name === "AbortError") return; | |
| setError("Couldn't fetch the model list."); | |
| setLoading(false); | |
| }); | |
| return () => ctrl.abort(); | |
| }, []); | |
| const adultInstalled = useMemo( | |
| () => installed.filter(_looksAdult).sort(), | |
| [installed], | |
| ); | |
| return ( | |
| <FieldLabel | |
| htmlFor="ix_adult_llm" | |
| label="Storyteller LLM" | |
| hint="Mature (gated) only. The default Llama models refuse explicit content; pick an installed abliterated / uncensored model so the wizard's scene-graph LLM and the persona's chat engine can actually generate the content this experience asks for. Leave on default to use the server's configured Ollama model." | |
| > | |
| <select | |
| id="ix_adult_llm" | |
| value={value} | |
| onChange={(e) => onChange(e.target.value)} | |
| disabled={loading} | |
| className="w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2.5 text-sm outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316]/40" | |
| > | |
| <option value="">Use server default</option> | |
| {adultInstalled.map((id) => ( | |
| <option key={id} value={id}> | |
| {id} | |
| </option> | |
| ))} | |
| </select> | |
| {!loading && !error && adultInstalled.length === 0 && ( | |
| <p className="text-[11px] text-amber-300 mt-1"> | |
| No abliterated / uncensored Ollama models found in your install. | |
| Open Models β Chat (Ollama) and install one tagged | |
| <span className="mx-1 px-1 py-0.5 rounded bg-amber-500/15 border border-amber-500/30 text-amber-200"> | |
| π₯ NSFW Pick | |
| </span> | |
| (Qwen3 Abliterated / JOSIEFIED Qwen3 / Dolphin / Samantha) β then | |
| revisit this picker. | |
| </p> | |
| )} | |
| {error && ( | |
| <p className="text-[11px] text-red-300 mt-1"> | |
| {error} β leaving this on default falls back to the server's Ollama | |
| model. | |
| </p> | |
| )} | |
| </FieldLabel> | |
| ); | |
| } | |