HomePilot / frontend /src /ui /components /PersonaSettingsPanel.jsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
99.7 kB
/**
* PersonaSettingsPanel — RPG-style character sheet for persona projects
*
* Replaces the AgentSettingsPanel when editing persona projects.
* Displays the avatar gallery, identity details, appearance settings,
* and agentic capabilities (goal, tools, agents, execution profile).
*
* Phase 2 additions:
* - Wardrobe system — generate outfit variations using stored character prompt
* - Avatar generation settings display — shows reproducibility info
* - Class badge from persona_class stored in persona_agent
*
* Designed like an MMORPG character profile card.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { X, Sparkles, User, Heart, Star, Shield, Palette, FileText, Trash2, Loader2, Camera, Zap, Wrench, Users, Server, Settings, Check, ChevronDown, ChevronUp, Shirt, Plus, Copy, Upload, RefreshCw, Package, Share2, } from 'lucide-react';
import { ImageViewer } from '../ImageViewer';
import { InventoryView } from './InventoryView';
import { OUTFIT_PRESETS, PERSONA_BLUEPRINTS } from '../personaTypes';
import { generateOutfitImages, generatePersonaImages, commitGeneratedImages } from '../personaApi';
import { commitPersonaAvatar } from '../personaPortability';
import { useAvatarCapabilities } from '../useAvatarCapabilities';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PROFILE_OPTIONS = [
{ value: 'fast', label: 'Swift', hint: 'Low latency, fewer tool calls', icon: '\u26A1' },
{ value: 'balanced', label: 'Balanced', hint: 'Good mix of speed and depth', icon: '\u2696\uFE0F' },
{ value: 'quality', label: 'Thorough', hint: 'Multi-step reasoning', icon: '\uD83C\uDFAF' },
];
const BUILTIN_CAPABILITIES = [
{ id: 'generate_images', label: 'Generate images' },
{ id: 'generate_videos', label: 'Generate short videos' },
{ id: 'analyze_documents', label: 'Analyze documents' },
{ id: 'automate_external', label: 'Automate external services' },
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readNsfwMode() {
try {
return localStorage.getItem('homepilot_nsfw_mode') === 'true';
}
catch {
return false;
}
}
/** Build a displayable /files/ URL from a DB-stored relative path.
* Appends auth token for <img> tags that can't set Authorization headers. */
function fileUrl(backendUrl, rel) {
if (!rel)
return null;
const clean = rel.replace(/^\/+/, '');
const tok = localStorage.getItem('homepilot_auth_token') || '';
return `${backendUrl}/files/${clean}${tok ? `?token=${encodeURIComponent(tok)}` : ''}`;
}
/** Resolve an image URL — prepend backendUrl for backend-relative paths
* like `/comfy/view/...` that come from Avatar Studio exports.
* Appends auth token for /files/ paths (needed for <img> tags). */
function resolveImgUrl(url, backendUrl) {
if (!url)
return url;
if (url.startsWith('data:') || url.startsWith('blob:'))
return url;
let full = url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
const base = backendUrl.replace(/\/+$/, '');
const path = url.startsWith('/') ? url : `/${url}`;
full = `${base}${path}`;
}
// Append auth token for /files/ paths so <img> tags can access them
if (full.includes('/files/')) {
const tok = localStorage.getItem('homepilot_auth_token') || '';
if (tok) {
const sep = full.includes('?') ? '&' : '?';
return `${full}${sep}token=${encodeURIComponent(tok)}`;
}
}
return full;
}
let _imgCounter = 0;
function nextImageId() {
return `pimg_${Date.now()}_${++_imgCounter}`;
}
function SectionHeader({ icon: Icon, title, badge, color = 'text-white/50', }) {
return (<div className="flex items-center gap-2 mb-3">
<Icon size={14} className={color}/>
<span className="text-xs font-semibold text-white/60 uppercase tracking-wider">{title}</span>
{badge !== undefined && (<span className="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-white/10 text-white/50 font-medium">
{badge}
</span>)}
</div>);
}
function StatBar({ label, value, color = 'bg-purple-500' }) {
return (<div className="flex items-center gap-3">
<span className="text-[11px] text-white/50 w-20 shrink-0">{label}</span>
<div className="flex-1 h-1.5 bg-white/10 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color} transition-all`} style={{ width: `${value}%` }}/>
</div>
<span className="text-[10px] text-white/40 w-8 text-right">{value}</span>
</div>);
}
function Toggle({ checked, onChange, label, }) {
return (<button type="button" onClick={() => onChange(!checked)} className="flex items-center justify-between w-full group">
<span className="text-sm text-white/80 group-hover:text-white transition-colors">{label}</span>
<div className={[
'relative w-10 h-5 rounded-full transition-colors',
checked ? 'bg-pink-500' : 'bg-white/15',
].join(' ')}>
<div className={[
'absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform',
checked ? 'translate-x-5' : 'translate-x-0.5',
].join(' ')}/>
</div>
</button>);
}
function StatusDot({ ok }) {
return <span className={`inline-block w-1.5 h-1.5 rounded-full ${ok ? 'bg-green-400' : 'bg-white/20'}`}/>;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function PersonaSettingsPanel({ project, backendUrl, apiKey, onClose, onSaved }) {
const pa = project.persona_agent || {};
const pap = project.persona_appearance || {};
const ag = project.agentic || {};
const isSpicy = readNsfwMode();
// Avatar model capabilities — purely informational, never blocks existing flows
const { capabilities: avatarCaps } = useAvatarCapabilities(backendUrl, apiKey);
// --- View mode: "sheet" (default character sheet) or "inventory" ---
const [viewMode, setViewMode] = useState('sheet');
// --- Persona identity state ---
const [name, setName] = useState(pa.label || project.name || '');
const [role, setRole] = useState(pa.role || project.description || '');
const [systemPrompt, setSystemPrompt] = useState(pa.system_prompt || project.instructions || '');
const [tone, setTone] = useState(pa.response_style?.tone || 'warm');
const [stylePreset, setStylePreset] = useState(pap.style_preset || 'Executive');
// --- Agentic state ---
const [goal, setGoal] = useState(ag.goal || '');
const [capabilities, setCapabilities] = useState(ag.capabilities || []);
const [profile, setProfile] = useState(ag.execution_profile || 'balanced');
const [askFirst, setAskFirst] = useState(ag.ask_before_acting !== false);
const [toolIds, setToolIds] = useState(ag.tool_ids || []);
const [agentIds, setAgentIds] = useState(ag.a2a_agent_ids || []);
const [toolSource, setToolSource] = useState(ag.tool_source || 'all');
// --- Catalog data ---
const [catalogTools, setCatalogTools] = useState([]);
const [catalogAgents, setCatalogAgents] = useState([]);
const [catalogServers, setCatalogServers] = useState([]);
const [catalogLoading, setCatalogLoading] = useState(true);
// --- Auto-pin & reconcile tools when catalog loads ---
// 1. If toolIds is empty: derive pins from tool_details or keyword matching.
// 2. If toolIds is populated but some IDs don't exist in the catalog:
// reconcile by matching tool names so checkboxes render correctly.
const autoPopulated = useRef(false);
useEffect(() => {
if (autoPopulated.current || catalogLoading || catalogTools.length === 0)
return;
autoPopulated.current = true;
const enabled = catalogTools.filter((t) => t.enabled !== false);
const catalogIdSet = new Set(enabled.map((t) => t.id));
// -- Reconcile existing pinned IDs that may not match catalog --
if (toolIds.length > 0) {
const allMatch = toolIds.every((id) => catalogIdSet.has(id));
if (allMatch)
return; // everything already matches, nothing to do
// Build lookup maps for fuzzy matching: by name and by suffix
const catalogByName = new Map(); // lowercase name → catalog id
const catalogBySuffix = new Map(); // last segment → catalog id
for (const t of enabled) {
catalogByName.set(t.name.toLowerCase(), t.id);
// e.g. "mcp-news__get-headlines" → "get-headlines"
const parts = t.id.split(/__|\/|:/);
const suffix = parts[parts.length - 1];
if (suffix && !catalogBySuffix.has(suffix))
catalogBySuffix.set(suffix, t.id);
}
// Also index tool_details by id for name lookups
const detailsMap = new Map(); // old id → name
const details = ag.tool_details;
if (Array.isArray(details)) {
for (const d of details) {
if (d.id && d.name)
detailsMap.set(d.id, d.name);
}
}
const reconciled = [];
const seen = new Set();
for (const oldId of toolIds) {
let resolved;
if (catalogIdSet.has(oldId)) {
resolved = oldId;
}
else {
// Try matching by tool name from tool_details
const name = detailsMap.get(oldId);
if (name)
resolved = catalogByName.get(name.toLowerCase());
// Try matching by suffix (last segment of the id)
if (!resolved) {
const parts = oldId.split(/__|\/|:/);
const suffix = parts[parts.length - 1];
if (suffix)
resolved = catalogBySuffix.get(suffix);
}
// Try direct name match on the old id itself
if (!resolved)
resolved = catalogByName.get(oldId.toLowerCase());
}
if (resolved && !seen.has(resolved)) {
reconciled.push(resolved);
seen.add(resolved);
}
}
if (reconciled.length > 0 && (reconciled.length !== toolIds.length || reconciled.some((id, i) => id !== toolIds[i]))) {
setToolIds(reconciled);
}
return;
}
// -- No pinned tools yet: auto-populate --
const ids = [];
// Strategy 1: use tool_details from the agentic data (set by .hpersona import)
const details = ag.tool_details;
if (Array.isArray(details) && details.length > 0) {
// Match by id first, then fall back to name
const catalogByName = new Map();
for (const t of enabled)
catalogByName.set(t.name.toLowerCase(), t.id);
for (const d of details) {
const tid = d.id || d.name || '';
let resolved;
if (tid && catalogIdSet.has(tid)) {
resolved = tid;
}
else if (d.name) {
resolved = catalogByName.get(d.name.toLowerCase());
}
else if (tid) {
resolved = catalogByName.get(tid.toLowerCase());
}
if (resolved && !ids.includes(resolved))
ids.push(resolved);
}
}
// Strategy 2: match role / system_prompt keywords against tool name prefixes
if (ids.length === 0) {
const haystack = [role, systemPrompt, goal].join(' ').toLowerCase();
// Build keyword→prefix map for profession-based matching
const KEYWORD_PREFIXES = [
[['news', 'journalist', 'reporter', 'headlines'], 'news-'],
[['teams', 'meeting', 'conference', 'calendar'], 'teams-'],
[['email', 'mail', 'gmail', 'outlook'], 'hp-email'],
[['web', 'search', 'research', 'browse'], 'hp-web'],
[['brief', 'digest', 'executive', 'summary'], 'hp-brief'],
[['decision', 'risk', 'options', 'strategy'], 'hp-decision'],
[['inventory', 'photos', 'images', 'files', 'assets'], 'hp-inventory'],
];
const matchedPrefixes = new Set();
for (const [kws, prefix] of KEYWORD_PREFIXES) {
if (kws.some((kw) => haystack.includes(kw)))
matchedPrefixes.add(prefix);
}
if (matchedPrefixes.size > 0) {
for (const t of enabled) {
for (const prefix of matchedPrefixes) {
if (t.id.startsWith(prefix) || t.name.toLowerCase().startsWith(prefix)) {
if (!ids.includes(t.id))
ids.push(t.id);
}
}
}
}
}
if (ids.length > 0)
setToolIds(ids);
}, [catalogLoading, catalogTools]); // eslint-disable-line react-hooks/exhaustive-deps
// Avatar state (sets is stateful so individual image deletions trigger re-render)
// For imported personas: sets may be empty but selected_filename exists on disk.
// Synthesize a fallback set so the portrait renders immediately.
const initialSets = (() => {
const raw = Array.isArray(pap.sets) ? pap.sets : [];
if (raw.length > 0)
return raw;
const thumb = pap.selected_thumb_filename;
const full = pap.selected_filename;
const url = fileUrl(backendUrl, thumb || full);
if (!url)
return [];
return [
{
set_id: 'set_imported_001',
images: [{ id: 'pimg_imported_001', url, set_id: 'set_imported_001' }],
},
];
})();
const [sets, setSets] = useState(initialSets);
const allImages = sets.flatMap((s) => (s.images || []).map((img) => ({ ...img, set_id: s.set_id })));
const [selectedImage, setSelectedImage] = useState(pap.selected
|| (initialSets.length > 0
? { set_id: initialSets[0].set_id, image_id: initialSets[0].images[0].id }
: null));
// Outfit / wardrobe state
const [outfits, setOutfits] = useState(pap.outfits || []);
const [generatingOutfit, setGeneratingOutfit] = useState(false);
const [outfitGenError, setOutfitGenError] = useState(null);
const [selectedOutfitPreset, setSelectedOutfitPreset] = useState('');
const [customOutfitPrompt, setCustomOutfitPrompt] = useState('');
const [customOutfitLabel, setCustomOutfitLabel] = useState('');
// Generation mode: 'standard' (default text-to-image) or 'identity' (face-preserving)
const [generationMode, setGenerationModeRaw] = useState(pap.avatar_settings?.generation_mode || 'standard');
const setGenerationMode = (mode) => {
setGenerationModeRaw(mode);
// Persist into avatar_settings so it survives save
if (avatarSettingsLocal) {
setAvatarSettingsLocal({ ...avatarSettingsLocal, generation_mode: mode });
}
markDirty();
};
// Avatar settings (stored for reproducibility)
const avatarSettings = pap.avatar_settings || null;
// Documents
const [documents, setDocuments] = useState(project.files || []);
// Shared API (publish as model)
const sa = project.shared_api || {};
const [sharedEnabled, setSharedEnabled] = useState(sa.enabled ?? false);
const [sharedAlias, setSharedAlias] = useState(sa.alias ?? '');
const [featuredSlot, setFeaturedSlot] = useState(sa.featured_slot ?? null);
// UI state
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [lightbox, setLightbox] = useState(null);
const [showGallery, setShowGallery] = useState(false);
const [showTools, setShowTools] = useState(false);
const [showAgents, setShowAgents] = useState(false);
const [showWardrobe, setShowWardrobe] = useState(false);
const [showAvatarSettings, setShowAvatarSettings] = useState(false);
const [showChangePhoto, setShowChangePhoto] = useState(false);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [generatingPhoto, setGeneratingPhoto] = useState(false);
const [changePhotoError, setChangePhotoError] = useState(null);
const [avatarSettingsLocal, setAvatarSettingsLocal] = useState(avatarSettings ?? null);
const [showEnableOutfits, setShowEnableOutfits] = useState(false);
const [enableOutfitCharDesc, setEnableOutfitCharDesc] = useState('');
// Class info
const personaClass = pa.persona_class || pa.category || 'custom';
const blueprint = PERSONA_BLUEPRINTS.find((bp) => bp.id === personaClass);
// Total image count across base portraits + all outfits (for LV badge)
const totalImageCount = allImages.length + outfits.reduce((n, o) => n + o.images.length, 0);
// Find selected image URL — must search base portraits AND outfit images.
// Resolve relative backend paths (e.g. /comfy/view/...) to full URLs.
const selectedUrl = (() => {
let raw = null;
if (selectedImage) {
// Check base portraits
for (const img of allImages) {
if (img.id === selectedImage.image_id && img.set_id === selectedImage.set_id) {
raw = img.url;
break;
}
}
// Check outfit images
if (!raw) {
for (const outfit of outfits) {
for (const img of outfit.images) {
if (img.id === selectedImage.image_id && img.set_id === selectedImage.set_id) {
raw = img.url;
break;
}
}
if (raw)
break;
}
}
}
if (raw)
return resolveImgUrl(raw, backendUrl);
// Fallback: first image in gallery, or resolve from imported filename fields
const fallback = allImages[0]?.url;
if (fallback)
return resolveImgUrl(fallback, backendUrl);
return fileUrl(backendUrl, pap.selected_thumb_filename)
|| fileUrl(backendUrl, pap.selected_filename);
})();
// --- RPG stat bars derived from persona config ---
const toneValues = {
warm: 70,
professional: 85,
playful: 50,
assertive: 90,
flirty: 40,
};
const styleValues = {
Executive: 90,
Elegant: 80,
Romantic: 60,
Casual: 40,
Seductive: 55,
Lingerie: 35,
'Pin-Up': 50,
Fantasy: 45,
};
// Track dirtiness
const markDirty = () => {
if (!dirty)
setDirty(true);
};
// --- Fetch catalog ---
useEffect(() => {
const headers = {};
if (apiKey)
headers['x-api-key'] = apiKey;
try {
if (typeof window !== 'undefined') {
const tok = window.localStorage.getItem('homepilot_auth_token') || '';
if (tok)
headers['authorization'] = `Bearer ${tok}`;
}
}
catch { /* ignore */ }
fetch(`${backendUrl}/v1/agentic/catalog`, { headers, credentials: 'include' })
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data) {
setCatalogServers(Array.isArray(data.servers)
? data.servers.map((s) => ({
id: String(s.id || s.name),
name: String(s.name || s.id),
description: s.description,
enabled: s.enabled !== false,
tool_ids: Array.isArray(s.tool_ids)
? s.tool_ids
: Array.isArray(s.associated_tools)
? s.associated_tools
: [],
}))
: []);
setCatalogTools(Array.isArray(data.tools)
? data.tools.map((t) => ({
id: t.id || t.name,
name: t.name,
description: t.description,
enabled: t.enabled !== false,
}))
: []);
setCatalogAgents(Array.isArray(data.a2a_agents)
? data.a2a_agents.map((a) => ({
id: a.id || a.name,
name: a.name,
description: a.description,
enabled: a.enabled !== false,
}))
: []);
}
})
.catch(() => { })
.finally(() => setCatalogLoading(false));
}, [backendUrl, apiKey]);
// --- Derived: effective tool counts ---
const enabledCatalogTools = catalogTools.filter((t) => t.enabled !== false);
const serverToolCount = (() => {
if (!toolSource.startsWith('server:'))
return 0;
const sid = toolSource.replace('server:', '');
const s = catalogServers.find((x) => x.id === sid);
return s?.tool_ids?.length || 0;
})();
const effectiveToolCount = (() => {
if (toolSource === 'none')
return 0;
if (toolSource === 'all')
return enabledCatalogTools.length;
if (toolSource.startsWith('server:'))
return serverToolCount;
return 0;
})();
const visibleTools = (() => {
if (toolSource === 'none')
return [];
if (toolSource === 'all')
return enabledCatalogTools;
if (toolSource.startsWith('server:')) {
const sid = toolSource.replace('server:', '');
const s = catalogServers.find((x) => x.id === sid);
if (!s?.tool_ids?.length)
return [];
const ids = new Set(s.tool_ids);
return enabledCatalogTools.filter((t) => ids.has(t.id));
}
return [];
})();
// --- Toggle helpers ---
const toggleCap = (id) => {
setCapabilities((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
markDirty();
};
const toggleTool = (id) => {
setToolIds((prev) => (prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]));
markDirty();
};
const toggleAgent = (id) => {
setAgentIds((prev) => (prev.includes(id) ? prev.filter((a) => a !== id) : [...prev, id]));
markDirty();
};
// --- Upload a new photo ---
const handleUploadPhoto = useCallback(async (file) => {
setUploadingPhoto(true);
setChangePhotoError(null);
try {
const formData = new FormData();
formData.append('file', file);
const headers = {};
if (apiKey)
headers['x-api-key'] = apiKey;
const uploadRes = await fetch(`${backendUrl}/upload`, {
method: 'POST',
headers,
body: formData,
});
if (!uploadRes.ok)
throw new Error(`Upload failed: ${uploadRes.status}`);
const { url } = await uploadRes.json();
// Extract the bare filename from the upload URL (e.g. "abc123.png")
// The upload endpoint returns /files/<uuid>.<ext>
const uploadedFilename = url.split('/files/').pop()?.split('?')[0];
// Commit the uploaded file as the project's durable avatar
const commitResult = await commitPersonaAvatar({
backendUrl,
apiKey,
projectId: project.id,
sourceFilename: uploadedFilename,
});
// Use the committed file URL for display
const committedProject = commitResult.project || {};
const committedPap = committedProject.persona_appearance || {};
const committedRel = committedPap.selected_thumb_filename || committedPap.selected_filename;
const _tok = localStorage.getItem('homepilot_auth_token') || '';
const displayUrl = committedRel
? `${backendUrl}/files/${String(committedRel).replace(/^\/+/, '')}?v=${Date.now()}${_tok ? `&token=${encodeURIComponent(_tok)}` : ''}`
: url;
// Add to gallery sets and select
const imgId = nextImageId();
const setId = `set_upload_${Date.now()}`;
const newImage = {
id: imgId,
url: displayUrl,
created_at: new Date().toISOString(),
set_id: setId,
};
setSets((prev) => [...prev, { set_id: setId, images: [newImage] }]);
setSelectedImage({ set_id: setId, image_id: imgId });
setShowChangePhoto(false);
markDirty();
}
catch (err) {
setChangePhotoError(err?.message || 'Upload failed');
}
finally {
setUploadingPhoto(false);
}
}, [backendUrl, apiKey, project.id]);
// --- Generate a new portrait photo ---
const handleGenerateNewPhoto = useCallback(async () => {
const charPrompt = avatarSettingsLocal?.character_prompt || `${name}, portrait`;
setGeneratingPhoto(true);
setChangePhotoError(null);
try {
const out = await generatePersonaImages({
backendUrl,
apiKey,
prompt: charPrompt,
imgModel: avatarSettingsLocal?.img_model,
imgBatchSize: 4,
imgAspectRatio: avatarSettingsLocal?.aspect_ratio ?? '2:3',
imgPreset: avatarSettingsLocal?.img_preset ?? 'med',
promptRefinement: true,
nsfwMode: avatarSettingsLocal?.nsfw_mode ?? false,
generationMode,
referenceImageUrl: generationMode === 'identity' ? selectedUrl ?? undefined : undefined,
});
if (out.urls.length === 0) {
setChangePhotoError('No images returned. Check your image backend (ComfyUI).');
return;
}
const setId = `set_gen_${Date.now()}`;
const newImages = out.urls.map((url, i) => ({
id: nextImageId(),
url,
created_at: new Date().toISOString(),
set_id: setId,
seed: out.seeds?.[i],
}));
// Show images immediately (comfy URLs) while commit runs
setSets((prev) => [...prev, { set_id: setId, images: newImages }]);
setSelectedImage({ set_id: setId, image_id: newImages[0].id });
// Commit-on-generate: persist to durable /files/ storage immediately
// so inventory + MCP + chat can resolve them without waiting for Save.
try {
const commitRes = await commitGeneratedImages({
backendUrl,
apiKey,
projectId: project.id,
kind: 'set',
images: newImages.map(img => ({ url: img.url, id: img.id, set_id: img.set_id })),
});
// Replace local URLs with durable /files/ URLs
if (commitRes.committed?.length) {
const urlMap = new Map(commitRes.committed.map(c => [c.id, c.url]));
setSets((prev) => prev.map(s => s.set_id === setId
? { ...s, images: s.images.map(img => ({ ...img, url: urlMap.get(img.id) || img.url })) }
: s));
}
}
catch {
// Non-fatal — images still display from ComfyUI URLs, commit on Save
}
// Also commit the selected avatar so thumbnail is durable
try {
await commitPersonaAvatar({
backendUrl,
apiKey,
projectId: project.id,
auto: true,
});
}
catch {
// Non-fatal
}
// Update avatar_settings with the generation params
const newSettings = {
character_prompt: charPrompt,
outfit_prompt: avatarSettingsLocal?.outfit_prompt || 'default outfit',
full_prompt: out.final_prompt ?? charPrompt,
style_preset: stylePreset,
gender: pap.gender ?? 'female',
img_model: out.model ?? avatarSettingsLocal?.img_model ?? 'dreamshaper_8.safetensors',
img_preset: avatarSettingsLocal?.img_preset ?? 'med',
aspect_ratio: avatarSettingsLocal?.aspect_ratio ?? '2:3',
nsfw_mode: avatarSettingsLocal?.nsfw_mode ?? false,
generation_mode: generationMode,
};
setAvatarSettingsLocal(newSettings);
setShowChangePhoto(false);
markDirty();
}
catch (err) {
setChangePhotoError(err?.message || 'Generation failed');
}
finally {
setGeneratingPhoto(false);
}
}, [avatarSettingsLocal, backendUrl, apiKey, name, stylePreset, pap.gender, generationMode, selectedUrl, project.id]);
// --- Enable outfit variations for imported personas ---
const handleEnableOutfitVariations = useCallback((charDescription) => {
if (!charDescription.trim())
return;
const style = stylePreset || 'elegant';
const newSettings = {
character_prompt: charDescription.trim(),
outfit_prompt: `${style} outfit variation`,
full_prompt: `${charDescription.trim()}, ${style} outfit, elegant lighting, realistic, sharp focus`,
style_preset: style,
gender: pap.gender ?? 'female',
img_model: pap.img_model ?? 'dreamshaper_8.safetensors',
img_preset: pap.img_preset ?? 'med',
aspect_ratio: pap.aspect_ratio ?? '2:3',
nsfw_mode: !!pap.nsfwMode,
};
setAvatarSettingsLocal(newSettings);
setShowEnableOutfits(false);
markDirty();
}, [stylePreset, pap]);
// --- Generate outfit variation ---
// Uses avatarSettingsLocal which includes both original DB settings
// and user-enabled settings (for imported personas that set it inline).
const effectiveAvatarSettings = avatarSettingsLocal ?? avatarSettings ?? null;
const handleGenerateOutfit = useCallback(async () => {
if (!effectiveAvatarSettings?.character_prompt) {
setOutfitGenError('No character description set. Enable outfit variations first.');
return;
}
const outfitPrompt = customOutfitPrompt.trim()
|| OUTFIT_PRESETS.find((p) => p.id === selectedOutfitPreset)?.prompt
|| '';
if (!outfitPrompt) {
setOutfitGenError('Select an outfit preset or enter a custom outfit description.');
return;
}
const label = customOutfitLabel.trim()
|| OUTFIT_PRESETS.find((p) => p.id === selectedOutfitPreset)?.label
|| 'Custom Outfit';
setGeneratingOutfit(true);
setOutfitGenError(null);
try {
const out = await generateOutfitImages({
backendUrl,
apiKey,
characterPrompt: effectiveAvatarSettings.character_prompt,
outfitPrompt,
imgModel: effectiveAvatarSettings.img_model,
imgPreset: effectiveAvatarSettings.img_preset,
imgAspectRatio: effectiveAvatarSettings.aspect_ratio,
nsfwMode: effectiveAvatarSettings.nsfw_mode,
generationMode,
referenceImageUrl: generationMode === 'identity' ? selectedUrl ?? undefined : undefined,
});
if (out.urls.length === 0) {
setOutfitGenError('No images returned. Check your image backend.');
return;
}
const created_at = new Date().toISOString();
const outfitId = `outfit_${Date.now()}`;
const images = out.urls.map((url, i) => ({
id: nextImageId(),
url,
created_at,
set_id: outfitId,
seed: out.seeds?.[i],
}));
const genSettings = {
...effectiveAvatarSettings,
outfit_prompt: outfitPrompt,
full_prompt: out.final_prompt ?? `${effectiveAvatarSettings.character_prompt}, ${outfitPrompt}`,
};
const newOutfit = {
id: outfitId,
label,
outfit_prompt: outfitPrompt,
images,
selected_image_id: images[0]?.id,
generation_settings: genSettings,
created_at,
};
// Show immediately in local state (comfy URLs)
// Merge into existing outfit with the same label (avoid duplicates
// like two separate "Lingerie" entries — instead combine images).
setOutfits((prev) => {
const existingIdx = prev.findIndex((o) => o.label.toLowerCase() === newOutfit.label.toLowerCase());
if (existingIdx >= 0) {
const updated = [...prev];
const existing = updated[existingIdx];
updated[existingIdx] = {
...existing,
images: [...existing.images, ...newOutfit.images],
};
return updated;
}
return [...prev, newOutfit];
});
// Commit-on-generate: persist to durable /files/ storage immediately
// so inventory + MCP + chat can resolve them without waiting for Save.
try {
const commitRes = await commitGeneratedImages({
backendUrl,
apiKey,
projectId: project.id,
kind: 'outfit',
images: images.map(img => ({ url: img.url, id: img.id, set_id: img.set_id })),
outfitId,
outfitLabel: label,
outfitPrompt,
generationSettings: genSettings,
});
// Replace local URLs with durable /files/ URLs
if (commitRes.committed?.length) {
const urlMap = new Map(commitRes.committed.map(c => [c.id, c.url]));
setOutfits((prev) => prev.map(o => {
if (o.id === outfitId || o.label.toLowerCase() === label.toLowerCase()) {
return {
...o,
images: o.images.map(img => ({ ...img, url: urlMap.get(img.id) || img.url })),
};
}
return o;
}));
}
}
catch {
// Non-fatal — images still display from ComfyUI URLs, commit on Save
}
setCustomOutfitPrompt('');
setCustomOutfitLabel('');
setSelectedOutfitPreset('');
markDirty();
}
catch (err) {
setOutfitGenError(err?.message || 'Outfit generation failed.');
}
finally {
setGeneratingOutfit(false);
}
}, [effectiveAvatarSettings, customOutfitPrompt, customOutfitLabel, selectedOutfitPreset, backendUrl, apiKey, generationMode, selectedUrl, project.id]);
// --- Delete outfit ---
const handleDeleteOutfit = (outfitId) => {
setOutfits((prev) => prev.filter((o) => o.id !== outfitId));
markDirty();
};
// --- Use outfit image as main avatar ---
const handleUseOutfitAsAvatar = (outfitImage) => {
setSelectedImage({ set_id: outfitImage.set_id, image_id: outfitImage.id });
markDirty();
};
// --- Save ---
const handleSave = useCallback(async () => {
setSaving(true);
try {
const headers = { 'Content-Type': 'application/json' };
if (apiKey)
headers['x-api-key'] = apiKey;
try {
if (typeof window !== 'undefined') {
const tok = window.localStorage.getItem('homepilot_auth_token') || '';
if (tok)
headers['authorization'] = `Bearer ${tok}`;
}
}
catch { /* ignore */ }
const prevToolDetails = {};
for (const d of ag.tool_details || []) {
if (d && typeof d === 'object' && d.id)
prevToolDetails[d.id] = d;
}
const prevAgentDetails = {};
for (const d of ag.agent_details || []) {
if (d && typeof d === 'object' && d.id)
prevAgentDetails[d.id] = d;
}
const toolDetails = toolIds.map((tid) => {
const t = catalogTools.find((x) => x.id === tid);
const prev = prevToolDetails[tid];
return {
id: tid,
name: t?.name || prev?.name || tid,
description: t?.description || prev?.description || '',
};
});
const agentDetailsList = agentIds.map((aid) => {
const a = catalogAgents.find((x) => x.id === aid);
const prev = prevAgentDetails[aid];
return {
id: aid,
name: a?.name || prev?.name || aid,
description: a?.description || prev?.description || '',
};
});
const body = {
name,
description: role,
instructions: systemPrompt,
project_type: 'persona',
persona_agent: {
...pa,
label: name,
role,
system_prompt: systemPrompt,
response_style: { ...(pa.response_style || {}), tone },
},
persona_appearance: {
...pap,
sets,
style_preset: stylePreset,
selected: selectedImage,
outfits,
...(avatarSettingsLocal ? { avatar_settings: avatarSettingsLocal } : {}),
},
agentic: {
goal,
capabilities,
tool_ids: toolIds,
a2a_agent_ids: agentIds,
tool_details: toolDetails,
agent_details: agentDetailsList,
tool_source: toolSource,
ask_before_acting: askFirst,
execution_profile: profile,
},
shared_api: {
enabled: sharedEnabled,
alias: sharedAlias,
featured_slot: featuredSlot,
},
};
const res = await fetch(`${backendUrl}/projects/${project.id}`, {
method: 'PUT',
headers,
credentials: 'include',
body: JSON.stringify(body),
});
if (res.ok) {
const data = await res.json();
// Auto-commit the currently selected avatar so the durable
// selected_filename / selected_thumb_filename stay in sync.
// This ensures the mini thumbnail in the projects list updates.
let finalProject = data.project;
try {
const commitRes = await commitPersonaAvatar({
backendUrl,
apiKey,
projectId: project.id,
auto: true,
});
if (commitRes.project) {
finalProject = commitRes.project;
}
}
catch {
// Non-fatal — avatar may already be committed or ComfyUI offline
}
setDirty(false);
onSaved(finalProject);
}
else {
alert('Failed to save persona settings');
}
}
catch {
alert('Failed to save persona settings');
}
finally {
setSaving(false);
}
}, [
name, role, systemPrompt, tone, stylePreset, selectedImage, sets, outfits,
goal, capabilities, profile, askFirst, toolIds, agentIds, toolSource,
pa, pap, ag.tool_details, ag.agent_details, backendUrl, apiKey,
project.id, onSaved, catalogTools, catalogAgents, avatarSettingsLocal,
sharedEnabled, sharedAlias, featuredSlot,
]);
// --- Document delete ---
const handleDeleteDoc = async (docName) => {
if (!confirm(`Delete document "${docName}"?`))
return;
try {
const headers = {};
if (apiKey)
headers['x-api-key'] = apiKey;
const res = await fetch(`${backendUrl}/projects/${project.id}/documents/${encodeURIComponent(docName)}`, { method: 'DELETE', headers });
if (res.ok)
setDocuments((prev) => prev.filter((d) => d.name !== docName));
}
catch {
/* silent */
}
};
// --- Available outfit presets based on NSFW mode ---
const availableOutfitPresets = OUTFIT_PRESETS.filter((p) => p.category === 'sfw' || isSpicy);
return (<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-3xl bg-[#0f0f1e] rounded-2xl border border-white/10 shadow-2xl flex flex-col max-h-[90vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
{/* -- Header -- */}
<div className="relative px-6 py-4 border-b border-white/10 bg-gradient-to-r from-pink-500/10 via-purple-500/10 to-transparent">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-pink-500/30 to-purple-500/30 border border-pink-500/30 flex items-center justify-center">
<User size={18} className="text-pink-400"/>
</div>
<div>
<h2 className="text-lg font-bold text-white tracking-tight flex items-center gap-2">
Persona Profile
{blueprint && blueprint.id !== 'custom' && (<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-white/10 border border-white/10 text-white/60">
{blueprint.icon} {blueprint.label}
</span>)}
</h2>
<p className="text-[11px] text-white/40 uppercase tracking-widest">
{viewMode === 'inventory' ? 'Inventory' : 'Character Sheet'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={async () => {
if (viewMode === 'inventory') {
setViewMode('sheet');
}
else {
// Auto-save before switching to inventory so the backend
// has fresh persona_appearance data (including newly
// generated outfits) for the inventory API to read.
if (dirty) {
await handleSave();
}
setViewMode('inventory');
}
}} className={[
'flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all border',
viewMode === 'inventory'
? 'bg-amber-500/20 border-amber-500/30 text-amber-400'
: 'bg-white/5 border-white/10 text-white/50 hover:text-white hover:bg-white/10',
].join(' ')}>
<Package size={14}/>
Inventory
</button>
<button onClick={onClose} className="p-2 text-white/50 hover:text-white hover:bg-white/10 rounded-lg transition-colors">
<X size={20}/>
</button>
</div>
</div>
</div>
{/* -- Content: swap between sheet and inventory -- */}
{viewMode === 'inventory' ? (<InventoryView projectId={project.id} backendUrl={backendUrl} apiKey={apiKey} onBack={() => setViewMode('sheet')} activeSelection={selectedImage} draftAppearance={{ sets, outfits, selected: selectedImage }} onSetActiveLook={(sel) => {
// Wardrobe-style selection: update selectedImage state, mark dirty.
// Stay on inventory page — no auto navigation back.
setSelectedImage({ set_id: sel.set_id, image_id: sel.image_id });
markDirty();
}}/>) : (<div className="flex-1 overflow-y-auto custom-scrollbar">
{/* -- Top: Avatar + Stats -- */}
<div className="p-6 border-b border-white/5">
<div className="flex gap-6">
{/* Avatar frame */}
<div className="shrink-0">
<div className="relative group">
{selectedUrl ? (<img src={selectedUrl} alt={name} onClick={() => setLightbox(selectedUrl)} className="w-40 h-52 object-cover object-top rounded-xl border-2 border-pink-500/30 shadow-lg shadow-pink-500/10 cursor-zoom-in"/>) : (<div className="w-40 h-52 bg-white/5 border-2 border-dashed border-white/20 rounded-xl flex items-center justify-center cursor-pointer hover:border-pink-500/40 transition-colors" onClick={() => setShowChangePhoto(true)}>
<div className="text-center">
<Camera size={32} className="text-white/20 mx-auto mb-1"/>
<span className="text-[10px] text-white/30">Add photo</span>
</div>
</div>)}
{/* Change Photo button (hover) */}
<button type="button" onClick={() => setShowChangePhoto(!showChangePhoto)} className="absolute top-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 rounded-lg border border-white/20 transition-all opacity-0 group-hover:opacity-100" title="Change photo">
<RefreshCw size={12} className="text-white"/>
</button>
{allImages.length > 1 && (<button type="button" onClick={() => setShowGallery(!showGallery)} className="absolute bottom-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 rounded-lg border border-white/20 transition-all opacity-0 group-hover:opacity-100">
<Camera size={14} className="text-white"/>
</button>)}
<div className="absolute -top-2 -left-2 bg-gradient-to-br from-pink-500 to-purple-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full shadow-lg">
LV {totalImageCount}
</div>
</div>
{/* Change Photo panel */}
{showChangePhoto && (<div className="mt-2 w-40 space-y-2">
{/* Upload option */}
<label className="flex items-center gap-2 px-3 py-2 bg-white/[0.06] hover:bg-white/10 border border-white/10 rounded-lg cursor-pointer transition-colors">
<Upload size={14} className="text-pink-400 shrink-0"/>
<span className="text-[11px] text-white/70">
{uploadingPhoto ? 'Uploading...' : 'Upload image'}
</span>
<input type="file" accept="image/png,image/jpeg,image/webp" className="hidden" disabled={uploadingPhoto} onChange={(e) => {
const f = e.target.files?.[0];
if (f)
handleUploadPhoto(f);
e.target.value = '';
}}/>
</label>
{/* Generate option */}
<button type="button" disabled={generatingPhoto} onClick={handleGenerateNewPhoto} className="w-full flex items-center gap-2 px-3 py-2 bg-white/[0.06] hover:bg-white/10 border border-white/10 rounded-lg transition-colors">
<Sparkles size={14} className="text-purple-400 shrink-0"/>
<span className="text-[11px] text-white/70">
{generatingPhoto ? 'Generating...' : 'Generate new (4)'}
</span>
{generatingPhoto && <Loader2 size={12} className="animate-spin text-white/40 ml-auto"/>}
</button>
{/* Generation mode toggle — Standard vs Same Person */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 px-2 py-1">
<span className="text-[10px] text-white/40 font-medium">Generation mode</span>
</div>
<div className="flex gap-1">
<button type="button" onClick={() => setGenerationMode('standard')} className={`flex-1 px-2 py-1.5 rounded-lg border text-[10px] font-medium transition-all ${generationMode === 'standard'
? 'bg-purple-500/15 border-purple-500/30 text-purple-300'
: 'bg-white/[0.03] border-white/10 text-white/40 hover:bg-white/[0.06]'}`}>
Standard
</button>
<button type="button" onClick={() => avatarCaps.canIdentityPortrait && setGenerationMode('identity')} disabled={!avatarCaps.canIdentityPortrait} title={avatarCaps.canIdentityPortrait
? 'Keeps the same face consistent across generations'
: 'Install Avatar Models (Add-ons) to enable'} className={`flex-1 px-2 py-1.5 rounded-lg border text-[10px] font-medium transition-all ${generationMode === 'identity'
? 'bg-emerald-500/15 border-emerald-500/30 text-emerald-300'
: avatarCaps.canIdentityPortrait
? 'bg-white/[0.03] border-white/10 text-white/40 hover:bg-white/[0.06]'
: 'bg-white/[0.02] border-white/5 text-white/15 cursor-not-allowed'}`}>
Same Person
</button>
</div>
{generationMode === 'identity' && (<div className="flex items-center gap-1 px-1.5">
<Check size={8} className="text-emerald-400 shrink-0"/>
<span className="text-[9px] text-emerald-300/60">Face preservation active</span>
</div>)}
{!avatarCaps.canIdentityPortrait && generationMode === 'standard' && (<div className="flex items-center gap-1 px-1.5">
<Sparkles size={8} className="text-white/15 shrink-0"/>
<span className="text-[9px] text-white/20">Install Avatar Models for same-person mode</span>
</div>)}
</div>
{changePhotoError && (<div className="text-[10px] text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-2 py-1.5">
{changePhotoError}
</div>)}
</div>)}
</div>
{/* Stats panel */}
<div className="flex-1 min-w-0 space-y-4">
<div>
<input value={name} onChange={(e) => {
setName(e.target.value);
markDirty();
}} className="bg-transparent text-xl font-bold text-white focus:outline-none focus:border-b focus:border-pink-500 w-full border-b border-transparent hover:border-white/20 transition-all pb-1" placeholder="Persona Name"/>
<input value={role} onChange={(e) => {
setRole(e.target.value);
markDirty();
}} className="bg-transparent text-sm text-pink-300/80 focus:outline-none w-full mt-1 border-b border-transparent hover:border-white/10 transition-all pb-1" placeholder="Role / Title"/>
</div>
<div className="space-y-2">
<StatBar label="Charisma" value={toneValues[tone] ?? 60} color="bg-pink-500"/>
<StatBar label="Elegance" value={styleValues[stylePreset] ?? 60} color="bg-purple-500"/>
<StatBar label="Confidence" value={tone === 'assertive' ? 95 : tone === 'professional' ? 80 : 65} color="bg-amber-500"/>
<StatBar label="Warmth" value={tone === 'warm' ? 90 : tone === 'flirty' ? 75 : tone === 'playful' ? 85 : 50} color="bg-rose-400"/>
</div>
<div className="flex flex-wrap gap-1.5">
<span className="text-[10px] px-2 py-0.5 rounded-full bg-pink-500/15 border border-pink-500/20 text-pink-300">
{stylePreset}
</span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 border border-purple-500/20 text-purple-300 capitalize">
{tone}
</span>
{pap.nsfwMode && (<span className="text-[10px] px-2 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/20 text-orange-300">
Spicy
</span>)}
<span className="text-[10px] px-2 py-0.5 rounded-full bg-white/10 border border-white/10 text-white/50">
{allImages.length} portrait{allImages.length !== 1 ? 's' : ''}
</span>
{outfits.length > 0 && (<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-500/15 border border-amber-500/20 text-amber-300">
{outfits.length} outfit{outfits.length !== 1 ? 's' : ''}
</span>)}
{capabilities.length > 0 && (<span className="text-[10px] px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/20 text-cyan-300">
{capabilities.length} skill{capabilities.length !== 1 ? 's' : ''}
</span>)}
</div>
</div>
</div>
</div>
{/* -- Portrait Gallery (expandable) — base portraits only -- */}
{showGallery && allImages.length > 0 && (<div className="p-6 border-b border-white/5 bg-white/[0.02]">
<SectionHeader icon={Camera} title="Portrait Gallery" badge={allImages.length} color="text-pink-400"/>
<div className="grid grid-cols-4 gap-2">
{allImages.map((img) => {
const isSel = selectedImage?.set_id === img.set_id && selectedImage?.image_id === img.id;
return (<div key={img.id} className="relative group/thumb">
<button type="button" onClick={() => {
setSelectedImage({ set_id: img.set_id, image_id: img.id });
markDirty();
}} className={`relative w-full overflow-hidden rounded-lg border-2 transition-all ${isSel
? 'border-pink-500 ring-2 ring-pink-500/30 scale-[1.02]'
: 'border-white/10 hover:border-white/30 hover:scale-[1.01]'}`}>
<img src={resolveImgUrl(img.url, backendUrl)} className="w-full h-28 object-cover object-top" alt="" loading="lazy"/>
{isSel && (<div className="absolute bottom-1 left-1 text-[8px] bg-pink-500 px-1.5 py-0.5 rounded-full font-bold shadow">
Active
</div>)}
</button>
{/* Delete — small bin icon, top-right, appears on hover */}
{!isSel && (<button type="button" onClick={(e) => {
e.stopPropagation();
setSets((prev) => prev.map((s) => ({
...s,
images: s.images.filter((i) => i.id !== img.id),
})).filter((s) => s.images.length > 0));
markDirty();
}} className="absolute top-1 right-1 p-1 bg-black/70 hover:bg-red-600/90 rounded-md border border-white/10 transition-all opacity-0 group-hover/thumb:opacity-100" title="Delete this portrait">
<Trash2 size={10} className="text-white/70 hover:text-white"/>
</button>)}
</div>);
})}
</div>
</div>)}
{/* -- Detail sections -- */}
<div className="p-6 space-y-8">
{/* --- Quest Objective --- */}
<section>
<SectionHeader icon={Star} title="Quest Objective" color="text-amber-400"/>
<textarea value={goal} onChange={(e) => {
setGoal(e.target.value);
markDirty();
}} placeholder="e.g. Help me plan my week, be my creative writing partner, roleplay as a mentor..." rows={2} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/30 transition-all resize-none"/>
<p className="text-[10px] text-white/30 mt-1.5 px-1">
Define what this persona should help you accomplish.
</p>
</section>
{/* --- Style & Tone --- */}
<section>
<SectionHeader icon={Palette} title="Style & Tone" color="text-pink-400"/>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-white/60 mb-2">Style</label>
<div className="flex flex-wrap gap-2">
{[...['Executive', 'Elegant', 'Romantic', 'Casual'], ...(isSpicy ? ['Seductive', 'Lingerie', 'Pin-Up', 'Fantasy'] : [])].map((s) => (<button key={s} type="button" onClick={() => {
setStylePreset(s);
markDirty();
}} className={`px-3 py-1.5 rounded-full border text-xs transition-all ${stylePreset === s
? 'bg-pink-500/20 border-pink-500/40 text-pink-300'
: 'bg-white/5 border-white/10 text-white/50 hover:bg-white/10'}`}>
{s}
</button>))}
</div>
</div>
<div>
<label className="block text-xs font-medium text-white/60 mb-2">Tone</label>
<div className="flex flex-wrap gap-2">
{['warm', 'professional', 'playful', 'assertive', ...(isSpicy ? ['flirty'] : [])].map((t) => (<button key={t} type="button" onClick={() => {
setTone(t);
markDirty();
}} className={`px-3 py-1.5 rounded-full border text-xs capitalize transition-all ${tone === t
? 'bg-purple-500/20 border-purple-500/40 text-purple-300'
: 'bg-white/5 border-white/10 text-white/50 hover:bg-white/10'}`}>
{t}
</button>))}
</div>
</div>
</div>
</section>
{/* --- Backstory & Personality --- */}
<section>
<SectionHeader icon={Heart} title="Backstory & Personality" color="text-rose-400"/>
<textarea value={systemPrompt} onChange={(e) => {
setSystemPrompt(e.target.value);
markDirty();
}} placeholder="Define this persona's personality, background, and how they should respond..." rows={4} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-white/30 focus:outline-none focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/30 transition-all resize-none"/>
</section>
{/* --- Wardrobe (Outfit Variations) --- */}
<section>
<SectionHeader icon={Shirt} title="Wardrobe (Outfits)" badge={outfits.length} color="text-amber-400"/>
{/* Existing outfits */}
{outfits.length > 0 && (<div className="space-y-3 mb-4">
{outfits.map((outfit) => (<div key={outfit.id} className="bg-white/[0.03] border border-white/10 rounded-xl p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{outfit.label}</span>
<button type="button" onClick={() => handleDeleteOutfit(outfit.id)} className="p-1 text-white/30 hover:text-red-400 rounded hover:bg-red-500/10 transition-all">
<Trash2 size={12}/>
</button>
</div>
<div className="grid grid-cols-4 gap-1.5">
{outfit.images.map((img) => {
const isActive = selectedImage?.set_id === img.set_id && selectedImage?.image_id === img.id;
return (<div key={img.id} className="relative group/oimg">
<button type="button" onClick={() => handleUseOutfitAsAvatar(img)} className={`relative w-full overflow-hidden rounded-lg border transition-all ${isActive
? 'border-amber-500 ring-1 ring-amber-500/30'
: 'border-white/10 hover:border-white/25'}`}>
<img src={resolveImgUrl(img.url, backendUrl)} className="w-full h-20 object-cover object-top" alt="" loading="lazy"/>
{isActive && (<div className="absolute bottom-0.5 left-0.5 text-[8px] bg-amber-500 px-1 py-0.5 rounded-full font-bold">
Active
</div>)}
</button>
{/* Delete single image — bin icon, top-right */}
{!isActive && (<button type="button" onClick={(e) => {
e.stopPropagation();
setOutfits((prev) => prev.map((o) => o.id === outfit.id
? { ...o, images: o.images.filter((i) => i.id !== img.id) }
: o).filter((o) => o.images.length > 0));
markDirty();
}} className="absolute top-0.5 right-0.5 p-0.5 bg-black/70 hover:bg-red-600/90 rounded border border-white/10 transition-all opacity-0 group-hover/oimg:opacity-100" title="Delete this image">
<Trash2 size={9} className="text-white/70 hover:text-white"/>
</button>)}
</div>);
})}
</div>
</div>))}
</div>)}
{/* Outfit generation mode selector */}
<div className="mb-3 space-y-2">
<div className="flex items-center gap-2 px-1">
<span className="text-[10px] text-white/50 font-medium">Outfit generation</span>
</div>
<div className="flex gap-1.5">
<button type="button" onClick={() => setGenerationMode('standard')} className={`flex-1 px-3 py-2 rounded-lg border text-[11px] font-medium transition-all ${generationMode === 'standard'
? 'bg-amber-500/15 border-amber-500/30 text-amber-300'
: 'bg-white/[0.03] border-white/10 text-white/40 hover:bg-white/[0.06]'}`}>
<div>Standard</div>
<div className="text-[9px] font-normal mt-0.5 opacity-60">Fast, flexible</div>
</button>
<button type="button" onClick={() => (avatarCaps.canOutfits || avatarCaps.canIdentityPortrait) && setGenerationMode('identity')} disabled={!avatarCaps.canOutfits && !avatarCaps.canIdentityPortrait} title={(avatarCaps.canOutfits || avatarCaps.canIdentityPortrait)
? 'Keeps the same face consistent across outfit variations'
: 'Install Avatar Models (Add-ons) to enable'} className={`flex-1 px-3 py-2 rounded-lg border text-[11px] font-medium transition-all ${generationMode === 'identity'
? 'bg-emerald-500/15 border-emerald-500/30 text-emerald-300'
: (avatarCaps.canOutfits || avatarCaps.canIdentityPortrait)
? 'bg-white/[0.03] border-white/10 text-white/40 hover:bg-white/[0.06]'
: 'bg-white/[0.02] border-white/5 text-white/15 cursor-not-allowed'}`}>
<div>Same Person</div>
<div className="text-[9px] font-normal mt-0.5 opacity-60">Face consistency</div>
</button>
</div>
{generationMode === 'identity' && avatarCaps.canOutfits && (<div className="flex items-center gap-1.5 px-2 py-1 bg-emerald-500/5 border border-emerald-500/10 rounded-lg">
<Check size={9} className="text-emerald-400 shrink-0"/>
<span className="text-[9px] text-emerald-300/60">Identity models ready — face preservation active for outfits</span>
</div>)}
{generationMode === 'identity' && avatarCaps.canIdentityPortrait && !avatarCaps.canOutfits && (<div className="flex items-center gap-1.5 px-2 py-1 bg-amber-500/5 border border-amber-500/10 rounded-lg">
<Sparkles size={9} className="text-amber-300/60 shrink-0"/>
<span className="text-[9px] text-amber-300/50">Basic identity models installed. Add PhotoMaker V2 or PuLID for best outfit results.</span>
</div>)}
{!avatarCaps.canOutfits && !avatarCaps.canIdentityPortrait && (<div className="flex items-center gap-1.5 px-2 py-1 bg-white/[0.02] border border-white/5 rounded-lg">
<Shirt size={9} className="text-white/15 shrink-0"/>
<span className="text-[9px] text-white/20">Install Avatar Models (Add-ons) to enable same-person mode</span>
</div>)}
</div>
{/* Generate new outfit */}
<button type="button" onClick={() => setShowWardrobe(!showWardrobe)} className="flex items-center gap-2 text-xs text-white/50 hover:text-white/80 transition-colors mb-2">
{showWardrobe ? <ChevronUp size={12}/> : <ChevronDown size={12}/>}
{showWardrobe ? 'Hide outfit creator' : 'Add new outfit variation'}
</button>
{showWardrobe && (<div className="space-y-3 bg-white/[0.02] border border-white/10 rounded-xl p-4">
{!effectiveAvatarSettings?.character_prompt ? (
/* Enable outfit variations — inline form */
<div className="space-y-3">
<div className="text-xs text-white/50">
Describe your character to enable outfit variations.
This description stays constant across all outfits.
</div>
{showEnableOutfits ? (<div className="space-y-2">
<textarea value={enableOutfitCharDesc} onChange={(e) => setEnableOutfitCharDesc(e.target.value)} rows={3} className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white placeholder-white/30 focus:outline-none focus:border-amber-500/50 resize-none" placeholder="e.g., young woman with long dark hair, green eyes, athletic build, elegant features..."/>
<div className="flex gap-2">
<button type="button" onClick={() => handleEnableOutfitVariations(enableOutfitCharDesc)} disabled={!enableOutfitCharDesc.trim()} className="flex-1 px-3 py-2 bg-amber-500/80 hover:bg-amber-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-semibold rounded-lg transition-all flex items-center justify-center gap-1.5">
<Check size={12}/>
Enable Outfits
</button>
<button type="button" onClick={() => { setShowEnableOutfits(false); setEnableOutfitCharDesc(''); }} className="px-3 py-2 bg-white/5 hover:bg-white/10 text-white/50 text-xs rounded-lg transition-colors">
Cancel
</button>
</div>
</div>) : (<button type="button" onClick={() => {
// Pre-fill with name + role if available
const hint = [name, role].filter(Boolean).join(', ');
setEnableOutfitCharDesc(hint ? `${hint}, portrait` : '');
setShowEnableOutfits(true);
}} className="w-full px-4 py-2.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/30 text-amber-300 text-xs font-semibold rounded-xl transition-all flex items-center justify-center gap-2">
<Sparkles size={14}/>
Set up outfit variations
</button>)}
</div>) : (<>
{/* Character prompt (read-only display) */}
<div>
<label className="block text-[10px] font-medium text-white/50 mb-1 flex items-center gap-1">
<Copy size={10}/>
Stored character description (constant across outfits)
</label>
<div className="text-[11px] text-white/40 bg-white/5 border border-white/5 rounded-lg p-2 max-h-16 overflow-y-auto">
{effectiveAvatarSettings.character_prompt}
</div>
</div>
{/* Outfit presets */}
<div>
<label className="block text-xs font-medium text-white/60 mb-2">Outfit preset</label>
<div className="flex flex-wrap gap-1.5">
{availableOutfitPresets.map((preset) => (<button key={preset.id} type="button" onClick={() => {
setSelectedOutfitPreset(preset.id);
setCustomOutfitPrompt('');
setCustomOutfitLabel(preset.label);
}} className={`px-2.5 py-1 rounded-full border text-[11px] transition-all ${selectedOutfitPreset === preset.id
? 'bg-amber-500/20 border-amber-500/40 text-amber-300'
: 'bg-white/5 border-white/10 text-white/50 hover:bg-white/10'}`}>
{preset.label}
</button>))}
</div>
</div>
{/* Custom outfit */}
<div>
<label className="block text-xs font-medium text-white/60 mb-1">
Or custom outfit description
</label>
<input type="text" value={customOutfitPrompt} onChange={(e) => {
setCustomOutfitPrompt(e.target.value);
if (e.target.value.trim())
setSelectedOutfitPreset('');
}} className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white placeholder-white/30 focus:outline-none focus:border-amber-500/50" placeholder="e.g., medieval armor, enchanted forest setting..."/>
</div>
{/* Label */}
<div>
<label className="block text-xs font-medium text-white/60 mb-1">Outfit label</label>
<input type="text" value={customOutfitLabel} onChange={(e) => setCustomOutfitLabel(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white placeholder-white/30 focus:outline-none focus:border-amber-500/50" placeholder="e.g., Medieval Knight"/>
</div>
<button type="button" onClick={handleGenerateOutfit} disabled={generatingOutfit} className="w-full px-4 py-2.5 bg-amber-500/80 hover:bg-amber-500 disabled:opacity-60 disabled:cursor-not-allowed text-white text-xs font-semibold rounded-xl transition-all flex items-center justify-center gap-2">
{generatingOutfit ? (<>
<Loader2 size={14} className="animate-spin"/>
Generating outfit...
</>) : (<>
<Plus size={14}/>
Generate Outfit Variation (4 images)
</>)}
</button>
{outfitGenError && (<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{outfitGenError}
</div>)}
</>)}
</div>)}
</section>
{/* --- Avatar Generation Settings (expandable) --- */}
{effectiveAvatarSettings && (<section>
<button type="button" onClick={() => setShowAvatarSettings(!showAvatarSettings)} className="flex items-center gap-2 mb-3">
<Settings size={14} className="text-white/40"/>
<span className="text-xs font-semibold text-white/50 uppercase tracking-wider">
Generation Settings
</span>
{showAvatarSettings ? (<ChevronUp size={12} className="text-white/30"/>) : (<ChevronDown size={12} className="text-white/30"/>)}
</button>
{showAvatarSettings && (<div className="rounded-xl bg-white/[0.03] border border-white/10 p-4 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-white/50">Model</span>
<span className="text-white/80 font-mono">{effectiveAvatarSettings.img_model}</span>
</div>
<div className="flex justify-between">
<span className="text-white/50">Quality</span>
<span className="text-white/80">{effectiveAvatarSettings.img_preset}</span>
</div>
<div className="flex justify-between">
<span className="text-white/50">Aspect ratio</span>
<span className="text-white/80">{effectiveAvatarSettings.aspect_ratio}</span>
</div>
<div className="flex justify-between">
<span className="text-white/50">Style</span>
<span className="text-white/80">{effectiveAvatarSettings.style_preset}</span>
</div>
{effectiveAvatarSettings.body_type && (<div className="flex justify-between">
<span className="text-white/50">Body type</span>
<span className="text-white/80">{effectiveAvatarSettings.body_type}</span>
</div>)}
<div className="mt-2 pt-2 border-t border-white/5">
<span className="text-white/40">Full prompt</span>
<div className="text-[10px] text-white/30 mt-1 bg-white/5 rounded-lg p-2 max-h-20 overflow-y-auto font-mono break-all">
{effectiveAvatarSettings.full_prompt}
</div>
</div>
</div>)}
</section>)}
{/* --- Execution Profile --- */}
<section>
<SectionHeader icon={Zap} title="Execution Stance" color="text-cyan-400"/>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-2">
{PROFILE_OPTIONS.map((opt) => (<button key={opt.value} type="button" onClick={() => {
setProfile(opt.value);
markDirty();
}} className={[
'relative px-3 py-3 rounded-xl border text-left transition-all',
profile === opt.value
? 'bg-cyan-500/15 border-cyan-500/40 ring-1 ring-cyan-500/20'
: 'bg-white/5 border-white/10 hover:bg-white/8 hover:border-white/15',
].join(' ')}>
<div className="text-sm font-medium text-white">
<span className="mr-1.5">{opt.icon}</span>
{opt.label}
</div>
<div className="text-[11px] text-white/40 mt-0.5 leading-tight">{opt.hint}</div>
{profile === opt.value && (<div className="absolute top-2 right-2">
<Check size={12} className="text-cyan-400"/>
</div>)}
</button>))}
</div>
<div className="px-1">
<Toggle checked={askFirst} onChange={(v) => {
setAskFirst(v);
markDirty();
}} label="Ask before executing actions"/>
<p className="text-[11px] text-white/35 mt-1 ml-0.5">
When enabled, the persona will confirm before running tools or taking actions.
</p>
</div>
</div>
</section>
{/* --- Skills --- */}
<section>
<SectionHeader icon={Shield} title="Skills" badge={capabilities.length} color="text-emerald-400"/>
<div className="grid grid-cols-2 gap-2">
{BUILTIN_CAPABILITIES.map((cap) => {
const active = capabilities.includes(cap.id);
return (<button key={cap.id} type="button" onClick={() => toggleCap(cap.id)} className={[
'flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-all',
active
? 'bg-emerald-500/15 border-emerald-500/30'
: 'bg-white/5 border-white/10 hover:bg-white/8',
].join(' ')}>
<div className={[
'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
active ? 'bg-emerald-500 border-emerald-500' : 'border-white/20',
].join(' ')}>
{active && <Check size={10} className="text-white"/>}
</div>
<span className={`text-sm ${active ? 'text-white' : 'text-white/60'}`}>{cap.label}</span>
</button>);
})}
</div>
</section>
{/* --- Equipment (Tools) --- */}
<section>
<SectionHeader icon={Wrench} title="Equipment (Tools)" badge={effectiveToolCount} color="text-orange-400"/>
{catalogLoading ? (<div className="flex items-center gap-2 text-xs text-white/40 py-3">
<Loader2 size={12} className="animate-spin"/> Loading catalog...
</div>) : catalogTools.length === 0 ? (<div className="text-xs text-white/35 py-3 px-1">
No tools registered. Start MCP servers and run the seed script to populate.
</div>) : (<div>
<button type="button" onClick={() => setShowTools(!showTools)} className="flex items-center gap-2 text-xs text-white/50 hover:text-white/80 transition-colors mb-2">
{showTools ? <ChevronUp size={12}/> : <ChevronDown size={12}/>}
{showTools ? 'Collapse' : `Browse ${effectiveToolCount} in bundle (${toolIds.length} pinned)`}
</button>
{showTools && (<div className="space-y-1 max-h-48 overflow-y-auto custom-scrollbar">
{visibleTools.map((tool) => {
const bound = toolIds.includes(tool.id);
return (<button key={tool.id} type="button" onClick={() => toggleTool(tool.id)} className={[
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-all',
bound
? 'bg-orange-500/10 border border-orange-500/20'
: 'hover:bg-white/5 border border-transparent',
].join(' ')}>
<StatusDot ok={tool.enabled !== false}/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-white truncate">{tool.name}</div>
{tool.description && (<div className="text-[10px] text-white/35 truncate">{tool.description}</div>)}
</div>
<div className={[
'w-4 h-4 rounded border flex items-center justify-center shrink-0',
bound ? 'bg-orange-500 border-orange-500' : 'border-white/20',
].join(' ')}>
{bound && <Check size={10} className="text-white"/>}
</div>
</button>);
})}
</div>)}
<div className="flex items-center gap-3 mt-2 pt-2 border-t border-white/5">
<label className="text-xs text-white/50">Tool bundle:</label>
<select value={toolSource} onChange={(e) => {
setToolSource(e.target.value);
markDirty();
}} className="bg-[#1a1a2e] border border-white/10 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-pink-500/50 [&>option]:bg-[#1a1a2e] [&>option]:text-white">
<option value="all">All enabled tools</option>
{catalogServers.map((s) => (<option key={s.id} value={`server:${s.id}`}>
Server: {s.name}
</option>))}
<option value="none">No tools</option>
</select>
</div>
</div>)}
</section>
{/* --- Party Members (Agents) --- */}
<section>
<SectionHeader icon={Users} title="Party Members (Agents)" badge={agentIds.length} color="text-violet-400"/>
{catalogLoading ? (<div className="flex items-center gap-2 text-xs text-white/40 py-3">
<Loader2 size={12} className="animate-spin"/> Loading...
</div>) : catalogAgents.length === 0 ? (<div className="text-xs text-white/35 py-3 px-1">No A2A agents registered.</div>) : (<div>
<button type="button" onClick={() => setShowAgents(!showAgents)} className="flex items-center gap-2 text-xs text-white/50 hover:text-white/80 transition-colors mb-2">
{showAgents ? <ChevronUp size={12}/> : <ChevronDown size={12}/>}
{showAgents
? 'Collapse'
: `Browse ${catalogAgents.length} available (${agentIds.length} in party)`}
</button>
{showAgents && (<div className="space-y-1 max-h-36 overflow-y-auto custom-scrollbar">
{catalogAgents.map((agent) => {
const bound = agentIds.includes(agent.id);
return (<button key={agent.id} type="button" onClick={() => toggleAgent(agent.id)} className={[
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-all',
bound
? 'bg-violet-500/10 border border-violet-500/20'
: 'hover:bg-white/5 border border-transparent',
].join(' ')}>
<StatusDot ok={agent.enabled !== false}/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-white truncate">{agent.name}</div>
{agent.description && (<div className="text-[10px] text-white/35 truncate">{agent.description}</div>)}
</div>
<div className={[
'w-4 h-4 rounded border flex items-center justify-center shrink-0',
bound ? 'bg-violet-500 border-violet-500' : 'border-white/20',
].join(' ')}>
{bound && <Check size={10} className="text-white"/>}
</div>
</button>);
})}
</div>)}
</div>)}
</section>
{/* --- Character Summary --- */}
<section>
<SectionHeader icon={Server} title="Character Summary" color="text-white/50"/>
<div className="rounded-xl bg-white/[0.03] border border-white/10 p-4 space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-white/50">Class</span>
<span className="text-white/80 font-medium">
{blueprint ? `${blueprint.icon} ${blueprint.label}` : stylePreset} {role || 'Persona'}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-white/50">Alignment</span>
<span className="text-white/80 font-medium capitalize">{tone}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-white/50">Portraits</span>
<span className="text-white/80 font-medium">{allImages.length}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-white/50">Wardrobe</span>
<span className="text-white/80 font-medium">
{outfits.length} outfit{outfits.length !== 1 ? 's' : ''} ({outfits.reduce((n, o) => n + o.images.length, 0)} images)
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-white/50">Generation</span>
<span className={`font-medium ${generationMode === 'identity' ? 'text-emerald-300' : 'text-white/80'}`}>
{generationMode === 'identity' ? 'Same Person' : 'Standard'}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-white/50">Equipment</span>
<span className="text-white/80 font-medium">
{toolSource === 'all'
? `All tools (${effectiveToolCount})`
: toolSource === 'none'
? 'No tools'
: (() => {
const sid = toolSource.replace('server:', '');
const s = catalogServers.find((x) => x.id === sid);
return s ? `${s.name} (${effectiveToolCount})` : toolSource;
})()}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-white/50">Party</span>
<span className="text-white/80 font-medium">
{agentIds.length === 0
? 'Solo'
: agentIds
.map((id) => catalogAgents.find((a) => a.id === id)?.name || id)
.join(', ')}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-white/50">Stance</span>
<span className="text-white/80 font-medium capitalize">
{profile} / {askFirst ? 'Cautious' : 'Auto'}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-white/50">Skills</span>
<span className="text-white/80 font-medium">
{capabilities.length > 0 ? capabilities.length : 'None'}
</span>
</div>
{pap.nsfwMode && (<div className="flex items-center justify-between text-xs">
<span className="text-white/50">Mode</span>
<span className="text-orange-300 font-medium">Spicy</span>
</div>)}
{/* Age in days — computed from project creation timestamp */}
{(() => {
const ts = project.created_at;
if (!ts || ts <= 0)
return null;
const createdDate = new Date(ts * 1000);
const ageDays = Math.max(0, Math.floor((Date.now() - createdDate.getTime()) / 86_400_000));
return (<>
<div className="border-t border-white/5 my-1"/>
<div className="flex items-center justify-between text-xs">
<span className="text-white/50">Born</span>
<span className="text-white/80 font-medium">{createdDate.toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-white/50">Age</span>
<span className="text-pink-300 font-semibold">
{ageDays === 0
? 'Newborn (today)'
: ageDays === 1
? '1 day'
: `${ageDays} days`}
</span>
</div>
</>);
})()}
</div>
</section>
{/* --- Knowledge Base --- */}
<section>
<SectionHeader icon={FileText} title="Knowledge Base" badge={documents.length} color="text-blue-400"/>
{documents.length === 0 ? (<div className="text-xs text-white/35 py-3 px-1">
No documents uploaded. Upload files when using this persona project.
</div>) : (<div className="space-y-1">
{documents.map((doc, i) => (<div key={i} className="flex items-center justify-between px-3 py-2 rounded-lg bg-white/5 border border-white/10 group">
<div className="flex items-center gap-2.5 min-w-0">
<FileText size={14} className="text-purple-400 shrink-0"/>
<div className="min-w-0">
<div className="text-xs text-white truncate">{doc.name}</div>
<div className="text-[10px] text-white/30">
{doc.size || ''}
{doc.chunks ? ` \u00b7 ${doc.chunks} chunks` : ''}
</div>
</div>
</div>
<button onClick={() => handleDeleteDoc(doc.name)} className="p-1 text-white/30 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all rounded hover:bg-red-500/10">
<Trash2 size={14}/>
</button>
</div>))}
</div>)}
</section>
{/* --- Shared API — Publish as Model --- */}
<section>
<SectionHeader icon={Share2} title="Shared API" color="text-emerald-400"/>
{/* Toggle row */}
<div className="flex items-center justify-between py-2">
<div>
<div className="text-xs text-white/70">Publish as API Model</div>
<div className="text-[10px] text-white/40 mt-0.5">
Make this persona discoverable by OllaBridge and external apps
</div>
</div>
<button type="button" onClick={() => { setSharedEnabled(!sharedEnabled); markDirty(); }} className={`w-9 h-5 rounded-full transition-colors relative ${sharedEnabled ? 'bg-emerald-500' : 'bg-white/20'}`}>
<span className={`block w-3.5 h-3.5 rounded-full bg-white shadow transition-transform absolute top-[3px] ${sharedEnabled ? 'left-[19px]' : 'left-[3px]'}`}/>
</button>
</div>
{/* Expanded settings (when enabled) */}
{sharedEnabled && (<div className="space-y-3 mt-2">
{/* Model alias */}
<div className="space-y-1.5">
<label className="text-[10px] text-white/50 block">Model Alias</label>
<input type="text" value={sharedAlias} onChange={(e) => { setSharedAlias(e.target.value); markDirty(); }} placeholder={`e.g. ${(name || 'my-persona').toLowerCase().replace(/\s+/g, '-')}`} className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-xs text-white placeholder-white/25 focus:outline-none focus:border-emerald-500/50 transition-colors font-mono"/>
<div className="text-[10px] text-white/30">
Short name for the API model. Letters, numbers, and hyphens only.
</div>
</div>
{/* Featured slot */}
<div className="space-y-1.5">
<label className="text-[10px] text-white/50 block">Featured Slot (optional)</label>
<select value={featuredSlot ?? ''} onChange={(e) => {
setFeaturedSlot(e.target.value ? Number(e.target.value) : null);
markDirty();
}} className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-emerald-500/50 transition-colors">
<option value="">None</option>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => (<option key={n} value={n}>Slot {n}</option>))}
</select>
<div className="text-[10px] text-white/30">
Featured slot controls display order in external apps.
</div>
</div>
{/* Generated model ID (read-only, copyable) */}
{(() => {
const aliasText = sharedAlias.trim();
const derivedName = (name || '').trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
const shortId = project.id.slice(0, 8);
const modelId = aliasText
? `persona:${aliasText.toLowerCase().replace(/[^a-z0-9-]/g, '').replace(/\s+/g, '-')}--${shortId}`
: derivedName
? `persona:${derivedName}--${shortId}`
: `persona:${shortId}`;
return (<div className="bg-black/30 border border-emerald-500/20 rounded-lg px-3 py-2.5">
<div className="text-[10px] text-emerald-400 font-semibold mb-1">
OpenAI Model ID
</div>
<div className="flex items-center gap-2">
<code className="text-[11px] text-white/70 font-mono flex-1 truncate">
{modelId}
</code>
<button type="button" onClick={() => navigator.clipboard.writeText(modelId)} className="p-1 text-white/30 hover:text-emerald-400 transition-colors" title="Copy model ID">
<Copy size={12}/>
</button>
</div>
<div className="text-[10px] text-white/30 mt-1">
{aliasText
? 'Use this in OllaBridge, OpenAI SDKs, or any compatible client.'
: 'Set an alias above for a custom model name, or the persona name will be used.'}
</div>
</div>);
})()}
</div>)}
</section>
</div>
</div>)}
{/* -- Footer -- */}
<div className="px-6 py-4 border-t border-white/10 bg-[#0f0f1e] flex items-center justify-between">
<div className="text-[11px] text-white/30">{dirty ? 'Unsaved changes' : 'All changes saved'}</div>
<div className="flex items-center gap-3">
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-white/50 hover:text-white transition-colors">
Cancel
</button>
<button onClick={handleSave} disabled={saving || !dirty} className={[
'px-5 py-2 text-sm font-semibold rounded-full transition-all',
dirty ? 'bg-pink-500 hover:bg-pink-600 text-white' : 'bg-white/10 text-white/30 cursor-not-allowed',
].join(' ')}>
{saving ? (<span className="flex items-center gap-2">
<Loader2 size={14} className="animate-spin"/> Saving...
</span>) : ('Save Changes')}
</button>
</div>
</div>
</div>
{/* Lightbox for full-screen avatar viewing (view-only, no edit/video) */}
{lightbox ? (<ImageViewer imageUrl={lightbox} onClose={() => setLightbox(null)}/>) : null}
</div>);
}