webapp/frontend/ — React Flow Visual Workflow Builder
Visual drag-and-drop agent workflow builder with real-time execution status
The frontend lets non-developers compose agent pipelines visually. React Flow provides the canvas, custom node types represent agents and Portfolio Tracking & Performance Metrics" href="../../backtesting_doc/portfolio/index.html">portfolio components, and custom hooks manage flow persistence, node state, and SSE-driven real-time status updates as agents execute.
What is this & why does it exist?
The frontend lets non-developers compose agent pipelines visually. React Flow provides the canvas where users drag agent nodes from a sidebar, connect them with edges, configure tickers and models, and press “Run”. The most sophisticated part is the SSE integration — as the backend executes agents, the frontend receives progress events and updates individual node visuals in real-time (idle → in-progress → complete/error). Flow configurations are persisted to the backend database so users can save, load, and reuse pipelines.
Real-world analogy
The frontend is like a visual music sequencer (think GarageBand). Each track (node) represents an instrument (agent). You drag tracks onto the timeline (canvas), connect them with wires (edges), press play (run), and watch each track light up as it plays its part (real-time status updates). The sidebar is your instrument library — Warren Buffett, Cathie Wood, and Ben Graham are all different instruments you can drop in. The output node is the master mix — the final combined recording of all tracks playing together.
Why Is It Designed This Way?
React Flow was chosen because it provides a production-ready Orchestration" href="../../core_doc/graph/index.html">graph canvas with built-in drag-and-drop, edge routing, zoom/pan, and minimap — all of which would take months to build from scratch. Custom node types (rather than generic boxes) give each agent a distinct visual identity with status indicators, analysis output, and ticker badges. The extractBaseAgentKey() function exists because React Flow requires globally unique node IDs, but the backend’s ANALYST_CONFIG uses canonical keys — the 6-character random suffix bridges this gap. SSE via fetch + ReadableStream was chosen over EventSource because SSE with POST bodies isn’t supported by the native EventSource API.
| Design Choice | What Was Chosen | What Was Rejected | Why |
|---|---|---|---|
| Graph library | React Flow (@xyflow/react) | D3.js / custom canvas | Production-ready drag-and-drop, edge routing, minimap; months of saved dev time |
| SSE client | fetch + ReadableStream | Native EventSource API | EventSource only supports GET; the run endpoint requires POST with JSON body |
| Node identity | Canonical key + 6-char random suffix | UUID / sequential IDs | Suffix ensures uniqueness for multiple instances of same agent; base key maps back to ANALYST_CONFIG |
| State management | React Context + custom hooks | Redux / Zustand | Three focused contexts (flow, node, toast) keep state colocated; no global store overhead |
Contents
Architecture
The frontend follows a layered architecture: UI components → custom hooks → contexts → services (API). React Flow manages the visual graph; contexts manage runtime state; services handle backend communication.
flowchart TD
subgraph UI["UI Components"]
Canvas["React Flow Canvas"]
Sidebar["Node Sidebar"]
Toolbar["Run Toolbar"]
Output["Output Panel"]
end
subgraph Nodes["Custom Node Types"]
AN["AgentNode"]
PSN["PortfolioStartNode"]
PMN["PortfolioManagerNode"]
IRN["InvestmentReportNode"]
JON["JsonOutputNode"]
SAN["StockAnalyzerNode"]
end
subgraph Hooks["Custom Hooks"]
UFM["useFlowManagement"]
UNS["useNodeState"]
UFC["useFlowConnection"]
end
subgraph Contexts
FC["FlowContext"]
NC["NodeContext"]
end
subgraph Services
API["api.ts\n(runHedgeFund, getAgents)"]
FS["flow-service.ts"]
end
subgraph Backend
BE["FastAPI\n(/hedge-fund/run)"]
end
Sidebar -->|"drag"| Canvas
Canvas --> Nodes
Toolbar -->|"Run click"| API
API -->|"POST + SSE stream"| BE
BE -->|"SSE events"| API
API -->|"updateAgentNode"| NC
NC -->|"status change"| Nodes
UFM -->|"save/load"| FS
FS -->|"CRUD"| BE
SSE Real-Time Update Flow
When the user clicks “Run”, the frontend POSTs the graph definition and opens a streaming connection. Each SSE event triggers a node status update that React re-renders in real time.
sequenceDiagram
participant User
participant Canvas as React Flow Canvas
participant API as api.runHedgeFund
participant NC as NodeContext
participant Backend as FastAPI
User->>Canvas: Click "Run"
Canvas->>API: runHedgeFund(nodes, edges, tickers)
API->>Backend: POST /hedge-fund/run
Backend-->>API: SSE: start event
API->>NC: resetAllNodes()
NC-->>Canvas: All nodes to "idle"
loop For each agent
Backend-->>API: SSE: progress event
API->>API: extractBaseAgentKey(agent_name)
API->>NC: updateAgentNode(nodeId, IN_PROGRESS)
NC-->>Canvas: Node glows "in-progress"
Backend-->>API: SSE: progress event (Done)
API->>NC: updateAgentNode(nodeId, COMPLETE)
NC-->>Canvas: Node turns "complete"
end
Backend-->>API: SSE: complete event
API->>NC: setOutputNodeData(decisions)
NC-->>Canvas: Output panel shows results
Key Classes & Functions
| Class / Function | File | Purpose |
|---|---|---|
AgentNode | nodes/types.ts | Custom React Flow node type for analyst agents with status and analysis display |
PortfolioStartNode | nodes/types.ts | Entry node where users configure tickers, dates, and initial portfolio |
PortfolioManagerNode | nodes/types.ts | Terminal node that displays the portfolio manager’s trading decisions |
InvestmentReportNode | nodes/types.ts | Output node that renders formatted investment reports |
JsonOutputNode | nodes/types.ts | Output node that displays raw JSON results |
StockAnalyzerNode | nodes/types.ts | Utility node for stock-specific analysis views |
api.runHedgeFund() | services/api.ts | POST + SSE streaming client — parses events and updates node context |
api.getAgents() | services/api.ts | Fetches available agent list from backend for sidebar population |
extractBaseAgentKey() | data/node-mappings.ts | Strips 6-char random suffix from node IDs to match backend agent keys |
getAgents() | data/agents.ts | Cached wrapper around api.getAgents() to avoid redundant API calls |
useFlowManagement() | hooks/use-flow-management.ts | Hook managing flow CRUD, search, load, save with node state persistence |
useNodeContext() | contexts/node-context.ts | Context providing per-node status, messages, analysis data, and output data |
useFlowContext() | contexts/flow-context.ts | Context managing React Flow instance, current flow ID, and viewport state |
Data Structures
| Type | Name | Description |
|---|---|---|
| TypeScript type | AppNode | Union of all 6 custom node types plus BuiltInNode from React Flow |
| TypeScript type | NodeStatus | String literal union: 'IDLE' | 'IN_PROGRESS' | 'COMPLETE' | 'ERROR' |
| Interface | Agent | {key, display_name, description, investing_style, order} — mirrors backend ANALYST_CONFIG |
| Interface | Flow | {id, name, description, nodes, edges, viewport, data, is_template, tags} |
| Interface | HedgeFundRequest | POST body: {tickers, graph_nodes, graph_edges, model_name, model_provider, ...} |
| Interface | NodeTypeDefinition | {createNode(position)} — factory for creating positioned nodes from sidebar drags |
Interactions
| Direction | Partner | Interface | Description |
|---|---|---|---|
| → | FastAPI Backend | POST /hedge-fund/run | Sends graph definition + config, receives SSE stream of progress events |
| → | FastAPI Backend | GET /hedge-fund/agents | Fetches available agent list to populate the sidebar |
| → | FastAPI Backend | GET/POST /flows | CRUD operations for saving and loading flow configurations |
| → | FastAPI Backend | GET /language-models | Fetches available LLM models for the model selector dropdown |
| ← | NodeContext | updateAgentNode() | SSE handler updates per-node status, causing React re-renders on the canvas |
| ← | FlowContext | saveCurrentFlow() | Serializes React Flow state + node internal states for backend persistence |
| ← | React Flow | @xyflow/react | Canvas rendering, drag-and-drop, edge routing, zoom/pan, minimap |
Configuration
| Knob / Parameter | Default | Description |
|---|---|---|
VITE_API_URL | http://localhost:8000 | Backend API base URL; set via .env or Vite env var |
| Node ID suffix length | 6 characters | Random alphanumeric suffix for globally unique node IDs |
| SSE poll timeout | 1 second | Interval between ReadableStream.read() calls for chunked SSE parsing |
| Agent cache | In-memory singleton | Agent list fetched once and cached for session lifetime |
Directory Map
Source tree for frontend
app/frontend/src/
services/
api.ts ← API client: runHedgeFund (SSE), getAgents, saveJsonFile
flow-service.ts ← Flow CRUD service (save, load, delete)
types.ts ← HedgeFundRequest, BacktestRequest interfaces
hooks/
use-flow-management.ts ← Flow CRUD hook with search, save, load
use-node-state.ts ← Per-node internal state management
use-flow-connection.ts ← Edge connection validation and management
use-toast-manager.ts ← Toast notification hook
contexts/
flow-context.ts ← React Flow instance + current flow ID
node-context.ts ← Per-node status, messages, analysis data
nodes/
types.ts ← TypeScript types for 6 custom node types + AppNode union
agent-node.tsx ← Agent node component with status indicator
portfolio-start-node.tsx ← Portfolio input node component
portfolio-manager-node.tsx ← Portfolio manager output node
data/
agents.ts ← Cached agent list fetcher
node-mappings.ts ← Node factories + extractBaseAgentKey()
models.ts ← Language model list and types
Annotated Code Walkthrough
The most important code in this sub-module, annotated with WHY each piece exists:
SSE Event Handling (services/api.ts)
runHedgeFund: (params: HedgeFundRequest,
nodeContext: ReturnType<typeof useNodeContext>,
flowId: string | null = null): (() => void) => {
const controller = new AbortController(); // WHY: Enables cancellation on unmount
// WHY: fetch() + ReadableStream because EventSource only supports GET
fetch(`${API_BASE_URL}/hedge-fund/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
signal: controller.signal,
})
.then(response => {
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';
const processStream = async () => {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// WHY: SSE events are separated by double newlines
const events = buffer.split('\n\n');
buffer = events.pop() || ''; // Keep partial event in buffer
for (const eventText of events) {
const eventTypeMatch = eventText.match(/^event: (.+)$/m);
const dataMatch = eventText.match(/^data: (.+)$/m);
if (eventTypeMatch && dataMatch) {
const eventType = eventTypeMatch[1];
const eventData = JSON.parse(dataMatch[1]);
switch (eventType) {
case 'start':
nodeContext.resetAllNodes(flowId); // WHY: Clean slate for new run
break;
case 'progress':
// WHY: Backend sends "warren_buffett_agent" but node IDs are
// "warren_buffett_abc123" - must strip suffix to match
const baseKey = eventData.agent.replace('_agent', '');
const nodeId = getAgentIds().find(id =>
extractBaseAgentKey(id) === baseKey
) || baseKey;
nodeContext.updateAgentNode(flowId, nodeId, {
status: eventData.status === 'Done' ? 'COMPLETE' : 'IN_PROGRESS',
ticker: eventData.ticker,
analysis: eventData.analysis,
});
break;
case 'complete':
nodeContext.setOutputNodeData(flowId, eventData.data);
nodeContext.updateAgentNodes(flowId, getAgentIds(), 'COMPLETE');
break;
}
}
}
}
};
processStream();
});
return () => controller.abort(); // WHY: Cleanup function for React useEffect
};
Line-by-line:
AbortController: React components unmount when the user navigates away. The abort controller cancels the fetch, which in turn causes the backend to detect disconnection and cancel the expensive LLM execution.buffer.split('\n\n'): SSE frames are delimited by double newlines. Because network chunks don’t respect frame boundaries, we buffer partial data and only process complete frames.extractBaseAgentKey()matching: The backend emits"warren_buffett_agent"as the agent name. The frontend node IDs are"warren_buffett_abc123". Stripping_agentfrom the backend name and the 6-char suffix from the node ID produces"warren_buffett"for both — enabling the match.- Return abort function: The caller (typically a
useEffectcleanup) calls this to cancel in-flight requests when the component unmounts, preventing memory leaks and orphaned network connections.
Edge Cases & Boundaries
| Edge Case | What Happens | Where in Code | Risk Level |
|---|---|---|---|
| User navigates away during run | AbortController.abort() cancels fetch; backend detects disconnect and cancels agents | api.runHedgeFund | Low |
| SSE chunk splits mid-event | Partial event stays in buffer; processed on next chunk arrival | processStream | Low |
| Unknown agent name in SSE event | find() returns undefined; fallback to raw baseKey — node update silently ignored | case 'progress' | Medium |
| Multiple instances of same agent | Each has a unique 6-char suffix; extractBaseAgentKey() maps both to same canonical key | node-mappings.ts | Medium |
| Backend returns HTTP error | Fetch rejects; error logged to console; nodes stay in last known state | api.runHedgeFund | High |
Code Examples
Example: Creating a custom node type
// data/node-mappings.ts - adding a new node type
const baseNodeTypeDefinitions: Record<string, NodeTypeDefinition> = {
"Portfolio Input": {
createNode: (position: { x: number, y: number }): AppNode => ({
id: `portfolio-start-node_${generateUniqueIdSuffix()}`,
type: "portfolio-start-node",
position,
data: {
name: "Portfolio Input",
description: "Enter your portfolio including tickers and prices.",
status: "Idle",
},
}),
},
// Each agent from the backend gets a dynamically generated entry:
// "Warren Buffett": { createNode: (pos) => ({ id: "warren_buffett_abc123", ... }) }
};
Example: Using the flow management hook
// In a React component
const {
flows, searchQuery, isLoading,
handleSaveCurrentFlow, handleLoadFlow, handleDeleteFlow,
setSearchQuery,
} = useFlowManagement();
// Save current canvas state to backend
await handleSaveCurrentFlow();
// Load a saved flow
await handleLoadFlow(flows[0]);