Spaces:
Sleeping
Sleeping
| import React, { useMemo, useState, useRef, useCallback } from 'react'; | |
| import { useQuery } from '@tanstack/react-query'; | |
| import { | |
| Activity, | |
| Zap, | |
| Target, | |
| TrendingUp, | |
| Database, | |
| Cpu, | |
| Globe, | |
| Play, | |
| Pause, | |
| ChevronDown, | |
| ChevronRight, | |
| Terminal, | |
| Plug, | |
| Eye, | |
| Bot, | |
| X, | |
| Check, | |
| Layers, | |
| FileText, | |
| Plus, | |
| Info, | |
| Link, | |
| MessageSquare, | |
| Image as ImageIcon, | |
| FolderOpen, | |
| Trash2, | |
| AlertCircle, | |
| Download, | |
| Copy, | |
| Navigation, | |
| Search, | |
| Code, | |
| CheckCircle, | |
| XCircle, | |
| Clock, | |
| FileJson, | |
| Sparkles, | |
| Brain, | |
| Compass, | |
| Shield, | |
| type LucideIcon, | |
| } from 'lucide-react'; | |
| import { Badge } from '@/components/ui/Badge'; | |
| import { classNames } from '@/utils/helpers'; | |
| import { apiClient, type ScrapeStep, type ScrapeResponse, type ScrapeRequest } from '@/api/client'; | |
| // Step action to icon mapping | |
| const getStepIcon = (action: string): LucideIcon => { | |
| const iconMap: Record<string, LucideIcon> = { | |
| 'initialize': Sparkles, | |
| 'navigate': Navigation, | |
| 'extract': Search, | |
| 'plugins': Plug, | |
| 'planner': Brain, | |
| 'planner_python': Code, | |
| 'navigator': Compass, | |
| 'navigator_python': Code, | |
| 'extractor_python': Code, | |
| 'verify': Shield, | |
| 'verifier': Shield, | |
| 'complete': CheckCircle, | |
| 'mcp_search': Search, | |
| 'python_sandbox': Terminal, | |
| 'site_template': FileText, | |
| 'tool_call': Zap, | |
| 'error': XCircle, | |
| }; | |
| return iconMap[action] || Activity; | |
| }; | |
| // Step action color mapping | |
| const getStepColor = (action: string, status: string): string => { | |
| if (status === 'failed') return 'text-red-400 bg-red-500/20 border-red-500/30'; | |
| if (status === 'running') return 'text-cyan-400 bg-cyan-500/20 border-cyan-500/30 animate-pulse'; | |
| const colorMap: Record<string, string> = { | |
| 'initialize': 'text-purple-400 bg-purple-500/20 border-purple-500/30', | |
| 'navigate': 'text-blue-400 bg-blue-500/20 border-blue-500/30', | |
| 'extract': 'text-emerald-400 bg-emerald-500/20 border-emerald-500/30', | |
| 'plugins': 'text-amber-400 bg-amber-500/20 border-amber-500/30', | |
| 'planner': 'text-pink-400 bg-pink-500/20 border-pink-500/30', | |
| 'planner_python': 'text-orange-400 bg-orange-500/20 border-orange-500/30', | |
| 'navigator': 'text-indigo-400 bg-indigo-500/20 border-indigo-500/30', | |
| 'navigator_python': 'text-orange-400 bg-orange-500/20 border-orange-500/30', | |
| 'extractor_python': 'text-orange-400 bg-orange-500/20 border-orange-500/30', | |
| 'verify': 'text-teal-400 bg-teal-500/20 border-teal-500/30', | |
| 'verifier': 'text-teal-400 bg-teal-500/20 border-teal-500/30', | |
| 'complete': 'text-green-400 bg-green-500/20 border-green-500/30', | |
| 'mcp_search': 'text-cyan-400 bg-cyan-500/20 border-cyan-500/30', | |
| 'python_sandbox': 'text-yellow-400 bg-yellow-500/20 border-yellow-500/30', | |
| 'site_template': 'text-violet-400 bg-violet-500/20 border-violet-500/30', | |
| 'tool_call': 'text-yellow-300 bg-yellow-500/20 border-yellow-500/30', | |
| }; | |
| return colorMap[action] || 'text-slate-400 bg-slate-500/20 border-slate-500/30'; | |
| }; | |
| const isAgentPluginId = (pluginId: string): boolean => { | |
| const lowered = pluginId.toLowerCase(); | |
| return lowered.startsWith('skill-') || lowered === 'web_scraper'; | |
| }; | |
| // Step Accordion Component | |
| interface StepAccordionItemProps { | |
| step: ScrapeStep; | |
| isExpanded: boolean; | |
| onToggle: () => void; | |
| isLatest: boolean; | |
| } | |
| const StepAccordionItem: React.FC<StepAccordionItemProps> = ({ step, isExpanded, onToggle, isLatest }) => { | |
| const Icon = getStepIcon(step.action); | |
| const colorClasses = getStepColor(step.action, step.status); | |
| // Check if this is a tool call | |
| const isToolCall = step.action === 'tool_call'; | |
| const toolName = (step.extracted_data?.tool_name as string) || ''; | |
| const toolDescription = (step.extracted_data?.tool_description as string) || ''; | |
| const toolParameters = (step.extracted_data?.parameters as Record<string, any>) || {}; | |
| const toolResult = (step.extracted_data?.result as Record<string, any>) || {}; | |
| return ( | |
| <div className={classNames( | |
| 'border rounded-lg overflow-hidden transition-all', | |
| isLatest ? 'ring-2 ring-cyan-500/50' : '', | |
| colorClasses.split(' ').slice(1).join(' ') | |
| )}> | |
| <button | |
| onClick={onToggle} | |
| className="w-full flex items-center justify-between px-4 py-3 hover:bg-white/5 transition-colors" | |
| > | |
| <div className="flex items-center gap-3 flex-1 min-w-0"> | |
| <div className={classNames('p-2 rounded-lg flex-shrink-0', colorClasses.split(' ').slice(1, 3).join(' '))}> | |
| <Icon className={classNames('w-4 h-4', colorClasses.split(' ')[0])} /> | |
| </div> | |
| <div className="text-left min-w-0 flex-1"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-sm font-medium text-white"> | |
| {isToolCall | |
| ? (toolName ? `🔧 ${toolName}` : 'Tool Call') | |
| : step.action.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())} | |
| </span> | |
| <Badge | |
| variant={step.status === 'completed' ? 'success' : step.status === 'failed' ? 'error' : 'info'} | |
| size="sm" | |
| > | |
| {step.status} | |
| </Badge> | |
| </div> | |
| <p className="text-xs text-slate-400 truncate">{step.message}</p> | |
| {isToolCall && toolDescription && ( | |
| <p className="text-[10px] text-slate-500 mt-0.5">{toolDescription}</p> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3 flex-shrink-0"> | |
| <div className="text-right"> | |
| <span className="text-xs text-slate-500">Step {step.step_number}</span> | |
| {step.reward > 0 && ( | |
| <p className="text-xs text-emerald-400">+{step.reward.toFixed(2)}</p> | |
| )} | |
| </div> | |
| {isExpanded ? ( | |
| <ChevronDown className="w-4 h-4 text-slate-400" /> | |
| ) : ( | |
| <ChevronRight className="w-4 h-4 text-slate-400" /> | |
| )} | |
| </div> | |
| </button> | |
| {isExpanded && ( | |
| <div className="px-4 py-3 border-t border-white/10 bg-slate-900/50 space-y-3"> | |
| {/* Tool Call Specific Details */} | |
| {isToolCall && ( | |
| <> | |
| <div className="bg-slate-800/50 rounded-lg p-3 space-y-2"> | |
| <div className="flex items-center gap-2"> | |
| <Zap className="w-3 h-3 text-yellow-400" /> | |
| <span className="text-xs font-semibold text-yellow-400">Tool Call Details</span> | |
| </div> | |
| {toolDescription && ( | |
| <div className="text-xs text-slate-300"> | |
| <span className="text-slate-500">Description:</span> {toolDescription} | |
| </div> | |
| )} | |
| {Object.keys(toolParameters).length > 0 && ( | |
| <div> | |
| <span className="text-xs text-slate-500">Parameters:</span> | |
| <pre className="text-xs text-cyan-300 bg-slate-900/70 rounded p-2 mt-1 overflow-auto max-h-20 font-mono"> | |
| {JSON.stringify(toolParameters, null, 2)} | |
| </pre> | |
| </div> | |
| )} | |
| {Object.keys(toolResult).length > 0 && ( | |
| <div> | |
| <span className="text-xs text-slate-500">Result:</span> | |
| <pre className="text-xs text-emerald-300 bg-slate-900/70 rounded p-2 mt-1 overflow-auto max-h-20 font-mono"> | |
| {JSON.stringify(toolResult, null, 2)} | |
| </pre> | |
| </div> | |
| )} | |
| </div> | |
| </> | |
| )} | |
| {/* Step Details */} | |
| <div className="grid grid-cols-2 gap-4 text-xs"> | |
| <div> | |
| <span className="text-slate-500">Action:</span> | |
| <span className="ml-2 text-slate-300">{step.action}</span> | |
| </div> | |
| <div> | |
| <span className="text-slate-500">Status:</span> | |
| <span className={classNames( | |
| 'ml-2', | |
| step.status === 'completed' ? 'text-emerald-400' : | |
| step.status === 'failed' ? 'text-red-400' : 'text-cyan-400' | |
| )}>{step.status}</span> | |
| </div> | |
| {step.url && ( | |
| <div className="col-span-2"> | |
| <span className="text-slate-500">URL:</span> | |
| <span className="ml-2 text-cyan-400 truncate">{step.url}</span> | |
| </div> | |
| )} | |
| {step.duration_ms && ( | |
| <div> | |
| <span className="text-slate-500">Duration:</span> | |
| <span className="ml-2 text-slate-300">{step.duration_ms.toFixed(0)}ms</span> | |
| </div> | |
| )} | |
| <div> | |
| <span className="text-slate-500">Reward:</span> | |
| <span className="ml-2 text-emerald-400">{step.reward.toFixed(2)}</span> | |
| </div> | |
| </div> | |
| {/* Extracted Data (non-tool calls or if additional data exists) */} | |
| {step.extracted_data && Object.keys(step.extracted_data).length > 0 && !isToolCall && ( | |
| <div className="mt-3"> | |
| <p className="text-xs text-slate-500 mb-2">Extracted Data:</p> | |
| <pre className="text-xs text-slate-300 bg-slate-800/50 rounded-lg p-3 overflow-auto max-h-40 font-mono"> | |
| {JSON.stringify(step.extracted_data, null, 2)} | |
| </pre> | |
| </div> | |
| )} | |
| {/* Timestamp */} | |
| <div className="flex items-center gap-2 text-[10px] text-slate-600"> | |
| <Clock className="w-3 h-3" /> | |
| {new Date(step.timestamp).toLocaleTimeString()} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| // Types | |
| interface TaskInput { | |
| urls: string[]; | |
| instruction: string; | |
| outputInstruction: string; | |
| taskType: 'low' | 'medium' | 'high'; | |
| selectedModel: string; | |
| selectedVisionModel: string; | |
| selectedAgents: string[]; | |
| enabledPlugins: string[]; | |
| } | |
| interface LogEntry { | |
| id: string; | |
| timestamp: string; | |
| level: 'info' | 'warn' | 'error' | 'debug'; | |
| message: string; | |
| source?: string; | |
| } | |
| interface Asset { | |
| id: string; | |
| type: 'url' | 'image' | 'file' | 'data'; | |
| name: string; | |
| source: 'user' | 'ai'; | |
| content: string; | |
| timestamp: string; | |
| } | |
| interface MemoryEntry { | |
| id: string; | |
| type: 'short_term' | 'working' | 'long_term' | 'shared'; | |
| content: string; | |
| timestamp: string; | |
| } | |
| interface PluginInfo { | |
| id: string; | |
| name: string; | |
| description: string; | |
| category: string; | |
| installed: boolean; | |
| } | |
| interface AgentInfo { | |
| type: string; | |
| name: string; | |
| description: string; | |
| } | |
| interface ModelInfo { | |
| provider: string; | |
| model: string; | |
| name: string; | |
| description?: string; | |
| } | |
| // View type | |
| type ViewType = 'input' | 'dashboard'; | |
| // Info Popup Component | |
| const InfoPopup: React.FC<{ | |
| isOpen: boolean; | |
| onClose: () => void; | |
| title: string; | |
| description: string; | |
| details?: Record<string, string>; | |
| }> = ({ isOpen, onClose, title, description, details }) => { | |
| if (!isOpen) return null; | |
| return ( | |
| <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm"> | |
| <div className="bg-gray-800 border border-gray-600 rounded-xl shadow-2xl w-full max-w-md p-5"> | |
| <div className="flex items-start justify-between mb-4"> | |
| <div className="flex items-center gap-3"> | |
| <div className="p-2 bg-cyan-500/20 rounded-lg"> | |
| <Info className="w-5 h-5 text-cyan-400" /> | |
| </div> | |
| <h3 className="font-semibold text-white text-lg">{title}</h3> | |
| </div> | |
| <button onClick={onClose} className="p-1 text-gray-400 hover:text-white transition-colors"> | |
| <X className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| <p className="text-gray-300 text-sm mb-4">{description}</p> | |
| {details && ( | |
| <div className="space-y-2 pt-3 border-t border-gray-700"> | |
| {Object.entries(details).map(([key, value]) => ( | |
| <div key={key} className="flex justify-between text-sm"> | |
| <span className="text-gray-500">{key}</span> | |
| <span className="text-gray-300">{value}</span> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| // Popup Components | |
| interface PopupProps { | |
| title: string; | |
| isOpen: boolean; | |
| onClose: () => void; | |
| children: React.ReactNode; | |
| size?: 'sm' | 'md' | 'lg'; | |
| } | |
| const Popup: React.FC<PopupProps> = ({ title, isOpen, onClose, children, size = 'md' }) => { | |
| if (!isOpen) return null; | |
| const sizeClasses = { | |
| sm: 'max-w-sm', | |
| md: 'max-w-lg', | |
| lg: 'max-w-2xl', | |
| }; | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"> | |
| <div className={`bg-gray-800 border border-gray-700 rounded-xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] overflow-hidden`}> | |
| <div className="flex items-center justify-between px-4 py-3 border-b border-gray-700"> | |
| <h3 className="font-semibold text-white">{title}</h3> | |
| <button onClick={onClose} className="p-1 text-gray-400 hover:text-white transition-colors"> | |
| <X className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| <div className="p-4 overflow-y-auto max-h-[65vh]">{children}</div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| // Accordion Component for sidebar | |
| interface AccordionProps { | |
| title: string; | |
| icon: React.ElementType; | |
| badge?: string | number; | |
| color: string; | |
| children: React.ReactNode; | |
| defaultOpen?: boolean; | |
| } | |
| const Accordion: React.FC<AccordionProps> = ({ title, icon: Icon, badge, color, children, defaultOpen = false }) => { | |
| const [isOpen, setIsOpen] = useState(defaultOpen); | |
| return ( | |
| <div className="border border-gray-700/50 rounded-lg overflow-hidden"> | |
| <button | |
| onClick={() => setIsOpen(!isOpen)} | |
| className="w-full flex items-center justify-between px-3 py-2 bg-gray-800/50 hover:bg-gray-800 transition-colors" | |
| > | |
| <div className="flex items-center gap-2"> | |
| <Icon className={`w-4 h-4 ${color}`} /> | |
| <span className="text-xs font-medium text-white">{title}</span> | |
| {badge !== undefined && Number(badge) > 0 && ( | |
| <Badge variant="neutral" size="sm">{badge}</Badge> | |
| )} | |
| </div> | |
| {isOpen ? <ChevronDown className="w-3 h-3 text-gray-400" /> : <ChevronRight className="w-3 h-3 text-gray-400" />} | |
| </button> | |
| {isOpen && <div className="p-2 bg-gray-900/30 border-t border-gray-700/50 space-y-1">{children}</div>} | |
| </div> | |
| ); | |
| }; | |
| // Main Dashboard Component | |
| export const Dashboard: React.FC = () => { | |
| // View state - 'input' or 'dashboard' | |
| const [currentView, setCurrentView] = useState<ViewType>('input'); | |
| // Task input state | |
| const [taskInput, setTaskInput] = useState<TaskInput>({ | |
| urls: [], | |
| instruction: '', | |
| outputInstruction: '', | |
| taskType: 'medium', | |
| selectedModel: 'groq/gpt-oss-120b', | |
| selectedVisionModel: '', | |
| selectedAgents: [], | |
| enabledPlugins: [], | |
| }); | |
| // URL input for adding | |
| const [newUrl, setNewUrl] = useState(''); | |
| // Logs | |
| const [logs, setLogs] = useState<LogEntry[]>([]); | |
| // Running state | |
| const [isRunning, setIsRunning] = useState(false); | |
| // Streaming state | |
| const [sessionId, setSessionId] = useState<string | null>(null); | |
| const [currentStep, setCurrentStep] = useState<ScrapeStep | null>(null); | |
| const [allSteps, setAllSteps] = useState<ScrapeStep[]>([]); | |
| const [expandedStepIndex, setExpandedStepIndex] = useState<number | null>(null); | |
| const [scrapeResult, setScrapeResult] = useState<ScrapeResponse | null>(null); | |
| const [progress, setProgress] = useState({ urlIndex: 0, totalUrls: 0, currentUrl: '' }); | |
| const [extractedData, setExtractedData] = useState<Record<string, unknown>>({}); | |
| const abortControllerRef = useRef<{ abort: () => void } | null>(null); | |
| const startLockRef = useRef(false); | |
| const seenStepKeysRef = useRef<Set<string>>(new Set()); | |
| const lastSessionInitRef = useRef<string | null>(null); | |
| // Assets | |
| const [assets, setAssets] = useState<Asset[]>([]); | |
| // Memories | |
| const [memories, setMemories] = useState<MemoryEntry[]>([]); | |
| const [newMemory, setNewMemory] = useState(''); | |
| // Popup states | |
| const [showModelPopup, setShowModelPopup] = useState(false); | |
| const [showVisionPopup, setShowVisionPopup] = useState(false); | |
| const [showAgentPopup, setShowAgentPopup] = useState(false); | |
| const [showPluginPopup, setShowPluginPopup] = useState(false); | |
| const [showTaskTypePopup, setShowTaskTypePopup] = useState(false); | |
| const [showMemoriesPopup, setShowMemoriesPopup] = useState(false); | |
| const [showAssetsPopup, setShowAssetsPopup] = useState(false); | |
| // Info popup | |
| const [infoPopup, setInfoPopup] = useState<{ isOpen: boolean; title: string; description: string; details?: Record<string, string> }>({ | |
| isOpen: false, | |
| title: '', | |
| description: '', | |
| }); | |
| // Episode stats - session-based, start at 0 | |
| const [stats, setStats] = useState({ episodes: 0, steps: 0, totalReward: 0, avgReward: 0 }); | |
| // API Queries | |
| const { data: health, isError: healthError } = useQuery({ | |
| queryKey: ['health'], | |
| queryFn: () => apiClient.healthCheck(), | |
| refetchInterval: 5000, | |
| }); | |
| const { data: agentsData } = useQuery({ | |
| queryKey: ['agents'], | |
| queryFn: async () => { | |
| const res = await fetch('/api/agents/list'); | |
| if (!res.ok) return { agent_types: [] }; | |
| return res.json(); | |
| }, | |
| }); | |
| const { data: pluginsData } = useQuery({ | |
| queryKey: ['plugins'], | |
| queryFn: async () => { | |
| const res = await fetch('/api/plugins'); | |
| if (!res.ok) return { plugins: {} }; | |
| return res.json(); | |
| }, | |
| }); | |
| const { data: memoryData } = useQuery({ | |
| queryKey: ['memory-stats'], | |
| queryFn: async () => { | |
| const res = await fetch('/api/memory/stats/overview'); | |
| if (!res.ok) return { total_count: 0 }; | |
| return res.json(); | |
| }, | |
| refetchInterval: 3000, | |
| }); | |
| const { data: settingsData } = useQuery({ | |
| queryKey: ['client-settings'], | |
| queryFn: async () => { | |
| const res = await fetch('/api/settings'); | |
| if (!res.ok) return { available_models: [], api_keys_configured: {} }; | |
| return res.json(); | |
| }, | |
| }); | |
| // Get installed plugins only | |
| const getInstalledPlugins = () => { | |
| if (!pluginsData?.plugins) return { mcps: [], apis: [], processors: [] }; | |
| const result: Record<string, PluginInfo[]> = {}; | |
| for (const [category, plugins] of Object.entries(pluginsData.plugins)) { | |
| if (category === 'skills') continue; | |
| result[category] = (plugins as PluginInfo[]).filter(p => p.installed); | |
| } | |
| return result; | |
| }; | |
| const installedPlugins = getInstalledPlugins(); | |
| const enabledNonAgentPlugins = useMemo( | |
| () => taskInput.enabledPlugins.filter((pluginId) => !isAgentPluginId(pluginId)), | |
| [taskInput.enabledPlugins] | |
| ); | |
| // Get agents | |
| const agents: AgentInfo[] = agentsData?.agent_types || []; | |
| // Get models grouped by provider | |
| const modelsByProvider = (): Record<string, ModelInfo[]> => { | |
| const models = settingsData?.available_models || []; | |
| const grouped: Record<string, ModelInfo[]> = {}; | |
| models.forEach((m: ModelInfo) => { | |
| if (!grouped[m.provider]) grouped[m.provider] = []; | |
| grouped[m.provider].push(m); | |
| }); | |
| return grouped; | |
| }; | |
| // Vision models | |
| const visionModels: ModelInfo[] = [ | |
| { provider: 'openai', model: 'gpt-4-vision-preview', name: 'GPT-4 Vision', description: 'OpenAI vision model' }, | |
| { provider: 'google', model: 'gemini-pro-vision', name: 'Gemini Pro Vision', description: 'Google vision model' }, | |
| { provider: 'anthropic', model: 'claude-3-opus-vision', name: 'Claude 3 Vision', description: 'Anthropic vision model' }, | |
| ]; | |
| // Task types | |
| const taskTypes = [ | |
| { id: 'low', name: 'Low', description: 'Simple single-page extraction', color: 'emerald', icon: '🟢' }, | |
| { id: 'medium', name: 'Medium', description: 'Multi-page navigation', color: 'amber', icon: '🟡' }, | |
| { id: 'high', name: 'High', description: 'Complex interactive tasks', color: 'red', icon: '🔴' }, | |
| ]; | |
| const detectOutputFormat = (outputInstruction: string): ScrapeRequest['output_format'] => { | |
| const normalized = outputInstruction.toLowerCase(); | |
| if (normalized.includes('csv')) return 'csv'; | |
| if (normalized.includes('markdown') || normalized.includes('md')) return 'markdown'; | |
| if (normalized.includes('text') || normalized.includes('plain')) return 'text'; | |
| return 'json'; | |
| }; | |
| // Add URL to list | |
| const handleAddUrl = () => { | |
| if (newUrl.trim() && !taskInput.urls.includes(newUrl.trim())) { | |
| const url = newUrl.trim(); | |
| setTaskInput(p => ({ ...p, urls: [...p.urls, url] })); | |
| // Also add to assets | |
| setAssets(prev => [...prev, { | |
| id: Date.now().toString(), | |
| type: 'url', | |
| name: url, | |
| source: 'user', | |
| content: url, | |
| timestamp: new Date().toISOString(), | |
| }]); | |
| setNewUrl(''); | |
| } | |
| }; | |
| // Remove URL | |
| const handleRemoveUrl = (url: string) => { | |
| setTaskInput(p => ({ ...p, urls: p.urls.filter(u => u !== url) })); | |
| setAssets(prev => prev.filter(a => a.content !== url)); | |
| }; | |
| // Add memory | |
| const handleAddMemory = () => { | |
| if (newMemory.trim()) { | |
| setMemories(prev => [...prev, { | |
| id: Date.now().toString(), | |
| type: 'working', | |
| content: newMemory.trim(), | |
| timestamp: new Date().toISOString(), | |
| }]); | |
| setNewMemory(''); | |
| } | |
| }; | |
| // Start task with streaming | |
| const handleStart = useCallback(() => { | |
| if (taskInput.urls.length === 0 && !taskInput.instruction) return; | |
| if (startLockRef.current || abortControllerRef.current) return; | |
| startLockRef.current = true; | |
| seenStepKeysRef.current.clear(); | |
| lastSessionInitRef.current = null; | |
| setStats(prev => ({ ...prev, episodes: prev.episodes + 1, steps: 0, totalReward: 0, avgReward: 0 })); | |
| setIsRunning(true); | |
| setCurrentView('dashboard'); | |
| setSessionId(null); | |
| setProgress({ urlIndex: 0, totalUrls: taskInput.urls.length, currentUrl: '' }); | |
| setScrapeResult(null); | |
| setExtractedData({}); | |
| setCurrentStep(null); | |
| setAllSteps([]); | |
| setExpandedStepIndex(null); | |
| // Build scrape request | |
| const scrapeRequest: ScrapeRequest = { | |
| assets: taskInput.urls, | |
| instructions: taskInput.instruction, | |
| output_instructions: taskInput.outputInstruction || 'Return as JSON', | |
| output_format: detectOutputFormat(taskInput.outputInstruction), | |
| complexity: taskInput.taskType, | |
| model: taskInput.selectedModel.split('/')[1] || 'llama-3.3-70b', | |
| provider: taskInput.selectedModel.split('/')[0] || 'nvidia', | |
| enable_memory: true, | |
| enable_plugins: enabledNonAgentPlugins, | |
| selected_agents: taskInput.selectedAgents, | |
| max_steps: 50, | |
| }; | |
| // Add initial log | |
| setLogs(prev => [...prev, { | |
| id: Date.now().toString(), | |
| timestamp: new Date().toISOString(), | |
| level: 'info', | |
| message: `Starting scrape with ${taskInput.urls.length} URLs`, | |
| source: 'system', | |
| }]); | |
| // Start streaming scrape | |
| abortControllerRef.current = apiClient.streamScrape( | |
| scrapeRequest, | |
| // onInit | |
| (sid) => { | |
| if (lastSessionInitRef.current === sid) return; | |
| lastSessionInitRef.current = sid; | |
| setSessionId(sid); | |
| setLogs(prev => [...prev, { | |
| id: Date.now().toString(), | |
| timestamp: new Date().toISOString(), | |
| level: 'info', | |
| message: `Session started: ${sid.slice(0, 8)}...`, | |
| source: 'scraper', | |
| }]); | |
| }, | |
| // onUrlStart | |
| (url, index, total) => { | |
| setProgress({ urlIndex: index, totalUrls: total, currentUrl: url }); | |
| setLogs(prev => [...prev, { | |
| id: Date.now().toString(), | |
| timestamp: new Date().toISOString(), | |
| level: 'info', | |
| message: `Processing URL ${index + 1}/${total}: ${url}`, | |
| source: 'scraper', | |
| }]); | |
| }, | |
| // onStep | |
| (step) => { | |
| const stepKey = `${step.step_number}|${step.action}|${step.url ?? ''}|${step.status}|${step.message}|${step.timestamp}`; | |
| if (seenStepKeysRef.current.has(stepKey)) return; | |
| seenStepKeysRef.current.add(stepKey); | |
| setCurrentStep(step); | |
| setAllSteps(prev => [...prev, step]); | |
| setStats(prev => { | |
| const steps = prev.steps + 1; | |
| const totalReward = prev.totalReward + step.reward; | |
| return { | |
| ...prev, | |
| steps, | |
| totalReward, | |
| avgReward: totalReward / steps, | |
| }; | |
| }); | |
| // Update extracted data | |
| if (step.extracted_data) { | |
| setExtractedData(prev => ({ ...prev, ...step.extracted_data })); | |
| } | |
| setLogs(prev => [...prev, { | |
| id: Date.now().toString(), | |
| timestamp: new Date().toISOString(), | |
| level: step.status === 'failed' ? 'error' : 'info', | |
| message: `[${step.action}] ${step.message} (reward: ${step.reward.toFixed(2)})`, | |
| source: step.url?.slice(0, 30) || 'step', | |
| }]); | |
| }, | |
| // onUrlComplete | |
| (url, _index) => { | |
| setLogs(prev => [...prev, { | |
| id: Date.now().toString(), | |
| timestamp: new Date().toISOString(), | |
| level: 'info', | |
| message: `Completed: ${url}`, | |
| source: 'scraper', | |
| }]); | |
| }, | |
| // onComplete | |
| (response) => { | |
| startLockRef.current = false; | |
| abortControllerRef.current = null; | |
| setScrapeResult(response); | |
| setIsRunning(false); | |
| setStats(prev => ({ | |
| ...prev, | |
| totalReward: response.total_reward, | |
| avgReward: response.total_reward / Math.max(prev.steps, 1), | |
| })); | |
| const extractedAssets = Object.entries(response.extracted_data).map(([url, data]) => ({ | |
| id: `${Date.now()}-${url}`, | |
| type: 'data' as const, | |
| name: `Data from ${url}`, | |
| source: 'ai' as const, | |
| content: JSON.stringify(data), | |
| timestamp: new Date().toISOString(), | |
| })); | |
| setAssets(prev => [...prev, ...extractedAssets]); | |
| setLogs(prev => [...prev, { | |
| id: Date.now().toString(), | |
| timestamp: new Date().toISOString(), | |
| level: response.errors.length > 0 ? 'warn' : 'info', | |
| message: `Scrape complete! Processed ${response.urls_processed} URLs, total reward: ${response.total_reward.toFixed(2)}`, | |
| source: 'system', | |
| }]); | |
| }, | |
| // onError | |
| (error, url) => { | |
| if (!url) { | |
| startLockRef.current = false; | |
| abortControllerRef.current = null; | |
| setIsRunning(false); | |
| } | |
| setLogs(prev => [...prev, { | |
| id: Date.now().toString(), | |
| timestamp: new Date().toISOString(), | |
| level: 'error', | |
| message: `Error${url ? ` (${url})` : ''}: ${error}`, | |
| source: 'scraper', | |
| }]); | |
| } | |
| ); | |
| }, [taskInput, enabledNonAgentPlugins]); | |
| // Stop task | |
| const handleStop = useCallback(() => { | |
| if (abortControllerRef.current) { | |
| abortControllerRef.current.abort(); | |
| abortControllerRef.current = null; | |
| } | |
| startLockRef.current = false; | |
| setIsRunning(false); | |
| setLogs(prev => [...prev, { | |
| id: Date.now().toString(), | |
| timestamp: new Date().toISOString(), | |
| level: 'warn', | |
| message: 'Scraping stopped by user', | |
| source: 'system', | |
| }]); | |
| }, []); | |
| // Copy result to clipboard | |
| const handleCopyResult = useCallback(() => { | |
| if (scrapeResult?.output) { | |
| navigator.clipboard.writeText(scrapeResult.output); | |
| setLogs(prev => [...prev, { | |
| id: Date.now().toString(), | |
| timestamp: new Date().toISOString(), | |
| level: 'info', | |
| message: 'Result copied to clipboard', | |
| source: 'system', | |
| }]); | |
| } | |
| }, [scrapeResult]); | |
| // Download result | |
| const handleDownloadResult = useCallback(() => { | |
| if (scrapeResult?.output) { | |
| const fileType = | |
| scrapeResult.output_format === 'csv' | |
| ? 'text/csv' | |
| : scrapeResult.output_format === 'markdown' | |
| ? 'text/markdown' | |
| : 'application/json'; | |
| const extension = | |
| scrapeResult.output_format === 'csv' | |
| ? 'csv' | |
| : scrapeResult.output_format === 'markdown' | |
| ? 'md' | |
| : scrapeResult.output_format === 'text' | |
| ? 'txt' | |
| : 'json'; | |
| const blob = new Blob([scrapeResult.output], { type: fileType }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `scrape-result-${sessionId?.slice(0, 8) || 'unknown'}.${extension}`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| }, [scrapeResult, sessionId]); | |
| // Format time | |
| const formatTime = (isoString: string) => { | |
| return new Date(isoString).toLocaleTimeString('en-US', { hour12: false }); | |
| }; | |
| const safeHostname = (url: string) => { | |
| try { | |
| return new URL(url).hostname; | |
| } catch { | |
| return url; | |
| } | |
| }; | |
| // Log level colors | |
| const getLogLevelColor = (level: LogEntry['level']) => { | |
| const colors = { info: 'text-cyan-400', warn: 'text-amber-400', error: 'text-red-400', debug: 'text-gray-400' }; | |
| return colors[level]; | |
| }; | |
| // Check system status | |
| const normalizedHealthStatus = typeof health?.status === 'string' | |
| ? health.status.toLowerCase() | |
| : null; | |
| const isSystemOnline = !healthError && ( | |
| normalizedHealthStatus === null | |
| || normalizedHealthStatus === 'healthy' | |
| || normalizedHealthStatus === 'ok' | |
| || normalizedHealthStatus === 'ready' | |
| ); | |
| // Show info popup | |
| const showInfo = (title: string, description: string, details?: Record<string, string>) => { | |
| setInfoPopup({ isOpen: true, title, description, details }); | |
| }; | |
| // ========== INPUT VIEW ========== | |
| if (currentView === 'input') { | |
| return ( | |
| <div className="h-screen flex flex-col bg-slate-900"> | |
| {/* System Status Banner */} | |
| {!isSystemOnline && ( | |
| <div className="flex-shrink-0 px-4 py-2 bg-red-500/20 border-b border-red-500/30 flex items-center justify-center gap-2"> | |
| <AlertCircle className="w-4 h-4 text-red-400" /> | |
| <span className="text-sm text-red-400">System is offline. Please check your connection.</span> | |
| </div> | |
| )} | |
| {/* Main Content - Full Screen Navy Blue Theme */} | |
| <div className="flex-1 flex flex-col items-center justify-center p-8 overflow-auto bg-gradient-to-br from-slate-900 via-slate-800 to-cyan-900/30"> | |
| <div className="w-full max-w-4xl space-y-8"> | |
| {/* Header */} | |
| <div className="text-center mb-12"> | |
| <div className="flex items-center justify-center gap-3 mb-4"> | |
| <div className="p-3 bg-cyan-500/20 rounded-xl border border-cyan-500/30"> | |
| <Zap className="w-8 h-8 text-cyan-400" /> | |
| </div> | |
| </div> | |
| <h1 className="text-4xl font-bold text-white mb-3 tracking-tight">ScrapeRL</h1> | |
| <p className="text-lg text-cyan-300/70">AI-Powered Intelligent Web Scraping</p> | |
| </div> | |
| {/* Assets Section */} | |
| <div className="bg-slate-800/60 backdrop-blur-sm border border-cyan-500/20 rounded-2xl p-6 shadow-xl shadow-cyan-500/5"> | |
| <div className="flex items-center gap-3 mb-4"> | |
| <div className="p-2 bg-cyan-500/20 rounded-lg"> | |
| <Link className="w-5 h-5 text-cyan-400" /> | |
| </div> | |
| <span className="text-lg font-semibold text-white">Assets</span> | |
| <Badge variant="info" size="sm">{taskInput.urls.length} URLs</Badge> | |
| </div> | |
| {/* URL Input */} | |
| <div className="flex gap-3 mb-4"> | |
| <input | |
| type="text" | |
| placeholder="Enter URL (e.g., https://example.com)" | |
| value={newUrl} | |
| onChange={(e) => setNewUrl(e.target.value)} | |
| onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()} | |
| className="flex-1 px-4 py-3 bg-slate-900/70 border border-cyan-500/30 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all" | |
| /> | |
| <button | |
| onClick={handleAddUrl} | |
| disabled={!newUrl.trim()} | |
| className="px-5 py-3 bg-cyan-500/20 hover:bg-cyan-500/30 disabled:bg-slate-700/50 border border-cyan-500/30 disabled:border-slate-600 text-cyan-400 disabled:text-slate-500 rounded-xl font-medium transition-all flex items-center gap-2" | |
| > | |
| <Plus className="w-5 h-5" /> | |
| Add | |
| </button> | |
| </div> | |
| {/* URL List */} | |
| {taskInput.urls.length > 0 && ( | |
| <div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto p-2 bg-slate-900/50 rounded-xl border border-slate-700/50"> | |
| {taskInput.urls.map((url, index) => ( | |
| <div | |
| key={index} | |
| className="flex items-center gap-2 px-3 py-2 bg-cyan-500/10 border border-cyan-500/30 text-cyan-300 rounded-lg text-sm group hover:bg-cyan-500/20 transition-colors" | |
| > | |
| <Globe className="w-4 h-4 text-cyan-400" /> | |
| <span className="max-w-[200px] truncate">{url}</span> | |
| <button | |
| onClick={() => handleRemoveUrl(url)} | |
| className="p-1 opacity-50 group-hover:opacity-100 hover:text-red-400 transition-all" | |
| > | |
| <X className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* Instructions Section */} | |
| <div className="bg-slate-800/60 backdrop-blur-sm border border-cyan-500/20 rounded-2xl p-6 shadow-xl shadow-cyan-500/5"> | |
| <div className="flex items-center gap-3 mb-4"> | |
| <div className="p-2 bg-purple-500/20 rounded-lg"> | |
| <MessageSquare className="w-5 h-5 text-purple-400" /> | |
| </div> | |
| <span className="text-lg font-semibold text-white">Instructions</span> | |
| </div> | |
| <textarea | |
| placeholder="What should I extract? (e.g., Extract all product names, prices, and descriptions from the page)" | |
| value={taskInput.instruction} | |
| onChange={(e) => setTaskInput(p => ({ ...p, instruction: e.target.value }))} | |
| rows={3} | |
| className="w-full px-4 py-3 bg-slate-900/70 border border-purple-500/30 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50 resize-none transition-all" | |
| /> | |
| </div> | |
| {/* Output Instructions */} | |
| <div className="bg-slate-800/60 backdrop-blur-sm border border-cyan-500/20 rounded-2xl p-6 shadow-xl shadow-cyan-500/5"> | |
| <div className="flex items-center gap-3 mb-4"> | |
| <div className="p-2 bg-emerald-500/20 rounded-lg"> | |
| <FileText className="w-5 h-5 text-emerald-400" /> | |
| </div> | |
| <span className="text-lg font-semibold text-white">Output Format</span> | |
| </div> | |
| <textarea | |
| placeholder="How should the output be formatted? (e.g., JSON with fields: name, price, description, url)" | |
| value={taskInput.outputInstruction} | |
| onChange={(e) => setTaskInput(p => ({ ...p, outputInstruction: e.target.value }))} | |
| rows={2} | |
| className="w-full px-4 py-3 bg-slate-900/70 border border-emerald-500/30 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 resize-none transition-all" | |
| /> | |
| </div> | |
| {/* Configuration Options */} | |
| <div className="flex flex-wrap items-center justify-center gap-4"> | |
| {/* Model */} | |
| <button | |
| onClick={() => setShowModelPopup(true)} | |
| className="px-5 py-3 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 text-cyan-400 rounded-xl text-sm font-medium transition-all flex items-center gap-2 shadow-lg shadow-cyan-500/5" | |
| > | |
| <Cpu className="w-4 h-4" /> | |
| {taskInput.selectedModel ? taskInput.selectedModel.split('/')[1] : 'Select Model'} | |
| </button> | |
| {/* Vision */} | |
| <button | |
| onClick={() => setShowVisionPopup(true)} | |
| className={classNames( | |
| 'px-5 py-3 border rounded-xl text-sm font-medium transition-all flex items-center gap-2 shadow-lg', | |
| taskInput.selectedVisionModel | |
| ? 'bg-pink-500/10 border-pink-500/30 text-pink-400 shadow-pink-500/5' | |
| : 'bg-slate-700/50 border-slate-600 text-slate-400 hover:border-pink-500/30 hover:text-pink-400' | |
| )} | |
| > | |
| <Eye className="w-4 h-4" /> | |
| {taskInput.selectedVisionModel ? 'Vision ✓' : 'Vision'} | |
| </button> | |
| {/* Agents */} | |
| <button | |
| onClick={() => setShowAgentPopup(true)} | |
| className="px-5 py-3 bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 text-purple-400 rounded-xl text-sm font-medium transition-all flex items-center gap-2 shadow-lg shadow-purple-500/5" | |
| > | |
| <Bot className="w-4 h-4" /> | |
| Agents {taskInput.selectedAgents.length > 0 && `(${taskInput.selectedAgents.length})`} | |
| </button> | |
| {/* Plugins */} | |
| <button | |
| onClick={() => setShowPluginPopup(true)} | |
| className="px-5 py-3 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 text-amber-400 rounded-xl text-sm font-medium transition-all flex items-center gap-2 shadow-lg shadow-amber-500/5" | |
| > | |
| <Plug className="w-4 h-4" /> | |
| Plugins {enabledNonAgentPlugins.length > 0 && `(${enabledNonAgentPlugins.length})`} | |
| </button> | |
| {/* Task Type */} | |
| <button | |
| onClick={() => setShowTaskTypePopup(true)} | |
| className={classNames( | |
| 'px-5 py-3 border rounded-xl text-sm font-medium transition-all flex items-center gap-2 shadow-lg', | |
| taskInput.taskType === 'low' && 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 shadow-emerald-500/5', | |
| taskInput.taskType === 'medium' && 'bg-amber-500/10 border-amber-500/30 text-amber-400 shadow-amber-500/5', | |
| taskInput.taskType === 'high' && 'bg-red-500/10 border-red-500/30 text-red-400 shadow-red-500/5' | |
| )} | |
| > | |
| <Target className="w-4 h-4" /> | |
| {taskTypes.find(t => t.id === taskInput.taskType)?.icon} {taskInput.taskType.charAt(0).toUpperCase() + taskInput.taskType.slice(1)} | |
| </button> | |
| </div> | |
| {/* Start Button */} | |
| <div className="flex justify-center pt-6"> | |
| <button | |
| onClick={handleStart} | |
| disabled={taskInput.urls.length === 0 || !isSystemOnline} | |
| className="px-10 py-4 bg-gradient-to-r from-cyan-500 to-cyan-600 hover:from-cyan-400 hover:to-cyan-500 disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed text-white rounded-2xl font-semibold text-lg transition-all flex items-center gap-3 shadow-xl shadow-cyan-500/30 disabled:shadow-none transform hover:scale-[1.02] disabled:hover:scale-100" | |
| > | |
| <Play className="w-6 h-6" /> | |
| Start Scraping | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Popups */} | |
| {renderPopups()} | |
| </div> | |
| ); | |
| } | |
| // ========== DASHBOARD VIEW ========== | |
| // Helper function to render popups (used in both views) | |
| function renderPopups() { | |
| return ( | |
| <> | |
| {/* Model Selection Popup */} | |
| <Popup title="Select Model" isOpen={showModelPopup} onClose={() => setShowModelPopup(false)} size="lg"> | |
| <div className="space-y-4"> | |
| {Object.entries(modelsByProvider()).map(([provider, models]) => ( | |
| <div key={provider}> | |
| <h4 className="text-xs font-semibold text-gray-400 uppercase mb-2 flex items-center gap-2"> | |
| <div className="w-2 h-2 rounded-full bg-cyan-400"></div> | |
| {provider} | |
| </h4> | |
| <div className="space-y-1 pl-4"> | |
| {models.map((model) => ( | |
| <button | |
| key={`${model.provider}/${model.model}`} | |
| onClick={() => { | |
| setTaskInput(p => ({ ...p, selectedModel: `${model.provider}/${model.model}` })); | |
| setShowModelPopup(false); | |
| }} | |
| className={classNames( | |
| 'w-full flex items-center justify-between p-3 rounded-lg transition-colors text-left', | |
| taskInput.selectedModel === `${model.provider}/${model.model}` | |
| ? 'bg-cyan-500/20 border border-cyan-500/30' | |
| : 'bg-gray-900/50 hover:bg-gray-800' | |
| )} | |
| > | |
| <div> | |
| <p className="text-sm font-medium text-white">{model.name}</p> | |
| <p className="text-xs text-gray-500">{model.description || model.model}</p> | |
| </div> | |
| {taskInput.selectedModel === `${model.provider}/${model.model}` && ( | |
| <Check className="w-5 h-5 text-cyan-400" /> | |
| )} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </Popup> | |
| {/* Vision Model Popup */} | |
| <Popup title="Select Vision Model" isOpen={showVisionPopup} onClose={() => setShowVisionPopup(false)}> | |
| <div className="space-y-2"> | |
| <button | |
| onClick={() => { | |
| setTaskInput(p => ({ ...p, selectedVisionModel: '' })); | |
| setShowVisionPopup(false); | |
| }} | |
| className={classNames( | |
| 'w-full flex items-center justify-between p-3 rounded-lg transition-colors text-left', | |
| !taskInput.selectedVisionModel ? 'bg-gray-700/50 border border-gray-600' : 'bg-gray-900/50 hover:bg-gray-800' | |
| )} | |
| > | |
| <span className="text-sm text-gray-400">None (No vision)</span> | |
| {!taskInput.selectedVisionModel && <Check className="w-5 h-5 text-gray-400" />} | |
| </button> | |
| {visionModels.map((model) => ( | |
| <div key={model.model} className="flex items-center gap-2"> | |
| <button | |
| onClick={() => { | |
| setTaskInput(p => ({ ...p, selectedVisionModel: model.model })); | |
| setShowVisionPopup(false); | |
| }} | |
| className={classNames( | |
| 'flex-1 flex items-center justify-between p-3 rounded-lg transition-colors text-left', | |
| taskInput.selectedVisionModel === model.model | |
| ? 'bg-pink-500/20 border border-pink-500/30' | |
| : 'bg-gray-900/50 hover:bg-gray-800' | |
| )} | |
| > | |
| <div> | |
| <p className="text-sm font-medium text-white">{model.name}</p> | |
| <p className="text-xs text-gray-500">{model.provider}</p> | |
| </div> | |
| {taskInput.selectedVisionModel === model.model && <Check className="w-5 h-5 text-pink-400" />} | |
| </button> | |
| <button | |
| onClick={() => showInfo(model.name, model.description || 'Vision model for image understanding', { Provider: model.provider, Model: model.model })} | |
| className="p-2 text-gray-500 hover:text-gray-300" | |
| > | |
| <Info className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| </Popup> | |
| {/* Agent Selection Popup */} | |
| <Popup title="Select Agents" isOpen={showAgentPopup} onClose={() => setShowAgentPopup(false)}> | |
| <div className="space-y-2"> | |
| {agents.map((agent) => { | |
| const isSelected = taskInput.selectedAgents.includes(agent.type); | |
| return ( | |
| <div key={agent.type} className="flex items-center gap-2"> | |
| <button | |
| onClick={() => { | |
| setTaskInput(p => ({ | |
| ...p, | |
| selectedAgents: isSelected | |
| ? p.selectedAgents.filter(a => a !== agent.type) | |
| : [...p.selectedAgents, agent.type], | |
| })); | |
| }} | |
| className={classNames( | |
| 'flex-1 flex items-center justify-between p-3 rounded-lg transition-colors text-left', | |
| isSelected ? 'bg-purple-500/20 border border-purple-500/30' : 'bg-gray-900/50 hover:bg-gray-800' | |
| )} | |
| > | |
| <div> | |
| <p className="text-sm font-medium text-white">{agent.name}</p> | |
| <p className="text-xs text-gray-500">{agent.description}</p> | |
| </div> | |
| {isSelected && <Check className="w-5 h-5 text-purple-400" />} | |
| </button> | |
| <button | |
| onClick={() => showInfo(agent.name, agent.description, { Type: agent.type })} | |
| className="p-2 text-gray-500 hover:text-gray-300" | |
| > | |
| <Info className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </Popup> | |
| {/* Plugin Selection Popup */} | |
| <Popup title="Enable Plugins" isOpen={showPluginPopup} onClose={() => setShowPluginPopup(false)} size="lg"> | |
| <div className="space-y-4"> | |
| {Object.entries(installedPlugins).map(([category, plugins]) => { | |
| if (plugins.length === 0) return null; | |
| return ( | |
| <div key={category}> | |
| <h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">{category}</h4> | |
| <div className="space-y-1"> | |
| {plugins.map((plugin: PluginInfo) => { | |
| const isEnabled = taskInput.enabledPlugins.includes(plugin.id); | |
| return ( | |
| <div key={plugin.id} className="flex items-center gap-2"> | |
| <button | |
| onClick={() => { | |
| setTaskInput(p => ({ | |
| ...p, | |
| enabledPlugins: isEnabled | |
| ? p.enabledPlugins.filter(a => a !== plugin.id) | |
| : [...p.enabledPlugins, plugin.id], | |
| })); | |
| }} | |
| className={classNames( | |
| 'flex-1 flex items-center justify-between p-2 rounded-lg transition-colors text-left', | |
| isEnabled ? 'bg-amber-500/20 border border-amber-500/30' : 'bg-gray-900/50 hover:bg-gray-800' | |
| )} | |
| > | |
| <span className="text-sm text-white">{plugin.name}</span> | |
| {isEnabled && <Check className="w-4 h-4 text-amber-400" />} | |
| </button> | |
| <button | |
| onClick={() => showInfo(plugin.name, plugin.description, { Category: plugin.category, ID: plugin.id })} | |
| className="p-2 text-gray-500 hover:text-gray-300" | |
| > | |
| <Info className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </Popup> | |
| {/* Task Type Popup */} | |
| <Popup title="Select Task Complexity" isOpen={showTaskTypePopup} onClose={() => setShowTaskTypePopup(false)}> | |
| <div className="space-y-2"> | |
| {taskTypes.map((type) => ( | |
| <button | |
| key={type.id} | |
| onClick={() => { | |
| setTaskInput(p => ({ ...p, taskType: type.id as 'low' | 'medium' | 'high' })); | |
| setShowTaskTypePopup(false); | |
| }} | |
| className={classNames( | |
| 'w-full flex items-center justify-between p-3 rounded-lg transition-colors text-left', | |
| taskInput.taskType === type.id | |
| ? type.id === 'low' ? 'bg-emerald-500/20 border border-emerald-500/30' | |
| : type.id === 'medium' ? 'bg-amber-500/20 border border-amber-500/30' | |
| : 'bg-red-500/20 border border-red-500/30' | |
| : 'bg-gray-900/50 hover:bg-gray-800' | |
| )} | |
| > | |
| <div className="flex items-center gap-3"> | |
| <span className="text-xl">{type.icon}</span> | |
| <div> | |
| <p className="text-sm font-medium text-white">{type.name}</p> | |
| <p className="text-xs text-gray-500">{type.description}</p> | |
| </div> | |
| </div> | |
| {taskInput.taskType === type.id && ( | |
| <Check className={classNames( | |
| 'w-5 h-5', | |
| type.id === 'low' ? 'text-emerald-400' : type.id === 'medium' ? 'text-amber-400' : 'text-red-400' | |
| )} /> | |
| )} | |
| </button> | |
| ))} | |
| </div> | |
| </Popup> | |
| {/* Memories Popup */} | |
| <Popup title="Memories" isOpen={showMemoriesPopup} onClose={() => setShowMemoriesPopup(false)} size="lg"> | |
| <div className="space-y-3"> | |
| {/* Add Memory */} | |
| <div className="flex gap-2"> | |
| <input | |
| type="text" | |
| placeholder="Add a new memory..." | |
| value={newMemory} | |
| onChange={(e) => setNewMemory(e.target.value)} | |
| onKeyDown={(e) => e.key === 'Enter' && handleAddMemory()} | |
| className="flex-1 px-3 py-2 bg-gray-900/50 border border-gray-700 rounded-lg text-white text-sm" | |
| /> | |
| <button onClick={handleAddMemory} className="px-3 py-2 bg-purple-500/20 border border-purple-500/30 text-purple-400 rounded-lg"> | |
| <Plus className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| {/* Memory List */} | |
| <div className="space-y-2 max-h-80 overflow-y-auto"> | |
| {memories.length === 0 ? ( | |
| <div className="text-center py-8 text-gray-500 text-sm">No memories yet</div> | |
| ) : ( | |
| memories.map((mem) => ( | |
| <div key={mem.id} className="p-3 bg-gray-900/50 rounded-lg"> | |
| <div className="flex items-start justify-between"> | |
| <p className="text-sm text-gray-300 flex-1">{mem.content}</p> | |
| <button | |
| onClick={() => setMemories(prev => prev.filter(m => m.id !== mem.id))} | |
| className="p-1 text-gray-500 hover:text-red-400 ml-2" | |
| > | |
| <Trash2 className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| <div className="flex items-center gap-2 mt-2"> | |
| <Badge variant="neutral" size="sm">{mem.type}</Badge> | |
| <span className="text-[10px] text-gray-500">{formatTime(mem.timestamp)}</span> | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| </Popup> | |
| {/* Assets Popup */} | |
| <Popup title="Assets" isOpen={showAssetsPopup} onClose={() => setShowAssetsPopup(false)} size="lg"> | |
| <div className="space-y-3"> | |
| <div className="space-y-2 max-h-80 overflow-y-auto"> | |
| {assets.length === 0 ? ( | |
| <div className="text-center py-8 text-gray-500 text-sm">No assets yet. URLs and fetched data will appear here.</div> | |
| ) : ( | |
| assets.map((asset) => ( | |
| <div key={asset.id} className="p-3 bg-gray-900/50 rounded-lg"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2 flex-1 min-w-0"> | |
| {asset.type === 'url' && <Link className="w-4 h-4 text-cyan-400 flex-shrink-0" />} | |
| {asset.type === 'image' && <ImageIcon className="w-4 h-4 text-pink-400 flex-shrink-0" />} | |
| {asset.type === 'file' && <FileText className="w-4 h-4 text-amber-400 flex-shrink-0" />} | |
| {asset.type === 'data' && <Database className="w-4 h-4 text-emerald-400 flex-shrink-0" />} | |
| <span className="text-sm text-gray-300 truncate">{asset.name}</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Badge variant={asset.source === 'ai' ? 'info' : 'neutral'} size="sm">{asset.source}</Badge> | |
| <button | |
| onClick={() => setAssets(prev => prev.filter(a => a.id !== asset.id))} | |
| className="p-1 text-gray-500 hover:text-red-400" | |
| > | |
| <Trash2 className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| </Popup> | |
| {/* Info Popup */} | |
| <InfoPopup | |
| isOpen={infoPopup.isOpen} | |
| onClose={() => setInfoPopup({ ...infoPopup, isOpen: false })} | |
| title={infoPopup.title} | |
| description={infoPopup.description} | |
| details={infoPopup.details} | |
| /> | |
| </> | |
| ); | |
| } | |
| return ( | |
| <div className="h-screen flex flex-col bg-slate-900"> | |
| {/* Main 3-Column Layout */} | |
| <div className="flex-1 flex overflow-hidden"> | |
| {/* Left Sidebar - Active Components */} | |
| <div className="w-56 flex-shrink-0 bg-slate-800/50 border-r border-cyan-500/10 overflow-y-auto p-3 space-y-3"> | |
| {/* Back to Input */} | |
| <button | |
| onClick={() => { setCurrentView('input'); handleStop(); }} | |
| className="w-full flex items-center gap-2 px-3 py-2 bg-slate-700/50 hover:bg-slate-700 border border-slate-600/50 rounded-xl text-sm text-slate-300 transition-all" | |
| > | |
| <ChevronRight className="w-4 h-4 rotate-180" /> | |
| New Task | |
| </button> | |
| {/* Progress Bar */} | |
| {isRunning && progress.totalUrls > 0 && ( | |
| <div className="p-3 bg-cyan-500/10 border border-cyan-500/20 rounded-xl"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="text-xs text-cyan-400 font-medium">Progress</span> | |
| <span className="text-xs text-cyan-300">{progress.urlIndex + 1}/{progress.totalUrls}</span> | |
| </div> | |
| <div className="h-2 bg-slate-700 rounded-full overflow-hidden"> | |
| <div | |
| className="h-full bg-gradient-to-r from-cyan-500 to-cyan-400 transition-all duration-500" | |
| style={{ width: `${((progress.urlIndex + 1) / progress.totalUrls) * 100}%` }} | |
| /> | |
| </div> | |
| <p className="text-[10px] text-slate-400 mt-2 truncate">{progress.currentUrl}</p> | |
| </div> | |
| )} | |
| {/* Agents */} | |
| <Accordion title="Agents" icon={Bot} badge={taskInput.selectedAgents.length} color="text-purple-400" defaultOpen> | |
| {taskInput.selectedAgents.length === 0 ? ( | |
| <p className="text-xs text-slate-500 p-2">No agents selected</p> | |
| ) : ( | |
| taskInput.selectedAgents.map((agentId) => { | |
| const agent = agents.find(a => a.type === agentId); | |
| return ( | |
| <div key={agentId} className="flex items-center justify-between p-2 bg-purple-500/10 border border-purple-500/30 rounded-lg"> | |
| <div className="flex items-center gap-2"> | |
| <div className={`w-2 h-2 rounded-full ${isRunning ? 'bg-emerald-400 animate-pulse' : 'bg-slate-500'}`}></div> | |
| <span className="text-xs text-white">{agent?.name || agentId}</span> | |
| </div> | |
| <button onClick={() => showInfo(agent?.name || agentId, agent?.description || '', { Type: agentId })} className="text-slate-500 hover:text-slate-300"> | |
| <Info className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| ); | |
| }) | |
| )} | |
| </Accordion> | |
| {/* Plugins */} | |
| <Accordion title="Plugins" icon={Plug} badge={enabledNonAgentPlugins.length} color="text-amber-400"> | |
| {enabledNonAgentPlugins.length === 0 ? ( | |
| <p className="text-xs text-slate-500 p-2">No plugins enabled</p> | |
| ) : ( | |
| enabledNonAgentPlugins.map((pluginId) => ( | |
| <div key={pluginId} className="p-2 bg-amber-500/10 border border-amber-500/30 rounded-lg"> | |
| <span className="text-xs text-white">{pluginId}</span> | |
| </div> | |
| )) | |
| )} | |
| </Accordion> | |
| {/* System Status */} | |
| <div className="p-3 bg-slate-900/50 border border-slate-700/50 rounded-xl"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="text-xs text-slate-400">Status</span> | |
| <Badge variant={isSystemOnline ? 'success' : 'error'} size="sm"> | |
| {isRunning ? 'Running' : isSystemOnline ? 'Online' : 'Offline'} | |
| </Badge> | |
| </div> | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="text-xs text-slate-400">Model</span> | |
| <span className="text-xs text-cyan-300">{taskInput.selectedModel.split('/')[1]}</span> | |
| </div> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-xs text-slate-400">Complexity</span> | |
| <span className={classNames( | |
| 'text-xs', | |
| taskInput.taskType === 'low' ? 'text-emerald-400' : | |
| taskInput.taskType === 'medium' ? 'text-amber-400' : 'text-red-400' | |
| )}>{taskInput.taskType.toUpperCase()}</span> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Center Content */} | |
| <div className="flex-1 flex flex-col overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800/50 to-cyan-900/10"> | |
| {/* Stats Header - Session-based, start at 0 */} | |
| <div className="flex-shrink-0 p-4 bg-slate-800/30 border-b border-cyan-500/10"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-8"> | |
| <div className="flex items-center gap-3"> | |
| <div className="p-2 bg-cyan-500/20 rounded-lg"> | |
| <Layers className="w-5 h-5 text-cyan-400" /> | |
| </div> | |
| <div> | |
| <p className="text-2xl font-bold text-white">{stats.episodes}</p> | |
| <p className="text-xs text-slate-500">Episodes</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <div className="p-2 bg-purple-500/20 rounded-lg"> | |
| <Target className="w-5 h-5 text-purple-400" /> | |
| </div> | |
| <div> | |
| <p className="text-2xl font-bold text-white">{stats.steps}</p> | |
| <p className="text-xs text-slate-500">Steps</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <div className="p-2 bg-emerald-500/20 rounded-lg"> | |
| <TrendingUp className="w-5 h-5 text-emerald-400" /> | |
| </div> | |
| <div> | |
| <p className="text-2xl font-bold text-white">{stats.totalReward.toFixed(2)}</p> | |
| <p className="text-xs text-slate-500">Total Reward</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| {/* Control Buttons */} | |
| {isRunning ? ( | |
| <button | |
| onClick={handleStop} | |
| className="px-6 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-all flex items-center gap-2 shadow-lg shadow-red-500/20" | |
| > | |
| <Pause className="w-4 h-4" /> | |
| Stop | |
| </button> | |
| ) : ( | |
| <button | |
| onClick={handleStart} | |
| disabled={taskInput.urls.length === 0} | |
| className="px-6 py-2.5 bg-gradient-to-r from-cyan-500 to-cyan-600 hover:from-cyan-400 hover:to-cyan-500 disabled:from-slate-600 disabled:to-slate-700 text-white rounded-xl font-medium transition-all flex items-center gap-2 shadow-lg shadow-cyan-500/20" | |
| > | |
| <Play className="w-4 h-4" /> | |
| Start | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Main Visualization Area */} | |
| <div className="flex-1 overflow-y-auto p-4"> | |
| <div className="h-full bg-slate-900/50 border border-cyan-500/10 rounded-2xl p-4"> | |
| {isRunning ? ( | |
| <div className="h-full flex flex-col"> | |
| {/* Steps Accordion */} | |
| <div className="flex-shrink-0 mb-4"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <div className="flex items-center gap-2"> | |
| <Layers className="w-5 h-5 text-cyan-400 animate-pulse" /> | |
| <span className="text-sm font-semibold text-white">Execution Steps</span> | |
| <Badge variant="info" size="sm">{allSteps.length}</Badge> | |
| </div> | |
| {currentStep && ( | |
| <Badge | |
| variant={currentStep.status === 'completed' ? 'success' : currentStep.status === 'failed' ? 'error' : 'info'} | |
| > | |
| {currentStep.action.toUpperCase()} | |
| </Badge> | |
| )} | |
| </div> | |
| {/* Step Accordion List */} | |
| <div className="space-y-2 max-h-[300px] overflow-y-auto pr-2"> | |
| {allSteps.length === 0 ? ( | |
| <div className="p-4 bg-slate-800/50 rounded-xl text-center"> | |
| <div className="animate-spin w-6 h-6 border-2 border-cyan-500 border-t-transparent rounded-full mx-auto mb-2"></div> | |
| <p className="text-sm text-slate-400">Initializing scraper...</p> | |
| </div> | |
| ) : ( | |
| allSteps.map((step, index) => ( | |
| <StepAccordionItem | |
| key={`${step.step_number}-${index}`} | |
| step={step} | |
| isExpanded={expandedStepIndex === index} | |
| onToggle={() => setExpandedStepIndex(expandedStepIndex === index ? null : index)} | |
| isLatest={index === allSteps.length - 1} | |
| /> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| {/* Extracted Data Preview */} | |
| <div className="flex-1 overflow-auto"> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <Database className="w-5 h-5 text-emerald-400" /> | |
| <span className="text-sm font-semibold text-white">Live Extracted Data</span> | |
| </div> | |
| <div className="p-4 bg-slate-800/50 rounded-xl min-h-[150px] max-h-[250px] overflow-auto"> | |
| <pre className="text-xs text-slate-300 font-mono whitespace-pre-wrap"> | |
| {Object.keys(extractedData).length > 0 | |
| ? JSON.stringify(extractedData, null, 2) | |
| : '{\n "status": "extracting...",\n "data": []\n}' | |
| } | |
| </pre> | |
| </div> | |
| </div> | |
| </div> | |
| ) : scrapeResult ? ( | |
| <div className="h-full flex flex-col"> | |
| {/* Result Header */} | |
| <div className="flex items-center justify-between mb-4"> | |
| <div className="flex items-center gap-3"> | |
| <div className={`p-2 rounded-lg ${scrapeResult.status === 'completed' ? 'bg-emerald-500/20' : 'bg-amber-500/20'}`}> | |
| {scrapeResult.status === 'completed' ? ( | |
| <CheckCircle className="w-6 h-6 text-emerald-400" /> | |
| ) : ( | |
| <AlertCircle className="w-6 h-6 text-amber-400" /> | |
| )} | |
| </div> | |
| <div> | |
| <h3 className="text-lg font-semibold text-white">Scraping Complete</h3> | |
| <p className="text-sm text-slate-400"> | |
| {scrapeResult.urls_processed} URLs • {scrapeResult.total_steps} steps • {scrapeResult.duration_seconds.toFixed(1)}s • Reward: {scrapeResult.total_reward.toFixed(2)} | |
| </p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={handleCopyResult} | |
| className="px-4 py-2 bg-cyan-500/20 hover:bg-cyan-500/30 border border-cyan-500/30 text-cyan-400 rounded-lg text-sm font-medium transition-all flex items-center gap-2" | |
| > | |
| <Copy className="w-4 h-4" /> | |
| Copy | |
| </button> | |
| <button | |
| onClick={handleDownloadResult} | |
| className="px-4 py-2 bg-emerald-500/20 hover:bg-emerald-500/30 border border-emerald-500/30 text-emerald-400 rounded-lg text-sm font-medium transition-all flex items-center gap-2" | |
| > | |
| <Download className="w-4 h-4" /> | |
| Download | |
| </button> | |
| </div> | |
| </div> | |
| {/* Steps Summary (collapsed) */} | |
| {allSteps.length > 0 && ( | |
| <div className="mb-4"> | |
| <Accordion title={`Execution Steps (${allSteps.length})`} icon={Layers} color="text-cyan-400"> | |
| <div className="space-y-1 max-h-[200px] overflow-y-auto"> | |
| {allSteps.map((step, index) => ( | |
| <div | |
| key={`result-${step.step_number}-${index}`} | |
| className="flex items-center justify-between p-2 bg-slate-800/50 rounded-lg text-xs" | |
| > | |
| <div className="flex items-center gap-2"> | |
| {React.createElement(getStepIcon(step.action), { | |
| className: classNames('w-3 h-3', getStepColor(step.action, step.status).split(' ')[0]) | |
| })} | |
| <span className="text-slate-300">{step.action}</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-emerald-400">+{step.reward.toFixed(2)}</span> | |
| {step.status === 'completed' ? ( | |
| <CheckCircle className="w-3 h-3 text-emerald-400" /> | |
| ) : step.status === 'failed' ? ( | |
| <XCircle className="w-3 h-3 text-red-400" /> | |
| ) : ( | |
| <Clock className="w-3 h-3 text-cyan-400" /> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </Accordion> | |
| </div> | |
| )} | |
| {/* Result Content - Full Output */} | |
| <div className="flex-1 overflow-auto"> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <FileJson className="w-4 h-4 text-amber-400" /> | |
| <span className="text-sm font-medium text-white">Output ({scrapeResult.output_format})</span> | |
| </div> | |
| <div className="p-4 bg-slate-800/50 rounded-xl overflow-auto max-h-[400px]"> | |
| <pre className="text-sm text-slate-300 font-mono whitespace-pre-wrap"> | |
| {scrapeResult.output} | |
| </pre> | |
| </div> | |
| </div> | |
| {/* Errors */} | |
| {scrapeResult.errors.length > 0 && ( | |
| <div className="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl"> | |
| <h4 className="text-sm font-medium text-red-400 mb-2">Errors ({scrapeResult.errors.length})</h4> | |
| {scrapeResult.errors.map((err, idx) => ( | |
| <p key={idx} className="text-xs text-red-300">{err}</p> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <div className="h-full flex flex-col items-center justify-center text-center"> | |
| <div className="w-20 h-20 bg-cyan-500/10 rounded-2xl flex items-center justify-center mb-6 border border-cyan-500/20"> | |
| <Globe className="w-10 h-10 text-cyan-400" /> | |
| </div> | |
| <h3 className="text-xl font-semibold text-white mb-2">Ready to Scrape</h3> | |
| <p className="text-sm text-slate-400 max-w-md mb-4"> | |
| {taskInput.urls.length} URLs loaded. Click Start to begin scraping. | |
| </p> | |
| <div className="flex flex-wrap gap-2 justify-center"> | |
| {taskInput.urls.slice(0, 3).map((url, idx) => ( | |
| <Badge key={idx} variant="info" size="sm">{safeHostname(url)}</Badge> | |
| ))} | |
| {taskInput.urls.length > 3 && ( | |
| <Badge variant="neutral" size="sm">+{taskInput.urls.length - 3} more</Badge> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Logs Terminal */} | |
| <div className="flex-shrink-0 h-36 bg-slate-900 border-t border-cyan-500/10"> | |
| <div className="flex items-center justify-between px-4 py-2 border-b border-slate-800"> | |
| <div className="flex items-center gap-2"> | |
| <Terminal className="w-4 h-4 text-cyan-400" /> | |
| <span className="text-xs font-medium text-slate-300">Live Logs</span> | |
| {isRunning && <div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>} | |
| </div> | |
| <button onClick={() => setLogs([])} className="text-xs text-slate-500 hover:text-slate-300 transition-colors"> | |
| Clear | |
| </button> | |
| </div> | |
| <div className="h-[calc(100%-32px)] overflow-y-auto p-3 font-mono text-xs"> | |
| {logs.length === 0 ? ( | |
| <p className="text-slate-600">Waiting for logs...</p> | |
| ) : ( | |
| logs.slice(-50).map((log) => ( | |
| <div key={log.id} className="flex items-start gap-2 py-0.5"> | |
| <span className="text-slate-600">[{formatTime(log.timestamp)}]</span> | |
| <span className={getLogLevelColor(log.level)}>[{log.level.toUpperCase()}]</span> | |
| {log.source && <span className="text-purple-400">[{log.source}]</span>} | |
| <span className="text-slate-300">{log.message}</span> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Right Sidebar */} | |
| <div className="w-72 flex-shrink-0 bg-slate-800/50 border-l border-cyan-500/10 overflow-y-auto p-4 space-y-4"> | |
| {/* Input Summary */} | |
| <div className="bg-slate-900/50 border border-slate-700/50 rounded-xl p-4"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <div className="flex items-center gap-2"> | |
| <FileText className="w-5 h-5 text-cyan-400" /> | |
| <span className="text-sm font-semibold text-white">Task Input</span> | |
| </div> | |
| <button | |
| onClick={() => setCurrentView('input')} | |
| className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors" | |
| > | |
| Edit | |
| </button> | |
| </div> | |
| <div className="space-y-3 text-sm"> | |
| <div> | |
| <p className="text-slate-500 text-xs mb-1">URLs ({taskInput.urls.length})</p> | |
| <p className="text-slate-300 truncate">{taskInput.urls[0] || 'None'}</p> | |
| </div> | |
| <div> | |
| <p className="text-slate-500 text-xs mb-1">Instruction</p> | |
| <p className="text-slate-300 line-clamp-2">{taskInput.instruction || 'None'}</p> | |
| </div> | |
| <div> | |
| <p className="text-slate-500 text-xs mb-1">Output Format</p> | |
| <p className="text-slate-300 truncate">{taskInput.outputInstruction || 'JSON'}</p> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Memories */} | |
| <div className="bg-slate-900/50 border border-slate-700/50 rounded-xl p-4"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <div className="flex items-center gap-2"> | |
| <Database className="w-5 h-5 text-purple-400" /> | |
| <span className="text-sm font-semibold text-white">Memory</span> | |
| </div> | |
| <button onClick={() => setShowMemoriesPopup(true)} className="text-xs text-purple-400 hover:text-purple-300"> | |
| Manage | |
| </button> | |
| </div> | |
| <div className="grid grid-cols-2 gap-2"> | |
| <div className="p-3 bg-slate-800/50 rounded-lg text-center"> | |
| <p className="text-lg font-bold text-emerald-400">{memoryData?.short_term_count || 0}</p> | |
| <p className="text-[10px] text-slate-500">Short-term</p> | |
| </div> | |
| <div className="p-3 bg-slate-800/50 rounded-lg text-center"> | |
| <p className="text-lg font-bold text-cyan-400">{memoryData?.working_count || 0}</p> | |
| <p className="text-[10px] text-slate-500">Working</p> | |
| </div> | |
| <div className="p-3 bg-slate-800/50 rounded-lg text-center"> | |
| <p className="text-lg font-bold text-purple-400">{memoryData?.long_term_count || 0}</p> | |
| <p className="text-[10px] text-slate-500">Long-term</p> | |
| </div> | |
| <div className="p-3 bg-slate-800/50 rounded-lg text-center"> | |
| <p className="text-lg font-bold text-amber-400">{memories.length}</p> | |
| <p className="text-[10px] text-slate-500">Session</p> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Assets */} | |
| <div className="bg-slate-900/50 border border-slate-700/50 rounded-xl p-4"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <div className="flex items-center gap-2"> | |
| <FolderOpen className="w-5 h-5 text-amber-400" /> | |
| <span className="text-sm font-semibold text-white">Assets</span> | |
| </div> | |
| <Badge variant="neutral" size="sm">{assets.length}</Badge> | |
| </div> | |
| {assets.length === 0 ? ( | |
| <p className="text-center py-4 text-slate-500 text-xs">No assets yet</p> | |
| ) : ( | |
| <div className="space-y-2 max-h-40 overflow-y-auto"> | |
| {assets.slice(0, 5).map((asset) => ( | |
| <div key={asset.id} className="flex items-center justify-between p-2 bg-slate-800/50 rounded-lg text-xs"> | |
| <div className="flex items-center gap-2 min-w-0"> | |
| {asset.type === 'url' && <Link className="w-3 h-3 text-cyan-400 flex-shrink-0" />} | |
| {asset.type === 'data' && <Database className="w-3 h-3 text-emerald-400 flex-shrink-0" />} | |
| <span className="text-slate-300 truncate">{asset.name.slice(0, 25)}</span> | |
| </div> | |
| <Badge variant={asset.source === 'ai' ? 'info' : 'neutral'} size="sm">{asset.source}</Badge> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| <button | |
| onClick={() => setShowAssetsPopup(true)} | |
| className="w-full mt-3 px-3 py-2 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 text-amber-400 rounded-lg text-xs font-medium transition-all" | |
| > | |
| View All Assets | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Popups */} | |
| {renderPopups()} | |
| </div> | |
| ); | |
| }; | |
| export default Dashboard; | |