const { useState, useEffect, useRef } = React;
const TRAITS = [
"Affectionate", "Opinionated", "Thoughtful/Curious", "Bold/Adventurous",
"Compassionate/Empathetic", "Confident", "Deep Conversations/Intellectual",
"Dramatic", "Expressive", "Flirty", "Innocent/Sweet", "Modest", "Outgoing",
"Philosophical", "Playful/Teasing", "Quiet/Reserved", "Romantic", "Sarcastic",
"Shy", "Stubborn",
];
const BODY_TYPES = [
{ id: "slim", label: "Slim" },
{ id: "average", label: "Average" },
{ id: "athletic", label: "Athletic" },
{ id: "curvy", label: "Curvy" },
];
const DEFAULT_APPEARANCE = {
country: "mixed",
skin_tone: "medium",
hair_color: "brown",
hair_style: "medium",
eye_color: "brown",
outfit: "casual",
framing: "face",
age_appearance: "mid_20s",
vibe: "natural",
};
const api = {
token: () => localStorage.getItem("token"),
headers: () => ({
"Content-Type": "application/json",
Authorization: `Bearer ${api.token()}`,
}),
async get(path) {
const r = await fetch(path, { headers: api.headers() });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || "Request failed");
return d;
},
async post(path, body) {
const r = await fetch(path, {
method: "POST",
headers: api.headers(),
body: JSON.stringify(body),
});
const d = await r.json();
if (!r.ok) throw new Error(typeof d.detail === "string" ? d.detail : "Request failed");
return d;
},
async patch(path, body) {
const r = await fetch(path, {
method: "PATCH",
headers: api.headers(),
body: JSON.stringify(body),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || "Request failed");
return d;
},
};
function avatarUrl(seed, lookGender) {
const style = lookGender === "male" ? "adventurer" : "lorelei";
return `https://api.dicebear.com/7.x/${style}/svg?seed=${encodeURIComponent(seed)}&backgroundColor=1a1a2e,2d1b4e`;
}
function portraitUrl(companion, cacheBust) {
if (companion?.portrait_path && companion?.id && api.token()) {
const base = `/api/companions/${companion.id}/portrait?token=${encodeURIComponent(api.token())}`;
return cacheBust ? `${base}&v=${cacheBust}` : base;
}
const portraits = {
female: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=600&fit=crop",
male: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=600&fit=crop",
nonbinary: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=400&h=600&fit=crop",
};
return portraits[companion?.look_gender] || portraits.female;
}
const PORTRAIT_FALLBACKS = {
female: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=1200&h=1600&fit=crop",
male: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=1200&h=1600&fit=crop",
nonbinary: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=1200&h=1600&fit=crop",
};
function PortraitLightbox({ src, alt, onClose }) {
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [onClose]);
return (

e.stopPropagation()} />
);
}
function MagnifyIcon() {
return (
);
}
function CompanionPortraitFrame({ companion, portraitVersion, alt, className, onOpenFull }) {
const src = portraitUrl(companion, portraitVersion);
return (

{
e.target.onerror = null;
e.target.src = PORTRAIT_FALLBACKS[companion?.look_gender] || PORTRAIT_FALLBACKS.female;
}}
/>
);
}
function Silhouette({ gender, bodyType }) {
const widths = { slim: 28, average: 36, athletic: 40, curvy: 44 };
const w = widths[bodyType] || 36;
const isMale = gender === "male";
const isNB = gender === "nonbinary";
return (
);
}
function AuthScreen({ config, onAuth, mode, setMode }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function submit(e) {
e.preventDefault();
setError("");
setLoading(true);
try {
const path = mode === "login" ? "/api/auth/login" : "/api/auth/register";
const data = await api.post(path, { email, password });
localStorage.setItem("token", data.token);
onAuth();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
{config.site_name}
{config.site_tagline}
{error &&
{error}
}
{mode === "login" ? (
<>New here? >
) : (
<>Already have an account? >
)}
);
}
function ProfileScreen({ onNext, onBack }) {
const [displayName, setDisplayName] = useState("");
const [gender, setGender] = useState("");
const [age, setAge] = useState("");
const [policy, setPolicy] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function submit(e) {
e.preventDefault();
if (!policy) { setError("You must accept the terms to continue."); return; }
setLoading(true);
setError("");
try {
await api.patch("/api/me/profile", {
display_name: displayName,
gender,
age: parseInt(age, 10),
policy_accepted: true,
});
onNext({ displayName, gender, age: parseInt(age, 10) });
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
Tell us about you
{error &&
{error}
}
);
}
function PillSelect({ label, options, value, onChange }) {
return (
{options.map((o) => (
onChange(o.id)}
>
{o.label}
))}
);
}
function AppearancePicker({ appearance, setAppearance, options, compact }) {
if (!options) return Loading look options…
;
const set = (key) => (id) => setAppearance((a) => ({ ...a, [key]: id }));
return (
);
}
function LookScreen({ onNext, onBack }) {
const [lookGender, setLookGender] = useState("female");
const [bodyType, setBodyType] = useState("average");
const [appearance, setAppearance] = useState({ ...DEFAULT_APPEARANCE });
const [options, setOptions] = useState(null);
useEffect(() => {
api.get("/api/appearance/options").then(setOptions).catch(() => {});
}, []);
return (
Choose Your Companion's Look
These choices shape their AI-generated portrait — country, colors, outfit, and how much of their body you see.
{["female", "male", "nonbinary"].map((g) => (
setLookGender(g)}>
{g.charAt(0).toUpperCase() + g.slice(1)}
))}
{BODY_TYPES.map((b) => (
setBodyType(b.id)}
>
{b.label}
))}
Face, style & photo
);
}
function PortraitStudioModal({ companion, options, quota, onClose, onUpdated }) {
const [appearance, setAppearance] = useState(
companion?.appearance ? { ...DEFAULT_APPEARANCE, ...companion.appearance } : { ...DEFAULT_APPEARANCE }
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const remaining = quota?.remaining ?? 0;
async function regenerate() {
if (!quota?.can_regenerate) {
setError(quota?.upgrade_message || "Daily limit reached.");
return;
}
setLoading(true);
setError("");
try {
const data = await api.post(`/api/companions/${companion.id}/regenerate-portrait`, { appearance });
onUpdated(data.companion, data.portrait_quota);
onClose();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
e.stopPropagation()}>
New portrait for {companion.name}
{remaining > 0
? `${remaining} of ${quota?.limit || 3} free generations left today.`
: quota?.upgrade_message}
{error &&
{error}
}
{!quota?.can_regenerate && (
)}
);
}
function SetupScreen({ config, wizard, onNext, onBack }) {
const [name, setName] = useState("");
const [relationship, setRelationship] = useState("friendship");
const [traits, setTraits] = useState([]);
const [customTrait, setCustomTrait] = useState("");
const [showBackstory, setShowBackstory] = useState(false);
const [backstory, setBackstory] = useState("");
const [error, setError] = useState("");
const maxBackstory = 1000;
function toggleTrait(t) {
setTraits((prev) =>
prev.includes(t) ? prev.filter((x) => x !== t) : prev.length < 7 ? [...prev, t] : prev
);
}
async function generateName() {
try {
const data = await api.post("/api/companions/generate-name", {
look_gender: wizard.lookGender,
});
setName(data.name);
} catch {
setName(["Luna", "Alex", "River"][Math.floor(Math.random() * 3)]);
}
}
function submit() {
if (!name.trim()) { setError("Give your companion a name."); return; }
if (relationship !== "custom" && traits.length < 3) { setError("Pick at least 3 traits."); return; }
setError("");
onNext({ name: name.trim(), relationship, traits, backstory: backstory.trim() });
}
useEffect(() => {
if (relationship === "custom") setShowBackstory(true);
}, [relationship]);
return (
Get Ready to Meet {name.trim() ? name : "Your Companion"}!
{error &&
{error}
}
{[
{ id: "friendship", label: "Friendship" },
{ id: "romantic", label: "Romantic" },
{ id: "mentor", label: "Mentor" },
{ id: "custom", label: "Custom" },
].map((r) => (
))}
{relationship !== "custom" && (
<>
Please choose between 3 and 7 key traits.
{TRAITS.map((t) => (
toggleTrait(t)}
>
{traits.includes(t) && ✓}
{t}
))}
setCustomTrait(e.target.value)}
placeholder="Add your own…"
onKeyDown={(e) => {
if (e.key === "Enter" && customTrait.trim() && traits.length < 7) {
toggleTrait(customTrait.trim());
setCustomTrait("");
}
}}
/>
{!showBackstory && (
)}
>
)}
{(showBackstory || relationship === "custom") && (
{relationship === "custom" && (
Enter details about background, personality, interests. Write in third person.
)}
)}
);
}
function CreatingScreen({ config, wizard, onDone }) {
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.post("/api/companions", {
name: wizard.name,
look_gender: wizard.lookGender,
body_type: wizard.bodyType,
relationship: wizard.relationship,
traits: wizard.traits,
backstory: wizard.backstory || "",
appearance: wizard.appearance || DEFAULT_APPEARANCE,
});
onDone(data.companion, data.greeting);
} catch (err) {
alert(err.message);
}
})();
return () => { cancelled = true; };
}, []);
return (
Creating {wizard.name} for you…
Please wait while we make {wizard.name}'s photo and bring them to life. This usually takes about a minute.
);
}
function applyDarkMode(mode) {
const root = document.documentElement;
root.classList.remove("dark-forced", "light-forced");
if (mode === "on") root.classList.add("dark-forced");
else if (mode === "off") root.classList.add("light-forced");
}
function formatBirthdateDisplay(birthdate) {
if (!birthdate) return null;
const d = new Date(birthdate + "T12:00:00");
if (Number.isNaN(d.getTime())) return birthdate;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function BottomNav({ active, onNav }) {
const items = [
{ id: "home", label: "Home", icon: "🏠" },
{ id: "new", label: "New", icon: "✨" },
{ id: "chat", label: "Chat", icon: "💬" },
{ id: "profile", label: "Profile", icon: "👤" },
];
return (
);
}
function SettingsSidebar({ config, active, onNav, onLogout }) {
const items = [
{ id: "profile", label: "Profile Settings", icon: "👤" },
{ id: "account", label: "Account Settings", icon: "⚙️", disabled: true },
{ id: "help", label: "Help", icon: "❓", disabled: true },
];
return (
);
}
function ProfileSettingsPanel({ user, onSaved }) {
const [displayName, setDisplayName] = useState(user?.display_name || "");
const [birthdate, setBirthdate] = useState(user?.birthdate || "");
const [gender, setGender] = useState(user?.gender || "male");
const [stylized, setStylized] = useState(!!user?.stylized_formatting);
const [darkMode, setDarkMode] = useState(user?.dark_mode || "device");
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
setDisplayName(user?.display_name || "");
setBirthdate(user?.birthdate || "");
setGender(user?.gender || "male");
setStylized(!!user?.stylized_formatting);
setDarkMode(user?.dark_mode || "device");
}, [user]);
async function submit(e) {
e.preventDefault();
setError("");
setSuccess(false);
setLoading(true);
try {
const data = await api.patch("/api/me/settings", {
display_name: displayName,
birthdate: birthdate || null,
gender,
stylized_formatting: stylized,
dark_mode: darkMode,
});
onSaved(data.user);
applyDarkMode(data.user.dark_mode || "device");
setSuccess(true);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
Profile Settings
{error &&
{error}
}
{success &&
Settings saved.
}
);
}
function HomePanel({ config, companion, onNav }) {
return (
Home
Welcome back{companion ? ` — ${companion.name} is ready to chat` : ""}.
);
}
function MainApp({ config, user, setUser, companion, setCompanion, portraitQuota, setPortraitQuota, initialMessages, greeting, onLogout, onNewCompanion }) {
const [view, setView] = useState("chat");
const [appearanceOptions, setAppearanceOptions] = useState(null);
useEffect(() => {
api.get("/api/appearance/options").then(setAppearanceOptions).catch(() => {});
}, []);
useEffect(() => {
applyDarkMode(user?.dark_mode || "device");
}, [user?.dark_mode]);
return (
{view === "profile" && (
<>
(id === "profile" ? setView("profile") : null)}
onLogout={onLogout}
/>
>
)}
{view === "home" && (
)}
{view === "new" && (
New Companion
Create another AI companion with a new look and personality.
)}
{view === "chat" && (
setView("profile")}
/>
)}
);
}
function ChatScreen({ config, companion, setCompanion, appearanceOptions, portraitQuota, setPortraitQuota, initialMessages, greeting, onOpenProfile }) {
const [portraitVersion, setPortraitVersion] = useState(Date.now());
const [showPortraitStudio, setShowPortraitStudio] = useState(false);
const [showPortraitLightbox, setShowPortraitLightbox] = useState(false);
const portraitSrc = portraitUrl(companion, portraitVersion);
const openFullPortrait = () => setShowPortraitLightbox(true);
const [messages, setMessages] = useState(
initialMessages.length
? initialMessages.map((m) => ({ role: m.role === "assistant" ? "ai" : "user", text: m.content }))
: [{ role: "ai", text: greeting }]
);
const [input, setInput] = useState("");
const [typing, setTyping] = useState(false);
const [rateLimited, setRateLimited] = useState(false);
const endRef = useRef(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, typing]);
async function send() {
const text = input.trim();
if (!text || typing) return;
setMessages((m) => [...m, { role: "user", text }]);
setInput("");
setTyping(true);
try {
const data = await api.post("/api/chat", { companion_id: companion.id, message: text });
setMessages((m) => [...m, { role: "ai", text: data.reply }]);
if (data.rate_limited) setRateLimited(true);
} catch (err) {
setMessages((m) => [...m, { role: "ai", text: "AI is busy right now — try again in a minute." }]);
setRateLimited(true);
} finally {
setTyping(false);
}
}
const today = new Date().toLocaleDateString("en-US", {
weekday: "short", month: "short", day: "2-digit", year: "numeric",
});
return (
{showPortraitLightbox && (
setShowPortraitLightbox(false)}
/>
)}
{showPortraitStudio && (
setShowPortraitStudio(false)}
onUpdated={(c, q) => {
setCompanion(c);
setPortraitQuota(q);
setPortraitVersion(Date.now());
}}
/>
)}
{rateLimited && (
Free AI limit hit — using demo replies. Add credits at openrouter.ai or wait ~1 hour.
)}
Upgrade for unlimited chat, faster messages & more daily selfies
{companion.name}
{companion.name}
{today}
{messages.map((m, i) => (
{m.text}
{m.role === "ai" && (
{new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
)}
))}
{typing && (
)}
setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
placeholder={`Message ${companion.name}…`}
/>
);
}
function App() {
const [config, setConfig] = useState({ site_name: "…", site_tagline: "" });
const [step, setStep] = useState("loading");
const [authMode, setAuthMode] = useState("login");
const [user, setUser] = useState(null);
const [companion, setCompanion] = useState(null);
const [greeting, setGreeting] = useState("");
const [messages, setMessages] = useState([]);
const [wizard, setWizard] = useState({});
const [portraitQuota, setPortraitQuota] = useState(null);
useEffect(() => {
fetch("/api/config")
.then((r) => r.json())
.then((c) => {
setConfig(c);
document.title = c.site_name;
});
}, []);
async function loadSession() {
if (!api.token()) {
setStep("auth");
return;
}
try {
const data = await api.get("/api/me");
setUser(data.user);
applyDarkMode(data.user?.dark_mode || "device");
if (data.companion) {
setCompanion(data.companion);
setPortraitQuota(data.portrait_quota || null);
const full = await api.get(`/api/companions/${data.companion.id}`);
setMessages(full.messages || []);
setStep("chat");
} else if (!data.user.policy_accepted) {
setStep("profile");
} else {
setStep("look");
}
} catch {
localStorage.removeItem("token");
setStep("auth");
}
}
useEffect(() => { loadSession(); }, []);
if (step === "loading") {
return ;
}
if (step === "auth") {
return (
);
}
if (step === "profile") {
return (
{ localStorage.removeItem("token"); setStep("auth"); }}
onNext={(p) => { setWizard((w) => ({ ...w, ...p })); setStep("look"); }}
/>
);
}
if (step === "look") {
return (
setStep("profile")}
onNext={(d) => { setWizard((w) => ({ ...w, ...d })); setStep("setup"); }}
/>
);
}
if (step === "setup") {
return (
setStep("look")}
onNext={(d) => { setWizard((w) => ({ ...w, ...d })); setStep("creating"); }}
/>
);
}
if (step === "creating") {
return (
{
setCompanion(c);
setGreeting(g);
try {
const me = await api.get("/api/me");
setPortraitQuota(me.portrait_quota || null);
} catch { /* ignore */ }
setStep("chat");
}}
/>
);
}
if (step === "chat" && companion) {
return (
{
localStorage.removeItem("token");
setUser(null);
setCompanion(null);
setStep("auth");
}}
onNewCompanion={() => setStep("look")}
/>
);
}
return null;
}
ReactDOM.createRoot(document.getElementById("root")).render();