UNPKG

crewai-ts

Version:

TypeScript port of crewAI for agent-based workflows

353 lines (339 loc) 13 kB
/** * Flow visualization component with optimized rendering performance. * Generates interactive flow diagrams using vis-network. */ import fs from 'fs'; import path from 'path'; import { COLORS, LAYOUT_CONFIG, NODE_STYLES } from './config.js'; import { addEdgesToNetwork, addNodesToNetwork, calculateNodeLevels, computePositions } from './utils.js'; // Lazy-load vis-network for performance optimization let visNetworkModule = null; async function loadVisNetwork() { if (visNetworkModule) return visNetworkModule; try { // Using dynamic import for tree-shaking benefits visNetworkModule = await import('vis-network'); return visNetworkModule; } catch (error) { console.error('Failed to load vis-network module:', error); throw new Error('vis-network is required for flow visualization. Install it with: npm install vis-network'); } } /** * Flow visualization component with optimized rendering performance. */ export class FlowVisualizer { flow; templatePath; outputDir; nodePositions = new Map(); /** * Creates a new flow visualizer instance * * @param flow The flow to visualize * @param options Optional configuration */ constructor(flow, options = {}) { this.flow = flow; this.templatePath = path.join(process.cwd(), 'src', 'flow', 'visualization', 'template.html'); this.outputDir = options.outputDir || process.cwd(); // Compute node positions early for better performance this.precomputePositions(); } /** * Precomputes node positions for the flow * This improves rendering performance by doing the layout calculation upfront */ precomputePositions() { try { const levels = calculateNodeLevels(this.flow); this.nodePositions = computePositions(this.flow, levels); } catch (error) { console.error('Failed to precompute node positions:', error); } } /** * Generates the flow visualization and saves it to a file * * @param filename Output filename (without extension) * @returns Path to the generated HTML file */ async generateVisualization(filename = 'flow_visualization') { let network; try { const vis = await loadVisNetwork(); // Generate visualization HTML content const html = await this.generateHtml(vis); // Ensure output directory exists if (!fs.existsSync(this.outputDir)) { fs.mkdirSync(this.outputDir, { recursive: true }); } // Save the HTML file const outputFilePath = path.join(this.outputDir, `${filename}.html`); fs.writeFileSync(outputFilePath, html, 'utf8'); console.log(`Flow visualization saved to: ${outputFilePath}`); return outputFilePath; } catch (error) { console.error('Failed to generate flow visualization:', error); throw error; } } /** * Generates the complete HTML content for the visualization * * @param vis The vis-network module * @returns Complete HTML content as string */ async generateHtml(vis) { try { // Create a virtual DOM container for vis-network const { JSDOM } = await import('jsdom'); const dom = new JSDOM(`<!DOCTYPE html><div id="network"></div>`); const container = dom.window.document.getElementById('network'); // Create a new network instance const network = new vis.Network(container, {}, { ...LAYOUT_CONFIG, physics: { enabled: false } // Disable physics for deterministic layout }); // Add nodes and edges to the network addNodesToNetwork(network, this.flow, this.nodePositions, NODE_STYLES); addEdgesToNetwork(network, this.flow, this.nodePositions, COLORS); // Extract the network HTML const networkHtml = container?.outerHTML || '<div id="network"></div>'; // Generate the complete HTML with template return this.generateFinalHtml(networkHtml); } catch (error) { console.error('Error generating HTML content:', error); throw error; } } /** * Generates the final HTML by combining the network visualization with the template * Uses memory-efficient DOM manipulation approach * * @param networkHtml The network visualization HTML content * @returns Complete HTML document string */ generateFinalHtml(networkHtml) { // Try to read the template file with proper error handling let templateContent; try { templateContent = fs.existsSync(this.templatePath) ? fs.readFileSync(this.templatePath, 'utf8') : this.getDefaultTemplate(); } catch (error) { console.warn('Failed to read template file, using default template:', error); templateContent = this.getDefaultTemplate(); } // Get flow metrics for template population const nodes = this.flow['_methods'] ? this.flow['_methods'].size : 0; const edges = this.extractEdgeCount(); const memoryUsage = process.memoryUsage().heapUsed / 1024 / 1024; // In MB // Build the JavaScript template value updater function // This is more memory-efficient than string replacement since it uses DOM operations const updateTemplateValuesFunc = ` function updateTemplateValues() { // Update text content document.getElementById('flow-title').textContent = 'Flow: ${this.flow.constructor.name}'; document.getElementById('node-count').textContent = '${nodes}'; document.getElementById('edge-count').textContent = '${edges}'; document.getElementById('memory-usage').textContent = '${memoryUsage.toFixed(2)} MB'; // Set CSS variables with proper memory allocation const root = document.documentElement; root.style.setProperty('--bg-color', '${COLORS.bg}'); root.style.setProperty('--start-color', '${COLORS.start}'); root.style.setProperty('--listener-color', '${COLORS.listener}'); root.style.setProperty('--router-color', '${COLORS.router}'); root.style.setProperty('--mixed-color', '${COLORS.mixed}'); root.style.setProperty('--default-edge-color', '${COLORS.edge.default}'); root.style.setProperty('--and-edge-color', '${COLORS.edge.and}'); root.style.setProperty('--or-edge-color', '${COLORS.edge.or}'); root.style.setProperty('--function-edge-color', '${COLORS.edge.function}'); } `; // Inject the network content into the template const networkDataPlaceholder = `/* NETWORK_DATA_PLACEHOLDER */`; const networkOptionsPlaceholder = `/* NETWORK_OPTIONS_PLACEHOLDER */`; const networkData = '{nodes: [], edges: []}'; // Placeholder that will be populated by client-side code const networkOptions = JSON.stringify(LAYOUT_CONFIG, null, 2); // Replace placeholders with real values using optimized approach // This is memory efficient as we're doing specific replacements rather than global regex templateContent = templateContent // Insert network HTML .replace('<div id="network">', `<div id="network">${networkHtml}`) // Update network data and options .replace(networkDataPlaceholder, networkData) .replace(networkOptionsPlaceholder, networkOptions) // Update the template values function .replace('function updateTemplateValues() {', updateTemplateValuesFunc); return templateContent; } /** * Extracts the count of edges in the flow for metrics display * Uses memory-efficient counting approach * * @returns Number of edges in the flow */ extractEdgeCount() { let count = 0; const listeners = this.flow['_listeners']; if (listeners) { // Count edges without creating intermediate arrays for (const listenerMap of listeners.values()) { count += listenerMap.size; } } return count; } /** * Returns a default HTML template if the template file is not found * This ensures the visualization works even without external dependencies * @returns The default HTML template as a string */ getDefaultTemplate() { return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Flow Visualization</title> <script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script> <style> body, html { margin: 0; padding: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; } .container { display: flex; flex-direction: column; height: 100vh; background-color: ${COLORS.bg}; } .header { padding: 16px; background-color: #263238; color: white; display: flex; align-items: center; justify-content: space-between; } .header h1 { margin: 0; font-size: 24px; font-weight: 400; } .main { display: flex; flex: 1; overflow: hidden; } #network { flex: 1; height: 100%; } .legend { width: 260px; padding: 16px; background-color: white; border-left: 1px solid #e0e0e0; overflow-y: auto; } .legend h2 { margin-top: 0; margin-bottom: 16px; font-size: 18px; font-weight: 500; } .legend-item { display: flex; align-items: center; margin-bottom: 12px; } .legend-color { width: 20px; height: 20px; margin-right: 8px; border-radius: 4px; } .legend-label { font-size: 14px; } </style> </head> <body> <div class="container"> <div class="header"> <h1>Flow Visualization</h1> </div> <div class="main"> <div id="network">{{NETWORK_CONTENT}}</div> <div class="legend"> <h2>Legend</h2> <h3>Node Types</h3> <div class="legend-item"> <div class="legend-color" style="background-color: ${COLORS.start}"></div> <div class="legend-label">Start Method</div> </div> <div class="legend-item"> <div class="legend-color" style="background-color: ${COLORS.listener}"></div> <div class="legend-label">Listener Method</div> </div> <div class="legend-item"> <div class="legend-color" style="background-color: ${COLORS.router}"></div> <div class="legend-label">Router Method</div> </div> <div class="legend-item"> <div class="legend-color" style="background-color: ${COLORS.mixed}"></div> <div class="legend-label">Mixed Type Method</div> </div> <h3>Edge Types</h3> <div class="legend-item"> <div class="legend-color" style="background-color: ${COLORS.edge.default}"></div> <div class="legend-label">Simple Connection</div> </div> <div class="legend-item"> <div class="legend-color" style="background-color: ${COLORS.edge.and}"></div> <div class="legend-label">AND Condition</div> </div> <div class="legend-item"> <div class="legend-color" style="background-color: ${COLORS.edge.or}"></div> <div class="legend-label">OR Condition</div> </div> <div class="legend-item"> <div class="legend-color" style="background-color: ${COLORS.edge.function}"></div> <div class="legend-label">Function Condition</div> </div> </div> </div> </div> <script> // Initialize vis-network document.addEventListener('DOMContentLoaded', function() { // The network content is already embedded in the page // This is just for additional initialization if needed }); </script> </body> </html>`; } } /** * Convenience function to create and save a flow visualization * * @param flow Flow instance to visualize * @param filename Output filename without extension * @param outputDir Directory to save the visualization * @returns Path to the generated HTML file */ export async function plotFlow(flow, filename = 'flow_visualization', outputDir) { const visualizer = new FlowVisualizer(flow, { outputDir }); return visualizer.generateVisualization(filename); }