Spaces:
Sleeping
Sleeping
| 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 ( | |
| <defs> | |
| {['idle', 'executing', 'completed', 'failed'].map((status) => ( | |
| <React.Fragment key={status}> | |
| {/* Forward arrow (end) */} | |
| <marker | |
| id={`arrow-${status}`} | |
| markerWidth="5" | |
| markerHeight="5" | |
| refX="4" | |
| refY="2.5" | |
| orient="auto" | |
| markerUnits="userSpaceOnUse" | |
| > | |
| <path d="M0,0 L0,5 L5,2.5 z" fill={`var(--pf-connector-${status})`} /> | |
| </marker> | |
| {/* Reverse arrow (start) for bidirectional */} | |
| <marker | |
| id={`arrow-start-${status}`} | |
| markerWidth="5" | |
| markerHeight="5" | |
| refX="1" | |
| refY="2.5" | |
| orient="auto" | |
| markerUnits="userSpaceOnUse" | |
| > | |
| <path d="M5,0 L5,5 L0,2.5 z" fill={`var(--pf-connector-${status})`} /> | |
| </marker> | |
| </React.Fragment> | |
| ))} | |
| </defs> | |
| ) | |
| } | |
| 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 ( | |
| <g opacity={opacity} className="transition-opacity duration-300"> | |
| <rect | |
| x={x - NODE_SIZE / 2} | |
| y={y - NODE_SIZE / 2} | |
| width={NODE_SIZE} | |
| height={NODE_SIZE} | |
| rx={isDiamond ? 4 : 8} | |
| strokeWidth={strokeWidth} | |
| className={cn( | |
| "pf-node", | |
| cacheState ? `pf-cache-${cacheState}` : `pf-node-${status}`, | |
| isAgent && "pf-agent", | |
| !hasBorder && "pf-no-border", | |
| isExecuting && "pf-pulse" | |
| )} | |
| transform={isDiamond ? `rotate(45 ${x} ${y})` : undefined} | |
| /> | |
| <foreignObject | |
| x={x - ICON_SIZE / 2} | |
| y={y - ICON_SIZE / 2} | |
| width={ICON_SIZE} | |
| height={ICON_SIZE} | |
| > | |
| <div className="flex items-center justify-center w-full h-full"> | |
| {isExecuting ? ( | |
| <Loader2 className="w-5 h-5 pf-icon animate-spin" /> | |
| ) : ( | |
| <Icon className="w-5 h-5 pf-icon" style={flipIcon ? { transform: 'scaleX(-1)' } : undefined} /> | |
| )} | |
| </div> | |
| </foreignObject> | |
| <text | |
| x={x} | |
| y={labelY} | |
| textAnchor="middle" | |
| className={cn( | |
| "font-medium", | |
| isAgent ? "text-[9px] pf-text-agent" : "text-[8px] pf-text-label" | |
| )} | |
| > | |
| {label} | |
| {label2 && ( | |
| <tspan x={x} dy="10">{label2}</tspan> | |
| )} | |
| </text> | |
| </g> | |
| ) | |
| } | |
| // === 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 ( | |
| <div className="h-[260px]"> | |
| <div className="h-full"> | |
| <svg viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`} preserveAspectRatio="xMinYMin meet" className="h-full w-auto"> | |
| <ArrowMarkers /> | |
| {/* Group Backgrounds */} | |
| <rect {...AGENTS_GROUP} rx={8} fill="none" stroke="var(--pf-group-stroke)" strokeWidth={1} strokeDasharray="4 3" opacity={0.35} /> | |
| <rect {...LLM_GROUP} rx={8} fill="none" stroke="var(--pf-group-stroke)" strokeWidth={1} strokeDasharray="4 3" opacity={0.35} /> | |
| <rect {...MCP_GROUP} rx={8} fill="none" stroke="var(--pf-group-stroke)" strokeWidth={1} strokeDasharray="4 3" opacity={0.35} /> | |
| {/* Completion Halo - around OUTPUT node when workflow completes successfully */} | |
| {allDone && !isAborted && ( | |
| <circle | |
| cx={NODES.output.x} | |
| cy={NODES.output.y} | |
| r={NODE_SIZE / 2 + 8} | |
| className="pf-success-halo" | |
| /> | |
| )} | |
| {/* Row 1 Rightward Connectors */} | |
| <line x1={nodeRight(NODES.input)} y1={ROW1_Y} x2={diamondLeft(NODES.cache)} y2={ROW1_Y} | |
| strokeWidth={1.4} markerEnd={`url(#arrow-${conn(inputStatus, cacheState === 'idle' ? 'idle' : 'completed')})`} | |
| className={cn("pf-connector", `pf-connector-${conn(inputStatus, cacheState === 'idle' ? 'idle' : 'completed')}`)} /> | |
| <line x1={diamondRight(NODES.cache)} y1={ROW1_Y} x2={nodeLeft(NODES.a2a)} y2={ROW1_Y} | |
| strokeWidth={1.4} markerEnd={`url(#arrow-${cacheState === 'miss' ? conn('miss', a2aStatus) : 'idle'})`} | |
| className={cn("pf-connector", `pf-connector-${cacheState === 'miss' ? conn('miss', a2aStatus) : 'idle'}`)} /> | |
| <line x1={nodeRight(NODES.a2a)} y1={ROW1_Y} x2={nodeLeft(NODES.analyzer)} y2={ROW1_Y} | |
| strokeWidth={1.4} markerEnd={`url(#arrow-${conn(a2aStatus, analyzerStatus)})`} | |
| className={cn("pf-connector", `pf-connector-${conn(a2aStatus, analyzerStatus)}`)} /> | |
| <line x1={nodeRight(NODES.analyzer)} y1={ROW1_Y} x2={nodeLeft(NODES.critic)} y2={ROW1_Y} | |
| strokeWidth={1.4} markerEnd={`url(#arrow-${conn(analyzerStatus, criticStatus)})`} | |
| className={cn("pf-connector", `pf-connector-${conn(analyzerStatus, criticStatus)}`)} /> | |
| {/* Critic → Analyzer revision loop (curved path below) - shows when revision loop is active */} | |
| <path | |
| d={`M ${NODES.critic.x} ${nodeBottom(NODES.critic)} | |
| Q ${NODES.critic.x} ${ROW1_Y + 38} ${(NODES.analyzer.x + NODES.critic.x) / 2} ${ROW1_Y + 38} | |
| Q ${NODES.analyzer.x} ${ROW1_Y + 38} ${NODES.analyzer.x} ${nodeBottom(NODES.analyzer)}`} | |
| fill="none" | |
| strokeWidth={1.4} | |
| markerEnd={`url(#arrow-${revisionCount > 0 && (analyzerStatus === 'executing' || criticStatus === 'completed') ? 'completed' : 'idle'})`} | |
| className={cn("pf-connector", `pf-connector-${revisionCount > 0 && (analyzerStatus === 'executing' || criticStatus === 'completed') ? 'completed' : 'idle'}`)} | |
| /> | |
| {/* Critic → Output connector */} | |
| <line x1={nodeRight(NODES.critic)} y1={ROW1_Y} x2={nodeLeft(NODES.output)} y2={ROW1_Y} | |
| strokeWidth={1.4} markerEnd={`url(#arrow-${conn(criticStatus, outputStatus)})`} | |
| className={cn("pf-connector", `pf-connector-${conn(criticStatus, outputStatus)}`)} /> | |
| {/* Researcher ↔ MCP block connector (bidirectional) */} | |
| <line x1={nodeRight(NODES.researcher)} y1={ROW3_Y} x2={MCP_GROUP.x - 2} y2={ROW3_Y} | |
| strokeWidth={1.4} | |
| markerStart={`url(#arrow-start-${researcherStatus === 'executing' || researcherStatus === 'completed' ? 'completed' : 'idle'})`} | |
| markerEnd={`url(#arrow-${researcherStatus === 'executing' || researcherStatus === 'completed' ? 'completed' : 'idle'})`} | |
| className={cn("pf-connector", `pf-connector-${researcherStatus === 'executing' || researcherStatus === 'completed' ? 'completed' : 'idle'}`)} /> | |
| {/* Bidirectional Vertical Connectors */} | |
| {/* User Input ↔ Exchange */} | |
| <line x1={NODES.input.x} y1={nodeBottom(NODES.input)} x2={NODES.exchange.x} y2={nodeTop(NODES.exchange)} | |
| strokeWidth={1.4} | |
| markerStart={`url(#arrow-start-${conn(exchangeStatus, inputStatus)})`} | |
| markerEnd={`url(#arrow-${conn(inputStatus, exchangeStatus)})`} | |
| className={cn("pf-connector", `pf-connector-${inputStatus === 'completed' || exchangeStatus === 'completed' ? 'completed' : 'idle'}`)} /> | |
| {/* A2A ↔ Researcher */} | |
| <line x1={NODES.a2a.x} y1={nodeBottom(NODES.a2a)} x2={NODES.researcher.x} y2={nodeTop(NODES.researcher)} | |
| strokeWidth={1.4} | |
| markerStart={`url(#arrow-start-${conn(researcherStatus, a2aStatus)})`} | |
| markerEnd={`url(#arrow-${conn(a2aStatus, researcherStatus)})`} | |
| className={cn("pf-connector", `pf-connector-${a2aStatus === 'completed' || researcherStatus === 'completed' ? 'completed' : a2aStatus === 'executing' || researcherStatus === 'executing' ? 'executing' : 'idle'}`)} /> | |
| {/* Agent Group ↔ LLM Group (Orchestration connector) */} | |
| <line x1={AGENTS_CENTER_X} y1={AGENTS_GROUP.y + AGENTS_GROUP.height + 2} x2={AGENTS_CENTER_X} y2={LLM_GROUP.y - 2} | |
| markerStart={`url(#arrow-start-${analyzerStatus === 'executing' || criticStatus === 'executing' ? 'executing' : analyzerStatus === 'completed' ? 'completed' : 'idle'})`} | |
| markerEnd={`url(#arrow-${analyzerStatus === 'executing' || criticStatus === 'executing' ? 'executing' : analyzerStatus === 'completed' ? 'completed' : 'idle'})`} | |
| className={cn("pf-connector pf-orchestration", `pf-connector-${analyzerStatus === 'executing' || criticStatus === 'executing' ? 'executing' : analyzerStatus === 'completed' ? 'completed' : 'idle'}`)} /> | |
| {/* Row 1 Nodes - labels above */} | |
| <SVGNode x={NODES.input.x} y={NODES.input.y} icon={User} label="User Input" status={inputStatus} labelPosition="above" /> | |
| <SVGNode x={NODES.cache.x} y={NODES.cache.y} icon={Database} label="Cache" status={cacheState === 'idle' ? 'idle' : 'completed'} isDiamond cacheState={cacheState} labelPosition="above" /> | |
| <SVGNode x={NODES.a2a.x} y={NODES.a2a.y} icon={Network} label="A2A client" status={a2aStatus} labelPosition="above" /> | |
| <SVGNode x={NODES.analyzer.x} y={NODES.analyzer.y} icon={Brain} label="Analyzer" label2="Agent" status={analyzerStatus} isAgent labelPosition="above" /> | |
| <SVGNode x={NODES.critic.x} y={NODES.critic.y} icon={MessageSquare} label="Critic" label2="Agent" status={criticStatus} isAgent labelPosition="above" /> | |
| <SVGNode x={NODES.output.x} y={NODES.output.y} icon={FileOutput} label="Output" status={outputStatus} labelPosition="above" flipIcon /> | |
| {/* Row 2 & 3 Nodes - labels below */} | |
| <SVGNode x={NODES.exchange.x} y={NODES.exchange.y} icon={GitBranch} label="Exchange" label2="Database" status={exchangeStatus} /> | |
| <SVGNode x={NODES.researcher.x} y={NODES.researcher.y} icon={Search} label="Researcher" label2="Agent" status={researcherStatus} isAgent /> | |
| {/* 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 ( | |
| <g key={llm.id}> | |
| <rect | |
| x={llm.x - LLM_WIDTH / 2} | |
| y={ROW2_Y - LLM_HEIGHT / 2} | |
| width={LLM_WIDTH} | |
| height={LLM_HEIGHT} | |
| rx={4} | |
| strokeWidth={1} | |
| className={cn("pf-llm", `pf-llm-${status}`, status === 'executing' && "pf-pulse")} | |
| /> | |
| <text | |
| x={llm.x} | |
| y={ROW2_Y + 4} | |
| textAnchor="middle" | |
| className={`text-[9px] font-medium pf-llm-text-${status}`} | |
| > | |
| {llm.name} | |
| </text> | |
| </g> | |
| ) | |
| })} | |
| {/* 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 ( | |
| <g key={mcp.id} opacity={status === 'failed' || status === 'partial' ? 0.9 : status === 'executing' ? 1 : status === 'completed' ? 0.85 : 0.6}> | |
| <rect x={mcp.x - MCP_SIZE / 2} y={ROW3_Y - MCP_SIZE / 2} width={MCP_SIZE} height={MCP_SIZE} rx={4} | |
| strokeWidth={1} | |
| className={cn("pf-node pf-node-mcp", | |
| status === 'failed' ? 'pf-node-failed' : | |
| status === 'partial' ? 'pf-node-partial' : | |
| status === 'executing' ? 'pf-node-executing pf-pulse' : | |
| status === 'completed' ? 'pf-node-completed' : 'pf-node-idle')} /> | |
| <foreignObject | |
| x={mcp.x - MCP_ICON_SIZE / 2} | |
| y={ROW3_Y - MCP_ICON_SIZE / 2} | |
| width={MCP_ICON_SIZE} | |
| height={MCP_ICON_SIZE} | |
| > | |
| <div className="flex items-center justify-center w-full h-full"> | |
| <Icon className={cn("w-4 h-4", | |
| status === 'failed' ? 'text-red-400' : | |
| status === 'partial' ? 'text-amber-400' : 'pf-icon')} /> | |
| </div> | |
| </foreignObject> | |
| <text x={mcp.x} y={MCP_GROUP.y + MCP_GROUP.height + 12} textAnchor="middle" | |
| className={cn("text-[8px] font-medium", | |
| status === 'failed' ? 'fill-red-400' : | |
| status === 'partial' ? 'fill-amber-400' : 'pf-text-label')}>{mcp.label}</text> | |
| </g> | |
| ) | |
| })} | |
| {/* MCP Group Label */} | |
| <text | |
| x={MCP_GROUP.x + MCP_GROUP.width / 2} | |
| y={MCP_GROUP.y - 6} | |
| textAnchor="middle" | |
| className="text-[9px] font-medium pf-group-label" | |
| > | |
| Custom MCP Servers | |
| </text> | |
| </svg> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| export default ProcessFlow | |