UserSyncInterface / components /SimulationPage.tsx
AUXteam's picture
Upload folder using huggingface_hub
ef213b3 verified
import React, { useState, useEffect } from 'react';
import { ChevronDown, Plus, Info, MessageSquare, BookOpen, LogOut, PanelLeftClose, MessageCircle, Menu, PanelRightClose, RefreshCw } from 'lucide-react';
import SimulationGraph from './SimulationGraph';
import { GradioService } from '../services/gradioService';
interface SimulationPageProps {
onBack: () => void;
onOpenChat: () => void;
onOpenGuide: () => void;
user?: any;
onLogin?: () => void;
onLogout?: () => void;
simulationResult: any;
setSimulationResult: (res: any) => void;
}
// Define the data structure for filters
const VIEW_FILTERS: Record<string, Array<{ label: string; color: string }>> = {
'Country': [
{ label: "United States", color: "bg-blue-600" },
{ label: "United Kingdom", color: "bg-purple-600" },
{ label: "Netherlands", color: "bg-teal-600" },
{ label: "France", color: "bg-orange-600" },
{ label: "India", color: "bg-pink-600" }
],
'Job Title': [
{ label: "Founder", color: "bg-indigo-500" },
{ label: "Product Manager", color: "bg-emerald-500" },
{ label: "Engineer", color: "bg-rose-500" },
{ label: "Investor", color: "bg-amber-500" },
{ label: "Designer", color: "bg-fuchsia-500" }
],
'Sentiment': [
{ label: "Positive", color: "bg-green-500" },
{ label: "Neutral", color: "bg-gray-500" },
{ label: "Negative", color: "bg-red-500" },
{ label: "Mixed", color: "bg-yellow-500" }
],
'Activity Level': [
{ label: "Power User", color: "bg-red-600" },
{ label: "Daily Active", color: "bg-orange-500" },
{ label: "Weekly Active", color: "bg-blue-500" },
{ label: "Lurker", color: "bg-slate-600" }
]
};
const SimulationPage: React.FC<SimulationPageProps> = ({
onBack, onOpenChat, onOpenGuide, user, onLogin, onLogout, simulationResult, setSimulationResult
}) => {
const [society, setSociety] = useState('');
const [societies, setSocieties] = useState<string[]>([]);
const [viewMode, setViewMode] = useState('Job Title');
const [isRefreshing, setIsRefreshing] = useState(false);
const [isBuilding, setIsBuilding] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(window.innerWidth > 1200);
const [isLeftPanelOpen, setIsLeftPanelOpen] = useState(window.innerWidth > 768);
const [activeModal, setActiveModal] = useState<'none' | 'assemble' | 'feedback' | 'context' | 'test'>('none');
const [formData, setFormData] = useState({
customerProfile: '',
companyInfo: '',
personaScale: 50,
feedback: '',
context: '',
testName: ''
});
// Handle window resize for mobile responsiveness
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 768) {
setIsLeftPanelOpen(false);
setIsRightPanelOpen(false);
}
};
window.addEventListener('resize', handleResize);
// Fetch real focus groups
const fetchSocieties = async () => {
try {
let names: string[] = [];
// 1. Fetch from Gradio (Templates/Global)
const result = await GradioService.listSimulations();
const list = Array.isArray(result) ? result : (result?.data?.[0] || []);
// GradioService now returns the array of focus group objects directly
if (Array.isArray(list)) {
const gradioNames = list
.map((s: any) => {
if (typeof s === 'string') return s;
if (typeof s === 'object' && s !== null) return s.id || s.name || '';
return '';
})
// Filter out non-user groups as requested
.filter(name => name.length > 0 && !name.toLowerCase().includes('default') && !name.toLowerCase().includes('template') && !name.toLowerCase().includes('current'));
names = [...gradioNames];
}
// 2. Fetch User-created groups from local storage
if (user?.preferred_username) {
try {
const localResp = await fetch(`/api/list-data?type=assemble&user=${user.preferred_username}`);
if (localResp.ok) {
const localData = await localResp.json();
const localNames = localData.map((d: any) => d.data.customerProfile.substring(0, 20) + '...');
names = [...names, ...localNames];
}
} catch (e) {
console.error("Failed to fetch local groups", e);
}
}
// Remove duplicates
const uniqueNames = Array.from(new Set(names));
setSocieties(uniqueNames);
if (uniqueNames.length > 0) {
if (!society || !uniqueNames.includes(society)) {
setSociety(uniqueNames[0]);
}
} else {
setSociety('Standard Example');
}
} catch (e) {
console.error("Failed to fetch focus groups", e);
}
};
fetchSocieties();
return () => window.removeEventListener('resize', handleResize);
}, []);
// Function to simulate rebuilding the graph when settings change
const handleSettingChange = (setter: (val: string) => void, value: string) => {
if (value === society || (value === viewMode && setter === setViewMode)) return; // No change
setter(value);
setIsBuilding(true);
// Simulate network delay
setTimeout(() => {
setIsBuilding(false);
}, 1500);
};
const currentFilters = VIEW_FILTERS[viewMode] || VIEW_FILTERS['Country'];
return (
<div className="flex h-screen w-screen overflow-hidden bg-black text-white font-sans relative">
{/* Sidebar */}
<aside className={`fixed md:relative w-[300px] h-full flex-shrink-0 border-r border-gray-800 flex flex-col bg-[#0a0a0a] z-40 transition-all duration-300 ${isLeftPanelOpen ? 'translate-x-0' : '-translate-x-full md:-ml-[300px]'}`}>
{/* Header */}
<div className="p-4 h-16 border-b border-gray-800 flex items-center justify-between">
<div className="flex items-center gap-2 cursor-pointer" onClick={onBack}>
<div className="w-6 h-6 flex items-center justify-center font-bold text-white">Λ</div>
<span className="font-semibold tracking-tight text-xs">Branding Content Testing</span>
</div>
<button
onClick={() => setIsLeftPanelOpen(false)}
className="text-gray-500 hover:text-white"
>
<PanelLeftClose size={18}/>
</button>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Focus Group Control */}
<div className="space-y-2">
<label className="text-xs text-gray-500 font-medium uppercase tracking-wider">Focus Group</label>
<div className="relative group">
<select
value={society}
onChange={(e) => handleSettingChange(setSociety, e.target.value)}
className="w-full appearance-none bg-[#111] border border-gray-700 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-teal-500 cursor-pointer"
>
{societies.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none w-4 h-4" />
</div>
</div>
{/* Current View Control */}
<div className="space-y-2">
<label className="text-xs text-gray-500 font-medium uppercase tracking-wider">Current View</label>
<div className="relative">
<select
value={viewMode}
onChange={(e) => handleSettingChange(setViewMode, e.target.value)}
className="w-full appearance-none bg-[#111] border border-gray-700 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-teal-500 cursor-pointer"
>
<option>Country</option>
<option>Job Title</option>
<option>Sentiment</option>
<option>Activity Level</option>
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none w-4 h-4" />
</div>
</div>
<div className="h-px bg-gray-800 my-4" />
{/* Actions */}
<button
onClick={() => setActiveModal('assemble')}
className="w-full flex items-center justify-between text-left text-sm text-gray-300 hover:text-white group py-2 border-b border-gray-800/50 mb-1"
>
<span>Assemble new group</span>
<Plus size={18} className="text-gray-500 group-hover:text-white" />
</button>
<button
onClick={() => setActiveModal('test')}
className="w-full flex items-center justify-between text-left text-sm text-gray-300 hover:text-white group py-2 border-b border-gray-800/50 mb-1"
>
<span>Create a new test</span>
<Plus size={18} className="text-gray-500 group-hover:text-white" />
</button>
<button
onClick={() => setActiveModal('context')}
className="w-full flex items-center justify-between text-left text-sm text-gray-300 hover:text-white group py-2"
>
<span>Request new context</span>
<Plus size={18} className="text-gray-500 group-hover:text-white" />
</button>
{/* Global Chat Button (Sidebar) */}
<button
onClick={onOpenChat}
className="w-full flex items-center gap-3 px-4 py-3 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors border border-gray-700"
>
<MessageCircle size={18} />
<span className="font-medium text-sm">Open Global Chat</span>
</button>
{/* Setup Warning / Info Box */}
<div className="bg-blue-900/30 border border-blue-700/50 rounded-xl p-4 mt-4">
<div className="flex items-center gap-2 text-blue-200 font-bold text-xs mb-1">
<Info size={14}/>
<span>Configuration Required</span>
</div>
<p className="text-blue-200/70 text-[10px] leading-relaxed">
Assemble new group and create a new test are required to be configured first before using any chat.
</p>
</div>
{/* History List */}
<div className="space-y-1 pt-4">
<label className="text-xs text-gray-500 font-medium uppercase tracking-wider mb-2 block">Recent Tests</label>
<div className="text-sm text-gray-400 py-2 px-2 hover:bg-gray-800/50 rounded cursor-pointer truncate">
Sustainable Luxury Narrative
</div>
<div className="text-sm text-gray-400 py-2 px-2 hover:bg-gray-800/50 rounded cursor-pointer truncate">
Radical Transparency Voice
</div>
<div className="text-sm text-gray-400 py-2 px-2 hover:bg-gray-800/50 rounded cursor-pointer truncate">
Gen-Z Greenwash Perception
</div>
</div>
</div>
{/* Footer */}
<div className="border-t border-gray-800 p-4 space-y-1 bg-[#0a0a0a]">
{user ? (
<div className="flex items-center gap-3 py-3 border-b border-gray-800 mb-2">
{user.avatarUrl && <img src={user.avatarUrl} alt={user.preferred_username} className="w-8 h-8 rounded-full border border-gray-700" />}
<div className="flex flex-col">
<span className="text-xs font-semibold text-gray-200">{user.preferred_username}</span>
<span className="text-[10px] text-gray-500">Credits: Unlimited</span>
</div>
</div>
) : (
<div className="py-2 border-b border-gray-800 mb-2">
<button
onClick={onLogin}
className="w-full py-2 bg-white text-black rounded-lg text-xs font-bold hover:bg-gray-200 transition-colors"
>
Sign in with Hugging Face
</button>
</div>
)}
<MenuItem icon={<MessageSquare size={16}/>} label="Leave Feedback" onClick={() => setActiveModal('feedback')} />
<MenuItem icon={<BookOpen size={16}/>} label="Product Guide" onClick={onOpenGuide} />
{user && <MenuItem icon={<LogOut size={16}/>} label="Log Out" onClick={onLogout} />}
<div className="pt-4 text-[10px] text-gray-600">Version 2.1</div>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 flex flex-col relative bg-black overflow-hidden">
{/* Top Navigation Overlay */}
<div className="absolute top-4 left-4 right-4 z-30 flex justify-between items-center pointer-events-none">
{/* Left Toggle (when sidebar closed) */}
<button
onClick={() => setIsLeftPanelOpen(true)}
className={`pointer-events-auto p-2 bg-gray-900/80 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition-opacity ${isLeftPanelOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}
>
<Menu size={20} />
</button>
{/* Right Toggle (when output closed) */}
<button
onClick={() => setIsRightPanelOpen(true)}
className={`pointer-events-auto p-2 bg-gray-900/80 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition-opacity ml-auto ${isRightPanelOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}
>
<PanelRightClose size={20} className="rotate-180" />
</button>
</div>
<div className="absolute top-6 left-6 right-6 z-10 flex justify-center pointer-events-none">
{/* Legend / Filter Chips */}
<div className="flex flex-wrap justify-center gap-2 pointer-events-auto max-w-[60%]">
{currentFilters.map((filter, idx) => (
<FilterChip key={idx} color={filter.color} label={filter.label} />
))}
</div>
</div>
{/* Graph Container */}
<div className="flex-1 w-full h-full">
<SimulationGraph
isBuilding={isBuilding}
societyType={society}
viewMode={viewMode}
onStartChat={onOpenChat}
/>
</div>
{/* Modals */}
{activeModal !== 'none' && (
<div className="absolute inset-0 z-[60] flex items-center justify-center p-6 bg-black/60 backdrop-blur-sm">
<div className="bg-[#111] border border-gray-800 rounded-2xl w-full max-w-md overflow-hidden shadow-2xl">
<div className="p-6 border-b border-gray-800 flex items-center justify-between">
<h3 className="font-semibold text-lg">
{activeModal === 'assemble' && "Assemble New Group"}
{activeModal === 'feedback' && "Leave Feedback"}
{activeModal === 'context' && "Request New Context"}
{activeModal === 'test' && "Create New Test"}
</h3>
<button onClick={() => setActiveModal('none')} className="text-gray-500 hover:text-white">
<PanelRightClose size={20} />
</button>
</div>
<div className="p-6 space-y-4">
{activeModal === 'assemble' && (
<>
<div className="space-y-1.5">
<label className="text-xs text-gray-400 font-medium">Customer Profile</label>
<textarea
value={formData.customerProfile}
onChange={(e) => setFormData({...formData, customerProfile: e.target.value})}
className="w-full bg-black border border-gray-800 rounded-lg p-3 text-sm focus:border-teal-500 outline-none h-24 resize-none"
placeholder="Describe your ideal audience..."
/>
</div>
<div className="space-y-1.5">
<label className="text-xs text-gray-400 font-medium">Company Info</label>
<textarea
value={formData.companyInfo}
onChange={(e) => setFormData({...formData, companyInfo: e.target.value})}
className="w-full bg-black border border-gray-800 rounded-lg p-3 text-sm focus:border-teal-500 outline-none h-24 resize-none"
placeholder="Tell us about your brand..."
/>
</div>
<div className="space-y-1.5">
<div className="flex justify-between">
<label className="text-xs text-gray-400 font-medium">Persona Scale</label>
<span className="text-xs text-teal-500 font-bold">{formData.personaScale}</span>
</div>
<input
type="range" min="1" max="100"
value={formData.personaScale}
onChange={(e) => setFormData({...formData, personaScale: parseInt(e.target.value)})}
className="w-full accent-teal-500"
/>
<div className="flex justify-between text-[10px] text-gray-600 uppercase font-bold">
<span>Conservative</span>
<span>Radical</span>
</div>
</div>
</>
)}
{activeModal === 'feedback' && (
<div className="space-y-1.5">
<label className="text-xs text-gray-400 font-medium">Your Feedback</label>
<textarea
value={formData.feedback}
onChange={(e) => setFormData({...formData, feedback: e.target.value})}
className="w-full bg-black border border-gray-800 rounded-lg p-3 text-sm focus:border-teal-500 outline-none h-40 resize-none"
placeholder="How can we improve?"
/>
</div>
)}
{activeModal === 'context' && (
<div className="space-y-1.5">
<label className="text-xs text-gray-400 font-medium">New Context / Fuse Box</label>
<textarea
value={formData.context}
onChange={(e) => setFormData({...formData, context: e.target.value})}
className="w-full bg-black border border-gray-800 rounded-lg p-3 text-sm focus:border-teal-500 outline-none h-40 resize-none"
placeholder="Specify the testing environment or scenario..."
/>
</div>
)}
{activeModal === 'test' && (
<>
<div className="space-y-1.5">
<label className="text-xs text-gray-400 font-medium">Test Name</label>
<input
type="text"
value={formData.testName}
onChange={(e) => setFormData({...formData, testName: e.target.value})}
className="w-full bg-black border border-gray-800 rounded-lg p-3 text-sm focus:border-teal-500 outline-none"
placeholder="Campaign Launch 2024..."
/>
</div>
<div className="space-y-1.5">
<label className="text-xs text-gray-400 font-medium">Brand Asset for Testing</label>
<div className="flex items-center justify-center w-full">
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-800 border-dashed rounded-lg cursor-pointer bg-black hover:bg-gray-900 transition-colors">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Plus className="w-8 h-8 mb-4 text-gray-500" />
<p className="mb-2 text-sm text-gray-500"><span className="font-semibold">Click to upload</span> or drag and drop</p>
<p className="text-xs text-gray-500">SVG, PNG, JPG (MAX. 800x400px)</p>
</div>
<input type="file" className="hidden" multiple accept="image/*" />
</label>
</div>
</div>
</>
)}
</div>
<div className="p-6 border-t border-gray-800 flex gap-3">
<button
onClick={() => setActiveModal('none')}
className="flex-1 py-2.5 rounded-xl border border-gray-800 text-sm font-medium hover:bg-gray-900 transition-colors"
>
Cancel
</button>
<button
onClick={async () => {
if (activeModal === 'assemble') {
setIsBuilding(true);
setIsRightPanelOpen(true);
setActiveModal('none');
try {
// 1. Generate personas based on profile and company info
const jobRes = await GradioService.generatePersonas(formData.companyInfo, formData.customerProfile, Math.ceil(formData.personaScale / 20));
const personas = [jobRes.job_id];
// 2. Generate social network for these personas
const groupName = formData.customerProfile.substring(0, 20);
await GradioService.generateSocialNetwork(groupName, formData.personaScale, 'scale_free', groupName);
// 3. Save to backend
await fetch('/api/save-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'assemble',
data: { ...formData, generatedPersonas: personas },
user: user?.preferred_username || 'anonymous'
})
});
// 4. Update UI
setSocieties(prev => [groupName, ...prev]);
setSociety(groupName);
alert('Focus group assembled and selected!');
} catch (e) {
console.error(e);
alert('Failed to assemble group via API.');
} finally {
setIsBuilding(false);
}
return;
}
// Save to backend logic for other modals
try {
const response = await fetch('/api/save-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: activeModal,
data: formData,
user: user?.preferred_username || 'anonymous'
})
});
if (response.ok) {
alert('Successfully saved!');
setActiveModal('none');
}
} catch (e) {
console.error(e);
alert('Successfully submitted (Local simulation)');
setActiveModal('none');
}
}}
className="flex-1 py-2.5 rounded-xl bg-teal-600 text-white text-sm font-bold hover:bg-teal-500 transition-colors shadow-lg shadow-teal-900/20"
>
Confirm
</button>
</div>
</div>
</div>
)}
{/* Floating Chat Button (Bottom) */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-30">
<button
onClick={onOpenChat}
className="flex items-center gap-2 bg-black/80 backdrop-blur-md border border-gray-700 text-white px-6 py-3 rounded-full shadow-2xl hover:bg-gray-900 transition-all hover:scale-105"
>
<MessageCircle size={20} />
<span className="font-medium">Open Simulation Chat</span>
</button>
</div>
</main>
{/* Right Sidebar (Output) */}
<aside className={`fixed right-0 md:relative w-[300px] h-full flex-shrink-0 border-l border-gray-800 flex flex-col bg-[#0a0a0a] z-40 transition-all duration-300 ${isRightPanelOpen ? 'translate-x-0' : 'translate-x-full md:-mr-[300px]'}`}>
<div className="p-4 h-16 border-b border-gray-800 flex items-center justify-between">
<span className="font-semibold tracking-tight uppercase text-xs text-gray-500">Output</span>
<button onClick={() => setIsRightPanelOpen(false)} className="text-gray-500 hover:text-white"><PanelRightClose size={18}/></button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{isBuilding && (
<div className="bg-teal-900/20 border border-teal-500/30 rounded-xl p-4 mb-4 animate-in fade-in slide-in-from-top-2">
<div className="flex items-center gap-3">
<RefreshCw className="w-4 h-4 text-teal-400 animate-spin" />
<div className="flex flex-col">
<span className="text-xs font-bold text-teal-400">Assembling...</span>
<span className="text-[10px] text-teal-400/60 font-mono">Constructing network mesh</span>
</div>
</div>
</div>
)}
<div className="bg-gray-900/50 border border-gray-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-gray-500">Simulation Results</p>
{simulationResult && (
<button
onClick={async () => {
setIsRefreshing(true);
try {
// In SimulationPage we don't track the running simulation's job ID.
// To get the status, we would need the job ID returned by startSimulation.
// If we are polling for the group assembly job, we might need to store it.
// For now we try to poll with the group name, though it may fail if it's not a job ID.
// Ensure that we poll only if society contains a job ID. If it is a group name, getSimulationStatus will fail on the new API.
const status = await GradioService.getSimulationStatus(simulationResult?.job_id || society);
setSimulationResult({
status: "Updated",
message: "Latest status gathered from API.",
data: status
});
} catch (e) {
console.error(e);
} finally {
setIsRefreshing(false);
}
}}
className="p-1 hover:bg-gray-800 rounded text-gray-500 hover:text-white"
title="Refresh Status"
>
<RefreshCw size={12} className={isRefreshing ? "animate-spin" : ""} />
</button>
)}
</div>
{!simulationResult ? (
<div className="text-sm text-gray-400 italic text-center py-8">
Results will appear here after running a simulation.
</div>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs font-medium text-green-400">
<div className="w-1.5 h-1.5 rounded-full bg-green-500"></div>
{simulationResult.status}
</div>
<p className="text-[11px] text-gray-400">{simulationResult.message}</p>
{simulationResult.data && (
<pre className="text-[10px] bg-black/50 p-2 rounded max-h-96 overflow-y-auto custom-scrollbar whitespace-pre-wrap text-gray-300 border border-gray-800">
{JSON.stringify(simulationResult.data, null, 2)}
</pre>
)}
<p className="text-[10px] text-gray-600 mt-4 italic">
Note: Complete simulation results can take up to 30 minutes to be fully processed by the API.
</p>
</div>
)}
</div>
</div>
</aside>
</div>
);
};
// Helper Components
interface MenuItemProps {
icon: React.ReactNode;
label: string;
highlight?: boolean;
onClick?: () => void;
}
const MenuItem: React.FC<MenuItemProps> = ({ icon, label, highlight = false, onClick }) => (
<button
onClick={onClick}
className={`w-full flex items-center gap-3 px-2 py-2.5 rounded-md text-sm transition-colors ${highlight ? 'text-teal-400 hover:bg-teal-950/30' : 'text-gray-400 hover:bg-gray-800 hover:text-white'}`}
>
{icon}
<span>{label}</span>
</button>
);
interface FilterChipProps {
color: string;
label: string;
}
const FilterChip: React.FC<FilterChipProps> = ({ color, label }) => (
<button className="flex items-center gap-2 bg-gray-900/80 backdrop-blur border border-gray-700 rounded-full pl-2 pr-4 py-1.5 hover:border-gray-500 transition-colors">
<span className={`w-2.5 h-2.5 rounded-full ${color}`}></span>
<span className="text-xs font-medium text-gray-300">{label}</span>
</button>
);
export default SimulationPage;