import React, { useMemo } from "react" import { cn } from "@/lib/utils" import { User, Database, Search, Brain, MessageSquare, FileOutput, Loader2, Network, GitBranch, TrendingUp, DollarSign, Activity, Globe, Newspaper, } from "lucide-react" import type { MCPStatus, LLMStatus } from "@/lib/api" // === TYPES === type NodeStatus = 'idle' | 'executing' | 'completed' | 'failed' | 'skipped' type CacheState = 'idle' | 'hit' | 'miss' | 'checking' interface ProcessFlowProps { currentStep: string completedSteps: string[] mcpStatus: MCPStatus llmStatus?: LLMStatus llmProvider?: string cacheHit?: boolean stockSelected?: boolean isSearching?: boolean revisionCount?: number isAborted?: boolean } // === CONSTANTS === const NODE_SIZE = 44 const ICON_SIZE = 24 const MCP_SIZE = 36 const MCP_ICON_SIZE = 20 const LLM_WIDTH = 64 const LLM_HEIGHT = 24 const GAP = 72 const CONNECTOR_PAD = 2 const GROUP_PAD = 4 // ADJUSTED VALUES FOR TIGHT FIT const ROW_GAP = 68 // Slight reduction to tighten vertical flow const ROW1_Y = 48 // Increased for labels above containers const ROW2_Y = ROW1_Y + ROW_GAP const ROW3_Y = ROW2_Y + ROW_GAP // SVG dimensions const SVG_HEIGHT = 218 // Exact content height - scales to fill container const NODE_COUNT = 6 // Reduced: removed Editor node const FLOW_WIDTH = GAP * (NODE_COUNT - 1) + NODE_SIZE const SVG_WIDTH = 480 // Narrower now without Editor const FLOW_START_X = NODE_SIZE / 2 // Left-aligned with half-node margin const NODES = { input: { x: FLOW_START_X, y: ROW1_Y }, cache: { x: FLOW_START_X + GAP, y: ROW1_Y }, a2a: { x: FLOW_START_X + GAP * 2, y: ROW1_Y }, analyzer: { x: FLOW_START_X + GAP * 3, y: ROW1_Y }, critic: { x: FLOW_START_X + GAP * 4, y: ROW1_Y }, output: { x: FLOW_START_X + GAP * 5, y: ROW1_Y }, // Moved up (was editor position) exchange: { x: FLOW_START_X, y: ROW2_Y }, researcher: { x: FLOW_START_X + GAP * 2, y: ROW3_Y }, } const MCP_START_X = NODES.researcher.x + NODE_SIZE / 2 + 40 const MCP_GAP_LARGE = 48 // Gap between fundamentals and valuation const MCP_GAP_SMALL = 40 // Reduced gap for other servers const MCP_SERVERS = [ { id: 'fundamentals', label: 'Fundamentals', icon: DollarSign, x: MCP_START_X }, { id: 'valuation', label: 'Valuation', icon: TrendingUp, x: MCP_START_X + MCP_GAP_LARGE }, { id: 'volatility', label: 'Volatility', icon: Activity, x: MCP_START_X + MCP_GAP_LARGE + MCP_GAP_SMALL }, { id: 'macro', label: 'Macro (US)', icon: Globe, x: MCP_START_X + MCP_GAP_LARGE + MCP_GAP_SMALL * 2 }, { id: 'news', label: 'News', icon: Newspaper, x: MCP_START_X + MCP_GAP_LARGE + MCP_GAP_SMALL * 3 }, { id: 'sentiment', label: 'Sentiment', icon: MessageSquare, x: MCP_START_X + MCP_GAP_LARGE + MCP_GAP_SMALL * 4 }, ] const AGENTS_CENTER_X = (NODES.analyzer.x + NODES.critic.x) / 2 // Now between Analyzer and Critic only const LLM_GAP = 68 // LLM_WIDTH (64) + 4px spacing const LLM_PROVIDERS = [ { id: 'groq', name: 'Groq', x: AGENTS_CENTER_X - LLM_GAP }, { id: 'gemini', name: 'Gemini', x: AGENTS_CENTER_X }, { id: 'openrouter', name: 'OpenRouter', x: AGENTS_CENTER_X + LLM_GAP }, ] const AGENTS_GROUP = { x: NODES.analyzer.x - NODE_SIZE / 2 - GROUP_PAD, y: ROW1_Y - NODE_SIZE / 2 - GROUP_PAD, width: NODES.critic.x - NODES.analyzer.x + NODE_SIZE + GROUP_PAD * 2, // Now only Analyzer + Critic height: NODE_SIZE + GROUP_PAD * 2, } const LLM_GROUP = { x: LLM_PROVIDERS[0].x - LLM_WIDTH / 2 - GROUP_PAD, y: ROW2_Y - LLM_HEIGHT / 2 - GROUP_PAD, width: LLM_PROVIDERS[2].x - LLM_PROVIDERS[0].x + LLM_WIDTH + GROUP_PAD * 2, height: LLM_HEIGHT + GROUP_PAD * 2, } const MCP_GROUP = { x: MCP_SERVERS[0].x - MCP_SIZE / 2 - GROUP_PAD, y: ROW3_Y - MCP_SIZE / 2 - GROUP_PAD, width: MCP_SERVERS[5].x - MCP_SERVERS[0].x + MCP_SIZE + GROUP_PAD * 2, height: MCP_SIZE + GROUP_PAD * 2, } // === HELPER FUNCTIONS === function normalizeStep(step: string): string { const lower = step.toLowerCase() if (lower === 'completed') return 'output' return lower } function getNodeStatus( stepId: string, currentStep: string, completedSteps: string[], cacheHit?: boolean ): NodeStatus { const normalizedCurrent = normalizeStep(currentStep) const normalizedCompleted = completedSteps.map(normalizeStep) // On cache hit, intermediate steps stay idle (not completed) if (cacheHit && ['researcher', 'analyzer', 'critic', 'a2a'].includes(stepId)) { return 'idle' } if (normalizedCompleted.includes(stepId)) return 'completed' if (normalizedCurrent === stepId) return 'executing' return 'idle' } // === SVG SUB-COMPONENTS === function ArrowMarkers() { return ( {['idle', 'executing', 'completed', 'failed'].map((status) => ( {/* Forward arrow (end) */} {/* Reverse arrow (start) for bidirectional */} ))} ) } function SVGNode({ x, y, icon: Icon, label, label2, status, isDiamond = false, cacheState, isAgent = false, hasBorder = true, labelPosition = 'below', flipIcon = false, }: { x: number y: number icon: React.ElementType label: string label2?: string status: NodeStatus isDiamond?: boolean cacheState?: CacheState isAgent?: boolean hasBorder?: boolean labelPosition?: 'above' | 'below' flipIcon?: boolean }) { const isExecuting = status === 'executing' || cacheState === 'checking' const opacity = status === 'idle' && !cacheState ? 0.7 : status === 'skipped' ? 0.7 : 1 const strokeWidth = hasBorder ? 1 : 0 // Label positioning const labelY = labelPosition === 'above' ? y - NODE_SIZE / 2 - (label2 ? 16 : 8) : y + NODE_SIZE / 2 + 10 return (
{isExecuting ? ( ) : ( )}
{label} {label2 && ( {label2} )}
) } // === MAIN COMPONENT === export function ProcessFlow({ currentStep, completedSteps, mcpStatus, llmStatus, llmProvider = 'groq', cacheHit = false, stockSelected = false, isSearching = false, revisionCount = 0, isAborted = false, }: ProcessFlowProps) { // Logic derivations - when aborted, stop all executing states const inputStatus = stockSelected ? 'completed' : getNodeStatus('input', currentStep, completedSteps, cacheHit) const exchangeStatus = stockSelected ? 'completed' : isSearching ? 'executing' : 'idle' // When aborted, freeze agent nodes at their last completed state (no executing) const analyzerStatus = isAborted ? (completedSteps.includes('analyzer') ? 'completed' : 'idle') : getNodeStatus('analyzer', currentStep, completedSteps, cacheHit) const criticStatus = isAborted ? (completedSteps.includes('critic') ? 'completed' : 'idle') : getNodeStatus('critic', currentStep, completedSteps, cacheHit) const outputStatus = isAborted ? (completedSteps.includes('output') ? 'completed' : 'idle') : getNodeStatus('output', currentStep, completedSteps, cacheHit) const researcherStatus = isAborted ? (completedSteps.includes('researcher') ? 'completed' : 'idle') : getNodeStatus('researcher', currentStep, completedSteps, cacheHit) const a2aStatus = isAborted ? (completedSteps.includes('researcher') ? 'completed' : 'idle') : (researcherStatus === 'executing' ? 'executing' : researcherStatus === 'completed' ? 'completed' : 'idle') const cacheState: CacheState = useMemo(() => { if (currentStep === 'cache') return 'checking' if (completedSteps.includes('cache')) return cacheHit ? 'hit' : 'miss' return 'idle' }, [currentStep, completedSteps, cacheHit]) // Completion halo: workflow completed successfully const allDone = useMemo(() => { const normalizedCompleted = completedSteps.map(normalizeStep) const essentialSteps = ['input', 'cache', 'researcher', 'analyzer', 'critic', 'output'] return essentialSteps.every(s => normalizedCompleted.includes(s)) }, [completedSteps]) const conn = (from: NodeStatus | CacheState, to: NodeStatus): NodeStatus => { if (from === 'completed' || from === 'miss' || from === 'hit') { return to === 'idle' ? 'idle' : to === 'executing' ? 'executing' : 'completed' } return 'idle' } // Positioning helpers const nodeRight = (n: { x: number }) => n.x + NODE_SIZE / 2 + CONNECTOR_PAD const nodeLeft = (n: { x: number }) => n.x - NODE_SIZE / 2 - CONNECTOR_PAD const nodeBottom = (n: { y: number }) => n.y + NODE_SIZE / 2 + CONNECTOR_PAD const nodeTop = (n: { y: number }) => n.y - NODE_SIZE / 2 - CONNECTOR_PAD // Diamond corners (rotated 45°, half-diagonal = NODE_SIZE * sqrt(2) / 2) const diamondLeft = (n: { x: number }) => n.x - NODE_SIZE * Math.sqrt(2) / 2 - CONNECTOR_PAD const diamondRight = (n: { x: number }) => n.x + NODE_SIZE * Math.sqrt(2) / 2 + CONNECTOR_PAD return (
{/* Group Backgrounds */} {/* Completion Halo - around OUTPUT node when workflow completes successfully */} {allDone && !isAborted && ( )} {/* Row 1 Rightward Connectors */} {/* Critic → Analyzer revision loop (curved path below) - shows when revision loop is active */} 0 && (analyzerStatus === 'executing' || criticStatus === 'completed') ? 'completed' : 'idle'})`} className={cn("pf-connector", `pf-connector-${revisionCount > 0 && (analyzerStatus === 'executing' || criticStatus === 'completed') ? 'completed' : 'idle'}`)} /> {/* Critic → Output connector */} {/* Researcher ↔ MCP block connector (bidirectional) */} {/* Bidirectional Vertical Connectors */} {/* User Input ↔ Exchange */} {/* A2A ↔ Researcher */} {/* Agent Group ↔ LLM Group (Orchestration connector) */} {/* Row 1 Nodes - labels above */} {/* Row 2 & 3 Nodes - labels below */} {/* LLM Providers - with borders */} {LLM_PROVIDERS.map((llm) => { // Check actual provider status from backend const providerStatus = llmStatus?.[llm.id as keyof LLMStatus]; const isFailed = providerStatus === 'failed'; const isProviderCompleted = providerStatus === 'completed'; // Only show executing if agents are active AND this provider hasn't failed/completed yet const agentsActive = analyzerStatus === 'executing' || criticStatus === 'executing'; const isActive = agentsActive && !isFailed && !isProviderCompleted; // Only the actually used provider shows as completed (from backend llmStatus) const status = isFailed ? 'failed' : isProviderCompleted ? 'completed' : isActive ? 'executing' : 'idle'; return ( {llm.name} ) })} {/* MCP Servers */} {MCP_SERVERS.map((mcp) => { // Check actual MCP status from backend const serverStatus = mcpStatus[mcp.id as keyof MCPStatus]; const isFailed = serverStatus === 'failed'; const isPartial = serverStatus === 'partial'; const isServerCompleted = serverStatus === 'completed'; const isServerExecuting = serverStatus === 'executing'; // Determine visual status: failed/partial take precedence (persist for session) const status = isFailed ? 'failed' : isPartial ? 'partial' : isServerCompleted ? 'completed' : isServerExecuting ? 'executing' : researcherStatus === 'executing' ? 'executing' : 'idle'; const Icon = mcp.icon; return (
{mcp.label}
) })} {/* MCP Group Label */} Custom MCP Servers
) } export default ProcessFlow