/**
* PersonaImportExport — Phase 4 (Enterprise MCP Auto-Install)
*
* Enhanced modal for importing .hpersona packages with MCP server auto-install:
* Step 0: Upload (drag-drop or file picker)
* Step 1: Preview + dependency check
* Step 2: MCP Server Installation (if external servers needed)
* Step 3: Install complete
*
* When a shared persona requires an external MCP server (e.g. hp-news):
* 1. Dependency checker detects the missing server
* 2. User sees a confirmation prompt: "This persona needs mcp-news. Install it?"
* 3. On "Yes": clones from git → installs deps → starts server → syncs to Forge
* 4. Real-time progress bars show each installation phase
* 5. Once all servers are ready, persona project is created
*
* Also provides an export button component for project cards.
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { X, Upload, Package, Check, AlertTriangle, Download, ChevronLeft, ChevronRight, Wrench, Server, Bot, Image as ImageIcon, Shield, Cpu, HelpCircle, GitBranch, Loader2, ExternalLink, CheckCircle, XCircle, SkipForward, RefreshCw, } from 'lucide-react';
import { previewPersonaPackage, importPersonaPackage, importPersonaAtomic, exportPersona, resolvePersonaDeps, installPersonaDeps, } from './personaPortability';
// ---------------------------------------------------------------------------
// Status icon helper
// ---------------------------------------------------------------------------
function StatusIcon({ status }) {
if (status === 'available')
return
;
if (status === 'installable')
return
;
if (status === 'downloadable')
return
;
if (status === 'missing')
return
;
if (status === 'degraded')
return ;
return
;
}
function DependencyRow({ item }) {
return (
{item.name}
{item.detail || item.description}
{item.source_type === 'builtin' && (
Built-in
)}
{item.source_type === 'community_bundle' && (
{item.status === 'available' ? 'Installed' : item.status === 'installable' ? 'Ready' : 'Download'}
)}
{item.source_type === 'registry' && (
{item.status === 'available' ? 'Installed' : 'Discover'}
)}
{item.source_type === 'external' && (
{item.status === 'available' ? 'Installed' : item.status === 'downloadable' ? 'Auto-install' : 'Missing'}
)}
);
}
// ---------------------------------------------------------------------------
// MCP Server Install Status Card
// ---------------------------------------------------------------------------
const PHASE_LABELS = {
analyzing: 'Analyzing',
cloning: 'Cloning from Git',
registering: 'Registering',
starting: 'Starting Server',
discovering: 'Discovering Tools',
syncing: 'Syncing to Forge',
complete: 'Complete',
failed: 'Failed',
skipped: 'Already Installed',
};
function McpInstallCard({ status, backendUrl, apiKey, }) {
const isComplete = status.phase === 'complete' || status.phase === 'skipped';
const isFailed = status.phase === 'failed';
const isActive = !isComplete && !isFailed;
return (
{/* Header */}
{isComplete ?
: isFailed ?
: }
{status.server_name}
{PHASE_LABELS[status.phase] || status.phase}
{status.port && ` \u2022 Port ${status.port}`}
{status.source_type && (
{status.source_type}
)}
{/* Progress bar */}
{isActive && (
)}
{/* Status message */}
{isFailed ? status.error : status.message}
{/* Git URL */}
{status.git_url && (
{status.git_url}
)}
{/* Tools discovered */}
{isComplete && status.tools_discovered > 0 && (
{status.tools_discovered} tools discovered
{status.tools_registered} registered in Forge
{status.elapsed_ms > 0 && {(status.elapsed_ms / 1000).toFixed(1)}s }
)}
{/* Install logs — show for failed installs so user can debug */}
{isFailed && backendUrl && (
)}
);
}
function InstallLogViewer({ backendUrl, apiKey, serverName, active, }) {
const [logs, setLogs] = useState([]);
const [expanded, setExpanded] = useState(false);
const sinceRef = useRef(0);
const scrollRef = useRef(null);
useEffect(() => {
let cancelled = false;
const fetchLogs = async () => {
try {
const headers = {};
if (apiKey)
headers['x-api-key'] = apiKey;
const res = await fetch(`${backendUrl}/v1/agentic/servers/install-logs?server=${encodeURIComponent(serverName)}&since=${sinceRef.current}`, { headers });
if (res.ok) {
const data = await res.json();
const newLogs = data.logs || [];
if (newLogs.length > 0) {
sinceRef.current += newLogs.length;
setLogs((prev) => [...prev, ...newLogs].slice(-100));
requestAnimationFrame(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
});
}
}
}
catch {
// non-critical
}
// If active, keep polling; otherwise one-time fetch
if (!cancelled && active) {
setTimeout(fetchLogs, 1500);
}
};
fetchLogs();
return () => { cancelled = true; };
}, [active, backendUrl, apiKey, serverName]);
if (logs.length === 0 && !active)
return null;
const visibleLogs = expanded ? logs : logs.slice(-5);
const levelColor = (level) => {
if (level === 'error')
return 'text-red-400';
if (level === 'warning')
return 'text-amber-400';
if (level === 'debug')
return 'text-white/20';
return 'text-white/40';
};
return (
setExpanded(!expanded)} className="text-[10px] text-white/30 hover:text-white/50 transition-colors mb-1">
{expanded ? 'Hide' : 'Show'} install logs ({logs.length} entries)
{(expanded || logs.length <= 5) && (
{visibleLogs.map((log, i) => (
{log.phase}
{log.message}
))}
{logs.length === 0 && active && (
Waiting for install logs...
)}
)}
);
}
// ---------------------------------------------------------------------------
// Step breadcrumb
// ---------------------------------------------------------------------------
function StepBreadcrumb({ step, totalSteps }) {
const labels = totalSteps === 4
? ['Upload', 'Preview', 'MCP Servers', 'Install']
: ['Upload', 'Preview', 'Install'];
return (
{labels.map((label, i) => (
{i > 0 && }
{i < step ? '\u2713 ' : ''}{label}
))}
);
}
// ---------------------------------------------------------------------------
// Import Modal
// ---------------------------------------------------------------------------
export function PersonaImportModal({ onClose, onImported, backendUrl, apiKey, }) {
const [step, setStep] = useState(0);
const [file, setFile] = useState(null);
const [isDragging, setIsDragging] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [preview, setPreview] = useState(null);
const [importing, setImporting] = useState(false);
const [importedProject, setImportedProject] = useState(null);
const [memoryMode, setMemoryMode] = useState('adaptive');
const fileInputRef = useRef(null);
// MCP install state
const [mcpPlan, setMcpPlan] = useState(null);
const [mcpInstalling, setMcpInstalling] = useState(false);
const [mcpResult, setMcpResult] = useState(null);
const [forceReinstall, setForceReinstall] = useState(false);
// Do we need MCP server installation? (installable, downloadable, or external missing)
const needsMcpInstall = preview?.dependency_check?.mcp_servers?.some((s) => s.status === 'installable' || s.status === 'downloadable'
|| (s.status === 'missing' && s.source_type === 'external')) ?? false;
// Alias for backwards compat in the component
const hasMissingMcpServers = needsMcpInstall;
// Are there MCP servers that are already installed? (user may want to reinstall)
const hasAlreadyInstalledServers = preview?.dependency_check?.mcp_servers?.some((s) => s.status === 'available' && s.source_type === 'external') ?? false;
// Total steps: 4 if MCP install needed, 3 otherwise
const totalSteps = hasMissingMcpServers ? 4 : 3;
// Map step numbers: 0=Upload, 1=Preview, 2=MCP (if needed), last=Complete
const STEP_UPLOAD = 0;
const STEP_PREVIEW = 1;
const STEP_MCP = hasMissingMcpServers ? 2 : -1;
const STEP_COMPLETE = hasMissingMcpServers ? 3 : 2;
// --- Drag & drop ---
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const f = e.dataTransfer.files?.[0];
if (f)
handleFileSelected(f);
}, []);
const handleFileChange = useCallback((e) => {
const f = e.target.files?.[0];
if (f)
handleFileSelected(f);
}, []);
const handleFileSelected = async (f) => {
setFile(f);
setError(null);
setLoading(true);
try {
const result = await previewPersonaPackage({ backendUrl, apiKey, file: f });
setPreview(result);
const mm = result.manifest?.memory_mode || result.persona_agent?.memory_mode || 'adaptive';
setMemoryMode(mm === 'basic' ? 'basic' : 'adaptive');
setStep(STEP_PREVIEW);
}
catch (err) {
setError(err.message || 'Failed to parse package');
}
finally {
setLoading(false);
}
};
// Resolve MCP deps (check what needs install)
const handleResolveDeps = async () => {
if (!file)
return;
setError(null);
setLoading(true);
try {
const plan = await resolvePersonaDeps({ backendUrl, apiKey, file });
setMcpPlan(plan);
setStep(STEP_MCP);
}
catch (err) {
setError(err.message || 'Failed to check MCP dependencies');
}
finally {
setLoading(false);
}
};
// Install missing MCP servers
const handleInstallMcp = async () => {
if (!file)
return;
setMcpInstalling(true);
setError(null);
try {
const result = await installPersonaDeps({ backendUrl, apiKey, file });
setMcpResult(result);
// After MCP install, proceed to import the persona
if (result.all_satisfied) {
await doImport();
}
}
catch (err) {
setError(err.message || 'MCP server installation failed');
}
finally {
setMcpInstalling(false);
}
};
// Import the persona project
const doImport = async () => {
if (!file)
return;
setImporting(true);
setError(null);
try {
const result = await importPersonaPackage({ backendUrl, apiKey, file });
setImportedProject(result.project);
setStep(STEP_COMPLETE);
try {
const engineValue = memoryMode === 'basic' ? 'v1' : 'v2';
localStorage.setItem('homepilot_memory_engine', engineValue);
}
catch { /* non-critical */ }
onImported(result.project);
}
catch (err) {
setError(err.message || 'Import failed');
}
finally {
setImporting(false);
}
};
// Atomic import: install MCP servers + create persona in one call
const handleAtomicImport = async () => {
if (!file)
return;
setImporting(true);
setError(null);
try {
const result = await importPersonaAtomic({
backendUrl, apiKey, file, autoInstallServers: true,
forceReinstall,
});
setImportedProject(result.project);
if (result.install_plan) {
setMcpResult(result.install_plan);
}
setStep(STEP_COMPLETE);
try {
const engineValue = memoryMode === 'basic' ? 'v1' : 'v2';
localStorage.setItem('homepilot_memory_engine', engineValue);
}
catch { /* non-critical */ }
onImported(result.project);
}
catch (err) {
setError(err.message || 'Atomic import failed');
}
finally {
setImporting(false);
}
};
const handleImport = async () => {
if (needsMcpInstall) {
// Use atomic import for one-click install
await handleAtomicImport();
}
else {
await doImport();
}
};
// Extract preview data
const agentLabel = preview?.persona_agent?.label || preview?.persona_agent?.id || 'Unknown';
const agentRole = preview?.persona_agent?.role || '';
const tone = preview?.persona_agent?.response_style?.tone || preview?.persona_agent?.tone || '';
const contentRating = preview?.manifest?.content_rating || 'sfw';
const schemaVer = preview?.manifest?.schema_version || 1;
const pkgVer = preview?.manifest?.package_version || 1;
const depCheck = preview?.dependency_check;
const tools = preview?.persona_agent?.allowed_tools || [];
const contents = preview?.manifest?.contents;
return (
{/* Header */}
{/* Content */}
{/* ── Step 0: Upload ── */}
{step === STEP_UPLOAD && (
{loading ? 'Analyzing package...' : 'Drop .hpersona file here'}
{loading ? 'Checking dependencies and validating contents' : 'or click to browse'}
{!loading && (
Browse files
)}
{loading && (
)}
.hpersona files contain persona configurations, avatar images, and model references.
No executable code is included. Tool and server dependencies are checked before installation.
)}
{/* ── Step 1: Preview ── */}
{step === STEP_PREVIEW && preview && (
{/* Persona card */}
{preview.avatar_preview_data_url ? (
) : preview.has_avatar ? (
) : (
)}
{agentLabel}
{agentRole && {agentRole} }
{tone && {tone} }
{contentRating === 'nsfw' && (NSFW )}
{tools.length > 0 && {tools.length} tools }
{contents?.has_avatar && Avatar included }
{(contents?.outfit_count || 0) > 0 && {contents.outfit_count} outfits }
v{pkgVer} / schema {schemaVer}
{/* Memory Mode */}
Memory Mode
{memoryMode === 'adaptive'
? 'Learns over time, forgets irrelevant details.'
: 'Only remembers what is explicitly saved.'}
{['adaptive', 'basic'].map((mode) => ( setMemoryMode(mode)} className={`text-[11px] px-3 py-1 rounded-full border transition-all ${memoryMode === mode
? mode === 'adaptive'
? 'bg-purple-500/15 border-purple-500/30 text-purple-300'
: 'bg-blue-500/15 border-blue-500/30 text-blue-300'
: 'bg-white/5 border-white/10 text-white/40 hover:bg-white/8'}`}>
{mode === 'adaptive' ? 'Adaptive' : 'Basic'}
))}
{/* System prompt preview */}
{preview.persona_agent?.system_prompt && (
System Prompt
{preview.persona_agent.system_prompt.slice(0, 300)}
{preview.persona_agent.system_prompt.length > 300 ? '...' : ''}
)}
{/* Dependencies */}
{depCheck && (
Dependencies
{depCheck.summary}
{/* Models */}
{depCheck.models.length > 0 && (
Image Models
{depCheck.models.map((m, i) => )}
)}
{/* Tools */}
{depCheck.tools.length > 0 && (
Tools ({depCheck.tools.length})
{depCheck.tools.map((t, i) => )}
)}
{/* MCP Servers */}
{depCheck.mcp_servers.length > 0 && (
MCP Servers ({depCheck.mcp_servers.length})
{depCheck.mcp_servers.map((s, i) => )}
)}
{/* A2A Agents */}
{depCheck.a2a_agents.length > 0 && (
A2A Agents ({depCheck.a2a_agents.length})
{depCheck.a2a_agents.map((a, i) => )}
)}
{/* No dependencies */}
{depCheck.models.length === 0 && depCheck.tools.length === 0 &&
depCheck.mcp_servers.length === 0 && depCheck.a2a_agents.length === 0 && (
No external dependencies required
)}
)}
{/* MCP Server Install Prompt */}
{needsMcpInstall && (
MCP Server Setup Required
This persona requires MCP server(s) that will be automatically installed.
Click Install Persona + Server to set everything up in one step.
{depCheck?.mcp_servers
.filter(s => s.status === 'installable' || s.status === 'downloadable'
|| (s.status === 'missing' && s.source_type === 'external'))
.map((s, i) => (
{s.status === 'installable' ? (<>
{s.name}
Ready to install{s.port ? ` on port ${s.port}` : ''}
>) : (<>
{s.name}
{s.url && ({s.url}) }
>)}
))}
)}
{/* Reinstall option — shown when servers are already installed */}
{hasAlreadyInstalledServers && (
MCP Server Already Installed
The required MCP server(s) are already running. Choose whether to reuse them or reinstall from scratch.
setForceReinstall(false)} className={`text-xs px-3 py-1.5 rounded-lg border transition-all ${!forceReinstall
? 'bg-emerald-500/15 border-emerald-500/30 text-emerald-300'
: 'bg-white/5 border-white/10 text-white/40 hover:bg-white/8'}`}>
Reuse existing
setForceReinstall(true)} className={`text-xs px-3 py-1.5 rounded-lg border transition-all ${forceReinstall
? 'bg-amber-500/15 border-amber-500/30 text-amber-300'
: 'bg-white/5 border-white/10 text-white/40 hover:bg-white/8'}`}>
Purge & reinstall
{depCheck?.mcp_servers
.filter(s => s.status === 'available' && s.source_type === 'external')
.map((s, i) => (
{s.name}
running{s.port ? ` on port ${s.port}` : ''}
))}
)}
)}
{/* ── Step 2: MCP Server Installation ── */}
{step === STEP_MCP && (
{/* Header */}
MCP Server Installation
{mcpInstalling
? 'Installing required MCP servers...'
: mcpResult
? mcpResult.all_satisfied
? 'All servers installed successfully!'
: 'Some servers could not be installed'
: `${mcpPlan?.servers_to_install.length || 0} server(s) need to be installed`}
{/* Install confirmation (before install) */}
{!mcpInstalling && !mcpResult && mcpPlan && (
{/* Already available */}
{mcpPlan.servers_already_available.length > 0 && (
Available
{mcpPlan.servers_already_available.map((s, i) => (
{s.name || 'unknown'}
{s.description || ''}
))}
)}
{/* To install */}
{mcpPlan.servers_to_install.length > 0 && (
Will be installed
{mcpPlan.servers_to_install.map((s, i) => (
{s.name}
{s.description}
{s.git_url && (
{s.git_url}
)}
{s.tools_provided.length > 0 && (
{s.tools_provided.length} tools: {s.tools_provided.join(', ')}
)}
))}
)}
{/* Install button */}
Install {mcpPlan.servers_to_install.length} Server(s)
)}
{/* Installing progress — live log viewer */}
{mcpInstalling && (
Installing MCP servers... This may take a moment.
{/* Live install logs for each server being installed */}
{mcpPlan?.servers_to_install?.map((srv, i) => (
))}
)}
{/* Install results */}
{mcpResult && (
{mcpResult.install_statuses.map((s, i) => (
))}
{/* Summary */}
{mcpResult.summary}
)}
)}
{/* ── Step 3 (or 2): Complete ── */}
{step === STEP_COMPLETE && importedProject && (
{importedProject.name || 'Persona'} installed!
The persona project has been created and is ready to use.
{preview?.has_avatar && (
Avatar committed to project storage
)}
{(preview?.manifest?.contents?.outfit_count || 0) > 0 && (
{preview.manifest.contents.outfit_count} outfits imported
)}
{(preview?.persona_agent?.allowed_tools?.length || 0) > 0 && (
{preview.persona_agent.allowed_tools.length} tools configured
)}
{mcpResult && mcpResult.install_statuses.length > 0 && (
{mcpResult.install_statuses.filter(s => s.phase === 'complete' || s.phase === 'skipped').length} MCP server(s) installed and synced
)}
{(preview?.dependency_check?.mcp_servers?.length || 0) > 0 && !mcpResult && (
{preview.dependency_check.mcp_servers.length} MCP server(s) referenced
)}
Memory: {memoryMode === 'adaptive' ? 'Adaptive Memory' : 'Basic Memory'}
)}
{/* Error display */}
{error && (
{error}
)}
{/* Footer */}
{step === STEP_UPLOAD && (<>
Cancel
>)}
{step === STEP_PREVIEW && (<>
{ setStep(STEP_UPLOAD); setFile(null); setPreview(null); setError(null); }} className="flex items-center gap-1 px-4 py-2 text-sm font-medium text-white/50 hover:text-white transition-colors">
Back
{importing || loading ? (<>
{needsMcpInstall ? 'Installing servers & persona...' : 'Installing...'}
>) : needsMcpInstall ? (<>
Install Persona + Server
>) : (<>
Install Persona
>)}
>)}
{step === STEP_MCP && (<>
{ setStep(STEP_PREVIEW); setMcpPlan(null); setMcpResult(null); setError(null); }} disabled={mcpInstalling} className="flex items-center gap-1 px-4 py-2 text-sm font-medium text-white/50 hover:text-white transition-colors disabled:opacity-30">
Back
{mcpResult?.all_satisfied && !importedProject && (
{importing ? (<>
Creating persona...
>) : (<>
Create Persona Project
>)}
)}
{mcpResult && !mcpResult.all_satisfied && (
Install Anyway (limited tools)
)}
>)}
{step === STEP_COMPLETE && (<>
Done
>)}
);
}
// ---------------------------------------------------------------------------
// Export button (inline, for project cards)
// ---------------------------------------------------------------------------
export function PersonaExportButton({ projectId, backendUrl, apiKey, className, }) {
const [exporting, setExporting] = useState(false);
const handleExport = async (e) => {
e.stopPropagation();
if (exporting)
return;
setExporting(true);
try {
await exportPersona({ backendUrl, apiKey, projectId });
}
catch (err) {
alert(`Export failed: ${err.message}`);
}
finally {
setExporting(false);
}
};
return (
{exporting ? (
) : ()}
);
}