/** * 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 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 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 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 (
{title} {badge !== undefined && ( {badge} )}
); } function StatBar({ label, value, color = 'bg-purple-500' }) { return (
{label}
{value}
); } function Toggle({ checked, onChange, label, }) { return (); } function StatusDot({ ok }) { return ; } // --------------------------------------------------------------------------- // 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/. 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 (
e.stopPropagation()}> {/* -- Header -- */}

Persona Profile {blueprint && blueprint.id !== 'custom' && ( {blueprint.icon} {blueprint.label} )}

{viewMode === 'inventory' ? 'Inventory' : 'Character Sheet'}

{/* -- Content: swap between sheet and inventory -- */} {viewMode === 'inventory' ? ( 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(); }}/>) : (
{/* -- Top: Avatar + Stats -- */}
{/* Avatar frame */}
{selectedUrl ? ({name} 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"/>) : (
setShowChangePhoto(true)}>
Add photo
)} {/* Change Photo button (hover) */} {allImages.length > 1 && ()}
LV {totalImageCount}
{/* Change Photo panel */} {showChangePhoto && (
{/* Upload option */} {/* Generate option */} {/* Generation mode toggle — Standard vs Same Person */}
Generation mode
{generationMode === 'identity' && (
Face preservation active
)} {!avatarCaps.canIdentityPortrait && generationMode === 'standard' && (
Install Avatar Models for same-person mode
)}
{changePhotoError && (
{changePhotoError}
)}
)}
{/* Stats panel */}
{ 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"/> { 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"/>
{stylePreset} {tone} {pap.nsfwMode && ( Spicy )} {allImages.length} portrait{allImages.length !== 1 ? 's' : ''} {outfits.length > 0 && ( {outfits.length} outfit{outfits.length !== 1 ? 's' : ''} )} {capabilities.length > 0 && ( {capabilities.length} skill{capabilities.length !== 1 ? 's' : ''} )}
{/* -- Portrait Gallery (expandable) — base portraits only -- */} {showGallery && allImages.length > 0 && (
{allImages.map((img) => { const isSel = selectedImage?.set_id === img.set_id && selectedImage?.image_id === img.id; return (
{/* Delete — small bin icon, top-right, appears on hover */} {!isSel && ()}
); })}
)} {/* -- Detail sections -- */}
{/* --- Quest Objective --- */}