HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
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>
);
}