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.

6
Node Types
4
Custom Hooks
3
Contexts
4
SSE Events

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 ChoiceWhat Was ChosenWhat Was RejectedWhy
Graph libraryReact Flow (@xyflow/react)D3.js / custom canvasProduction-ready drag-and-drop, edge routing, minimap; months of saved dev time
SSE clientfetch + ReadableStreamNative EventSource APIEventSource only supports GET; the run endpoint requires POST with JSON body
Node identityCanonical key + 6-char random suffixUUID / sequential IDsSuffix ensures uniqueness for multiple instances of same agent; base key maps back to ANALYST_CONFIG
State managementReact Context + custom hooksRedux / ZustandThree 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 / FunctionFilePurpose
AgentNodenodes/types.tsCustom React Flow node type for analyst agents with status and analysis display
PortfolioStartNodenodes/types.tsEntry node where users configure tickers, dates, and initial portfolio
PortfolioManagerNodenodes/types.tsTerminal node that displays the portfolio manager’s trading decisions
InvestmentReportNodenodes/types.tsOutput node that renders formatted investment reports
JsonOutputNodenodes/types.tsOutput node that displays raw JSON results
StockAnalyzerNodenodes/types.tsUtility node for stock-specific analysis views
api.runHedgeFund()services/api.tsPOST + SSE streaming client — parses events and updates node context
api.getAgents()services/api.tsFetches available agent list from backend for sidebar population
extractBaseAgentKey()data/node-mappings.tsStrips 6-char random suffix from node IDs to match backend agent keys
getAgents()data/agents.tsCached wrapper around api.getAgents() to avoid redundant API calls
useFlowManagement()hooks/use-flow-management.tsHook managing flow CRUD, search, load, save with node state persistence
useNodeContext()contexts/node-context.tsContext providing per-node status, messages, analysis data, and output data
useFlowContext()contexts/flow-context.tsContext managing React Flow instance, current flow ID, and viewport state

Data Structures

TypeNameDescription
TypeScript typeAppNodeUnion of all 6 custom node types plus BuiltInNode from React Flow
TypeScript typeNodeStatusString literal union: 'IDLE' | 'IN_PROGRESS' | 'COMPLETE' | 'ERROR'
InterfaceAgent{key, display_name, description, investing_style, order} — mirrors backend ANALYST_CONFIG
InterfaceFlow{id, name, description, nodes, edges, viewport, data, is_template, tags}
InterfaceHedgeFundRequestPOST body: {tickers, graph_nodes, graph_edges, model_name, model_provider, ...}
InterfaceNodeTypeDefinition{createNode(position)} — factory for creating positioned nodes from sidebar drags

Interactions

DirectionPartnerInterfaceDescription
→FastAPI BackendPOST /hedge-fund/runSends graph definition + config, receives SSE stream of progress events
→FastAPI BackendGET /hedge-fund/agentsFetches available agent list to populate the sidebar
→FastAPI BackendGET/POST /flowsCRUD operations for saving and loading flow configurations
→FastAPI BackendGET /language-modelsFetches available LLM models for the model selector dropdown
←NodeContextupdateAgentNode()SSE handler updates per-node status, causing React re-renders on the canvas
←FlowContextsaveCurrentFlow()Serializes React Flow state + node internal states for backend persistence
←React Flow@xyflow/reactCanvas rendering, drag-and-drop, edge routing, zoom/pan, minimap

Configuration

Knob / ParameterDefaultDescription
VITE_API_URLhttp://localhost:8000Backend API base URL; set via .env or Vite env var
Node ID suffix length6 charactersRandom alphanumeric suffix for globally unique node IDs
SSE poll timeout1 secondInterval between ReadableStream.read() calls for chunked SSE parsing
Agent cacheIn-memory singletonAgent 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 _agent from 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 useEffect cleanup) calls this to cancel in-flight requests when the component unmounts, preventing memory leaks and orphaned network connections.

Edge Cases & Boundaries

Edge CaseWhat HappensWhere in CodeRisk Level
User navigates away during runAbortController.abort() cancels fetch; backend detects disconnect and cancels agentsapi.runHedgeFundLow
SSE chunk splits mid-eventPartial event stays in buffer; processed on next chunk arrivalprocessStreamLow
Unknown agent name in SSE eventfind() returns undefined; fallback to raw baseKey — node update silently ignoredcase 'progress'Medium
Multiple instances of same agentEach has a unique 6-char suffix; extractBaseAgentKey() maps both to same canonical keynode-mappings.tsMedium
Backend returns HTTP errorFetch rejects; error logged to console; nodes stay in last known stateapi.runHedgeFundHigh

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]);