| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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'; |
| |
| |
| |
| 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' }, |
| ]; |
| |
| |
| |
| function readNsfwMode() { |
| try { |
| return localStorage.getItem('homepilot_nsfw_mode') === 'true'; |
| } |
| catch { |
| return false; |
| } |
| } |
| |
| |
| 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)}` : ''}`; |
| } |
| |
| |
| |
| 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}`; |
| } |
| |
| 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'}`}/>; |
| } |
| |
| |
| |
| 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(); |
| |
| const { capabilities: avatarCaps } = useAvatarCapabilities(backendUrl, apiKey); |
| |
| const [viewMode, setViewMode] = useState('sheet'); |
| |
| 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'); |
| |
| 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'); |
| |
| const [catalogTools, setCatalogTools] = useState([]); |
| const [catalogAgents, setCatalogAgents] = useState([]); |
| const [catalogServers, setCatalogServers] = useState([]); |
| const [catalogLoading, setCatalogLoading] = useState(true); |
| |
| |
| |
| |
| 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)); |
| |
| if (toolIds.length > 0) { |
| const allMatch = toolIds.every((id) => catalogIdSet.has(id)); |
| if (allMatch) |
| return; |
| |
| const catalogByName = new Map(); |
| const catalogBySuffix = new Map(); |
| for (const t of enabled) { |
| catalogByName.set(t.name.toLowerCase(), t.id); |
| |
| const parts = t.id.split(/__|\/|:/); |
| const suffix = parts[parts.length - 1]; |
| if (suffix && !catalogBySuffix.has(suffix)) |
| catalogBySuffix.set(suffix, t.id); |
| } |
| |
| const detailsMap = new Map(); |
| 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 { |
| |
| const name = detailsMap.get(oldId); |
| if (name) |
| resolved = catalogByName.get(name.toLowerCase()); |
| |
| if (!resolved) { |
| const parts = oldId.split(/__|\/|:/); |
| const suffix = parts[parts.length - 1]; |
| if (suffix) |
| resolved = catalogBySuffix.get(suffix); |
| } |
| |
| 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; |
| } |
| |
| const ids = []; |
| |
| const details = ag.tool_details; |
| if (Array.isArray(details) && details.length > 0) { |
| |
| 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); |
| } |
| } |
| |
| if (ids.length === 0) { |
| const haystack = [role, systemPrompt, goal].join(' ').toLowerCase(); |
| |
| 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]); |
| |
| |
| |
| 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)); |
| |
| 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(''); |
| |
| const [generationMode, setGenerationModeRaw] = useState(pap.avatar_settings?.generation_mode || 'standard'); |
| const setGenerationMode = (mode) => { |
| setGenerationModeRaw(mode); |
| |
| if (avatarSettingsLocal) { |
| setAvatarSettingsLocal({ ...avatarSettingsLocal, generation_mode: mode }); |
| } |
| markDirty(); |
| }; |
| |
| const avatarSettings = pap.avatar_settings || null; |
| |
| const [documents, setDocuments] = useState(project.files || []); |
| |
| 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); |
| |
| 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(''); |
| |
| const personaClass = pa.persona_class || pa.category || 'custom'; |
| const blueprint = PERSONA_BLUEPRINTS.find((bp) => bp.id === personaClass); |
| |
| const totalImageCount = allImages.length + outfits.reduce((n, o) => n + o.images.length, 0); |
| |
| |
| const selectedUrl = (() => { |
| let raw = null; |
| if (selectedImage) { |
| |
| for (const img of allImages) { |
| if (img.id === selectedImage.image_id && img.set_id === selectedImage.set_id) { |
| raw = img.url; |
| break; |
| } |
| } |
| |
| 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); |
| |
| const fallback = allImages[0]?.url; |
| if (fallback) |
| return resolveImgUrl(fallback, backendUrl); |
| return fileUrl(backendUrl, pap.selected_thumb_filename) |
| || fileUrl(backendUrl, pap.selected_filename); |
| })(); |
| |
| 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, |
| }; |
| |
| const markDirty = () => { |
| if (!dirty) |
| setDirty(true); |
| }; |
| |
| 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 { } |
| 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]); |
| |
| 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 []; |
| })(); |
| |
| 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(); |
| }; |
| |
| 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(); |
| |
| |
| const uploadedFilename = url.split('/files/').pop()?.split('?')[0]; |
| |
| const commitResult = await commitPersonaAvatar({ |
| backendUrl, |
| apiKey, |
| projectId: project.id, |
| sourceFilename: uploadedFilename, |
| }); |
| |
| 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; |
| |
| 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]); |
| |
| 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], |
| })); |
| |
| setSets((prev) => [...prev, { set_id: setId, images: newImages }]); |
| setSelectedImage({ set_id: setId, image_id: newImages[0].id }); |
| |
| |
| 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 })), |
| }); |
| |
| 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 { |
| |
| } |
| |
| try { |
| await commitPersonaAvatar({ |
| backendUrl, |
| apiKey, |
| projectId: project.id, |
| auto: true, |
| }); |
| } |
| catch { |
| |
| } |
| |
| 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]); |
| |
| 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]); |
| |
| |
| |
| 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, |
| }; |
| |
| |
| |
| 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]; |
| }); |
| |
| |
| 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, |
| }); |
| |
| 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 { |
| |
| } |
| 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]); |
| |
| const handleDeleteOutfit = (outfitId) => { |
| setOutfits((prev) => prev.filter((o) => o.id !== outfitId)); |
| markDirty(); |
| }; |
| |
| const handleUseOutfitAsAvatar = (outfitImage) => { |
| setSelectedImage({ set_id: outfitImage.set_id, image_id: outfitImage.id }); |
| markDirty(); |
| }; |
| |
| 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 { } |
| 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(); |
| |
| |
| |
| let finalProject = data.project; |
| try { |
| const commitRes = await commitPersonaAvatar({ |
| backendUrl, |
| apiKey, |
| projectId: project.id, |
| auto: true, |
| }); |
| if (commitRes.project) { |
| finalProject = commitRes.project; |
| } |
| } |
| catch { |
| |
| } |
| 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, |
| ]); |
| |
| 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 { |
| |
| } |
| }; |
| |
| 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> |
|
|
| {} |
| <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> |
|
|
| {} |
| <section> |
| <SectionHeader icon={Share2} title="Shared API" color="text-emerald-400"/> |
|
|
| {} |
| <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> |
|
|
| {} |
| {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>)} |
|
|
| {} |
| <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 ? (<ImageViewer imageUrl={lightbox} onClose={() => setLightbox(null)}/>) : null} |
| </div>); |
| } |
|
|