crewai-ts
Version:
TypeScript port of crewAI for agent-based workflows
701 lines (671 loc) • 23.5 kB
JavaScript
/**
* FlowDependencyVisualizer
*
* Provides optimized visualization tools for flow dependencies and execution performance,
* helping users identify bottlenecks and optimize multi-flow orchestration.
*/
import * as fs from 'fs/promises';
import path from 'path';
/**
* Visualizer for flow dependencies and execution performance with optimized rendering.
*/
export class FlowDependencyVisualizer {
orchestrator;
options;
// Cache for performance optimization
nodeCache = new Map();
edgeCache = new Map();
constructor(orchestrator, options = {}) {
this.orchestrator = orchestrator;
// Apply default options with performance optimizations
this.options = {
outputPath: './visualizations',
filename: `flow-dependencies-${Date.now()}`,
title: 'Flow Dependencies',
layout: 'hierarchical',
colorScheme: 'status',
includeMetadata: true,
optimizationLevel: 'medium',
maxNodeSize: 100, // Limit for performance
groupBy: 'none',
includeExecutionMetrics: true,
formatOptions: {
useStaticHTML: true,
compressOutput: true,
inlineStyles: true,
enableInteractivity: true
},
...options
};
}
/**
* Generate and save flow dependency visualization with performance optimizations
*/
async visualize() {
// Get graph data from orchestrator with optimized structure
const graphData = this.buildGraph();
// Apply performance optimizations based on optimization level
this.applyOptimizations(graphData);
// Generate HTML visualization
const html = this.generateVisualizationHtml(graphData);
// Ensure output directory exists
await fs.mkdir(this.options.outputPath, { recursive: true })
.catch(err => {
if (err.code !== 'EEXIST')
throw err;
});
// Write visualization to file
const outputPath = path.join(this.options.outputPath, `${this.options.filename}.html`);
await fs.writeFile(outputPath, html);
return outputPath;
}
/**
* Build optimized graph data from orchestrator state
*/
buildGraph() {
// Get flow graph data from orchestrator
const orchestratorGraph = this.orchestrator.getFlowGraph();
const executionMetrics = this.options.includeExecutionMetrics
? this.orchestrator.getExecutionMetrics()
: null;
// Create nodes and edges with performance-optimized data structures
const nodes = [];
const edges = [];
// Performance optimization: pre-allocate arrays when size is known
nodes.length = orchestratorGraph.nodes.length;
edges.length = orchestratorGraph.edges.length;
// Process nodes with optimized attribute handling
for (let i = 0; i < orchestratorGraph.nodes.length; i++) {
const node = orchestratorGraph.nodes[i];
// Use cache for unchanged nodes
const cacheKey = `${node.id}_${node.status}_${node.executionTime}`;
if (this.nodeCache.has(cacheKey)) {
nodes[i] = this.nodeCache.get(cacheKey);
continue;
}
// Create new node with styling based on status and performance
const graphNode = {
id: node.id,
label: this.formatNodeLabel(node),
status: node.status,
executionTime: node.executionTime,
priority: node.priority,
metadata: this.options.includeMetadata ? node.metadata : undefined
};
// Apply styling based on color scheme
this.applyNodeStyling(graphNode);
// Apply grouping if enabled
if (this.options.groupBy !== 'none') {
this.applyNodeGrouping(graphNode);
}
// Store in cache and result
this.nodeCache.set(cacheKey, graphNode);
nodes[i] = graphNode;
}
// Process edges with optimized attribute handling
for (let i = 0; i < orchestratorGraph.edges.length; i++) {
const edge = orchestratorGraph.edges[i];
// Use cache for unchanged edges
const cacheKey = `${edge.from}_${edge.to}_${edge.hasCondition}_${edge.hasDataMapping}`;
if (this.edgeCache.has(cacheKey)) {
edges[i] = this.edgeCache.get(cacheKey);
continue;
}
// Create new edge with styling
const graphEdge = {
id: `${edge.from}_to_${edge.to}`,
from: edge.from,
to: edge.to,
label: edge.hasCondition ? 'conditional' : (edge.hasDataMapping ? 'data' : undefined),
arrows: { to: true },
dashes: edge.hasCondition
};
// Apply edge styling
this.applyEdgeStyling(graphEdge);
// Store in cache and result
this.edgeCache.set(cacheKey, graphEdge);
edges[i] = graphEdge;
}
return { nodes, edges };
}
/**
* Format node label with optimized output
*/
formatNodeLabel(node) {
const parts = [node.id];
if (node.status) {
parts.push(`[${node.status}]`);
}
if (node.executionTime !== undefined) {
parts.push(`${node.executionTime.toFixed(2)}ms`);
}
return parts.join('\n');
}
/**
* Apply styling to nodes based on color scheme and status
*/
applyNodeStyling(node) {
// Default styling
node.shape = 'box';
node.borderWidth = 1;
// Apply color based on scheme
switch (this.options.colorScheme) {
case 'status':
// Status-based coloring
switch (node.status) {
case 'pending':
node.color = '#E0E0E0';
node.borderColor = '#9E9E9E';
break;
case 'running':
node.color = '#FFF59D';
node.borderColor = '#FBC02D';
break;
case 'completed':
node.color = '#A5D6A7';
node.borderColor = '#4CAF50';
break;
case 'failed':
node.color = '#EF9A9A';
node.borderColor = '#F44336';
break;
default:
node.color = '#E1F5FE';
node.borderColor = '#03A9F4';
}
break;
case 'performance':
// Performance-based coloring (gradient from green to red)
if (node.executionTime !== undefined) {
// Calculate color based on execution time
// Green (fast) to red (slow)
const normalized = Math.min(1, node.executionTime / 1000); // Normalize to 0-1 range (1 second max)
const r = Math.floor(normalized * 255);
const g = Math.floor((1 - normalized) * 255);
node.color = `rgb(${r}, ${g}, 0)`;
node.borderColor = `rgb(${Math.min(255, r + 40)}, ${Math.min(255, g + 40)}, 40)`;
}
else {
// No execution time data
node.color = '#E0E0E0';
node.borderColor = '#9E9E9E';
}
break;
case 'priority':
// Priority-based coloring
if (node.priority !== undefined) {
// Higher priority = deeper color
const priority = Math.max(0, Math.min(10, node.priority)); // Clamp to 0-10 range
const intensity = 55 + (priority * 20); // 55-255 range
node.color = `rgb(${intensity}, ${intensity}, 255)`;
node.borderColor = `rgb(0, 0, 200)`;
}
else {
// No priority data
node.color = '#E0E0E0';
node.borderColor = '#9E9E9E';
}
break;
}
// Set size based on priority if available
if (node.priority !== undefined) {
const baseFontSize = 14;
const baseSize = 30;
node.size = baseSize + (node.priority * 2);
node.font = {
size: baseFontSize + (node.priority > 5 ? 2 : 0),
bold: node.priority > 7
};
}
}
/**
* Apply styling to edges
*/
applyEdgeStyling(edge) {
// Default styling
edge.width = 1;
// Conditional edges
if (edge.dashes) {
edge.color = '#FF9800'; // Orange for conditional
}
else if (edge.label === 'data') {
edge.color = '#2196F3'; // Blue for data mapping
edge.width = 2;
}
else {
edge.color = '#9E9E9E'; // Gray for regular
}
}
/**
* Apply node grouping based on options
*/
applyNodeGrouping(node) {
switch (this.options.groupBy) {
case 'status':
node.group = node.status || 'unknown';
break;
case 'priority':
if (node.priority !== undefined) {
// Group by priority ranges
if (node.priority >= 8) {
node.group = 'high-priority';
}
else if (node.priority >= 4) {
node.group = 'medium-priority';
}
else {
node.group = 'low-priority';
}
}
else {
node.group = 'no-priority';
}
break;
case 'metadata':
if (node.metadata && this.options.groupByMetadataField) {
node.group = String(node.metadata[this.options.groupByMetadataField] || 'unknown');
}
else {
node.group = 'unknown';
}
break;
}
}
/**
* Apply performance optimizations based on optimization level
*/
applyOptimizations(graphData) {
const { optimizationLevel, maxNodeSize } = this.options;
// Limit the total number of nodes for performance
if (graphData.nodes.length > maxNodeSize) {
console.warn(`Limiting visualization to ${maxNodeSize} nodes for performance reasons`);
graphData.nodes = graphData.nodes.slice(0, maxNodeSize);
// Filter edges to only include connections between remaining nodes
const nodeIds = new Set(graphData.nodes.map(n => n.id));
graphData.edges = graphData.edges.filter(e => nodeIds.has(e.from) && nodeIds.has(e.to));
}
// Apply additional optimizations based on level
switch (optimizationLevel) {
case 'high':
// Maximum optimization (minimal details)
graphData.nodes.forEach(node => {
// Remove metadata to reduce size
delete node.metadata;
// Simplify labels
node.label = node.id;
});
// Remove edge labels
graphData.edges.forEach(edge => {
delete edge.label;
});
break;
case 'medium':
// Balanced optimization
graphData.nodes.forEach(node => {
// Keep only essential metadata
if (node.metadata) {
const essentialKeys = ['type', 'category', 'owner'];
const essentialMetadata = {};
essentialKeys.forEach(key => {
if (node.metadata[key] !== undefined) {
essentialMetadata[key] = node.metadata[key];
}
});
node.metadata = essentialMetadata;
}
});
break;
case 'low':
// Minimal optimization (keep most details)
// No additional optimizations needed
break;
}
}
/**
* Generate optimized HTML visualization
*/
generateVisualizationHtml(graphData) {
const { formatOptions, title, layout } = this.options;
// Get execution metrics if enabled
const executionMetrics = this.options.includeExecutionMetrics
? this.orchestrator.getExecutionMetrics()
: null;
// Convert graph data to JSON with minimal whitespace
const nodesJson = formatOptions?.compressOutput
? JSON.stringify(graphData.nodes)
: JSON.stringify(graphData.nodes, null, 2);
const edgesJson = formatOptions?.compressOutput
? JSON.stringify(graphData.edges)
: JSON.stringify(graphData.edges, null, 2);
// Determine layout options
const layoutOptions = this.getLayoutOptions();
// Generate metrics HTML if enabled
const metricsHtml = executionMetrics
? this.generateMetricsHtml(executionMetrics)
: '';
// Generate HTML
let html = `
<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style type="text/css">
body, html {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
width: 100%;
height: 100%;
overflow: hidden;
}
header {
padding: 10px;
background-color: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
margin: 0;
font-size: 18px;
}
#container {
display: flex;
width: 100%;
height: calc(100% - 50px);
}
#network-container {
flex: 3;
height: 100%;
}
#metrics-panel {
flex: 1;
padding: 15px;
overflow-y: auto;
background-color: #fafafa;
border-left: 1px solid #e0e0e0;
max-width: 350px;
}
.metric-card {
background: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
margin-bottom: 15px;
padding: 10px;
}
.metric-title {
font-weight: bold;
margin-bottom: 8px;
color: #333;
font-size: 14px;
}
.metric-value {
font-size: 22px;
color: #2196F3;
font-weight: 300;
}
.metric-subtitle {
font-size: 12px;
color: #757575;
margin-top: 3px;
}
.flow-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin-top: 10px;
}
.flow-table th {
text-align: left;
background-color: #f5f5f5;
padding: 5px 8px;
}
.flow-table td {
padding: 5px 8px;
border-top: 1px solid #eeeeee;
}
.flow-status {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
}
.status-completed {
background-color: #4CAF50;
}
.status-failed {
background-color: #F44336;
}
.status-running {
background-color: #FBC02D;
}
.status-pending {
background-color: #9E9E9E;
}
.legend {
display: flex;
gap: 10px;
margin: 0;
font-size: 12px;
}
.legend-item {
display: flex;
align-items: center;
}
</style>
</head>
<body>
<header>
<h1>${title}</h1>
<div class="legend">
<div class="legend-item"><span class="flow-status status-pending"></span> Pending</div>
<div class="legend-item"><span class="flow-status status-running"></span> Running</div>
<div class="legend-item"><span class="flow-status status-completed"></span> Completed</div>
<div class="legend-item"><span class="flow-status status-failed"></span> Failed</div>
</div>
</header>
<div id="container">
<div id="network-container"></div>
${executionMetrics ? '<div id="metrics-panel">' + metricsHtml + '</div>' : ''}
</div>
<script type="text/javascript">
// Create a network
const container = document.getElementById('network-container');
// Provide the data in the vis format
const nodes = new vis.DataSet(${nodesJson});
const edges = new vis.DataSet(${edgesJson});
const data = { nodes, edges };
// Options
const options = {
nodes: {
shape: 'box',
margin: 10,
font: {
multi: true,
size: 14
}
},
edges: {
smooth: true,
arrows: {
to: { enabled: true, scaleFactor: 1 }
}
},
physics: {
enabled: true,
stabilization: {
iterations: 100
}
},
layout: ${JSON.stringify(layoutOptions)},
groups: {
'completed': { color: { background: '#A5D6A7', border: '#4CAF50' } },
'running': { color: { background: '#FFF59D', border: '#FBC02D' } },
'pending': { color: { background: '#E0E0E0', border: '#9E9E9E' } },
'failed': { color: { background: '#EF9A9A', border: '#F44336' } },
'high-priority': { color: { background: '#D1C4E9', border: '#673AB7' } },
'medium-priority': { color: { background: '#BBDEFB', border: '#2196F3' } },
'low-priority': { color: { background: '#E1F5FE', border: '#03A9F4' } }
},
interaction: {
tooltipDelay: 200,
hover: true,
navigationButtons: true,
keyboard: true,
${formatOptions?.enableInteractivity === false ? 'dragNodes: false, zoomView: false,' : ''}
}
};
// Initialize network
const network = new vis.Network(container, data, options);
// Add event handlers
network.on("click", function(params) {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0];
const node = nodes.get(nodeId);
console.log('Selected node:', node);
}
});
// Optional: Export network as image
window.exportNetwork = function() {
const dataUrl = network.canvas.frame.canvas.toDataURL('image/png');
const link = document.createElement('a');
link.download = '${this.options.filename}.png';
link.href = dataUrl;
link.click();
};
</script>
</body>
</html>
`;
// Apply compression if needed
if (formatOptions?.compressOutput) {
html = this.minifyHtml(html);
}
return html;
}
/**
* Generate HTML for metrics display
*/
generateMetricsHtml(metrics) {
const formatTime = (ms) => {
if (ms < 1000)
return `${ms.toFixed(2)}ms`;
if (ms < 60000)
return `${(ms / 1000).toFixed(2)}s`;
return `${(ms / 60000).toFixed(2)}m`;
};
// Format flow execution times table
let flowTimesHtml = '';
if (metrics.flowExecutionTimes && Object.keys(metrics.flowExecutionTimes).length > 0) {
// Sort flows by execution time (descending)
const flowTimes = Object.entries(metrics.flowExecutionTimes)
.map(([id, time]) => ({ id, time: time }))
.sort((a, b) => b.time - a.time);
flowTimesHtml = `
<div class="metric-card">
<div class="metric-title">Flow Execution Times</div>
<table class="flow-table">
<thead>
<tr>
<th>Flow</th>
<th>Time</th>
</tr>
</thead>
<tbody>
${flowTimes.map(({ id, time }) => `
<tr>
<td>${id}</td>
<td>${formatTime(time)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
return `
<div class="metric-card">
<div class="metric-title">Total Execution Time</div>
<div class="metric-value">${formatTime(metrics.totalExecutionTime)}</div>
</div>
<div class="metric-card">
<div class="metric-title">Flow Status</div>
<table class="flow-table">
<tr>
<td><span class="flow-status status-completed"></span> Completed</td>
<td>${metrics.completedFlows || 0}</td>
</tr>
<tr>
<td><span class="flow-status status-failed"></span> Failed</td>
<td>${metrics.failedFlows || 0}</td>
</tr>
<tr>
<td>Total Flows</td>
<td>${metrics.flowCount || 0}</td>
</tr>
</table>
</div>
<div class="metric-card">
<div class="metric-title">Average Flow Time</div>
<div class="metric-value">${formatTime(metrics.averageFlowExecutionTime || 0)}</div>
<div class="metric-subtitle">Average execution time per flow</div>
</div>
${flowTimesHtml}
`;
}
/**
* Get layout options based on configuration
*/
getLayoutOptions() {
switch (this.options.layout) {
case 'hierarchical':
return {
hierarchical: {
direction: 'UD',
sortMethod: 'directed',
nodeSpacing: 150,
levelSeparation: 150
}
};
case 'force':
return {
// Force-directed layout
randomSeed: 2 // For consistent layouts
};
case 'circular':
return {
// Circular layout
improvedLayout: true,
randomSeed: 42
};
default:
return {
hierarchical: {
direction: 'UD',
sortMethod: 'directed',
nodeSpacing: 150,
levelSeparation: 150
}
};
}
}
/**
* Minify HTML for smaller output file size
*/
minifyHtml(html) {
// Basic HTML minification
return html
.replace(/\s+/g, ' ') // Replace multiple spaces with single space
.replace(/\s+</g, '<') // Remove spaces before opening tags
.replace(/>\s+/g, '>') // Remove spaces after closing tags
.replace(/<!--.*?-->/g, '') // Remove comments
.replace(/\s+\{/g, '{') // Remove spaces before opening braces
.replace(/\}\s+/g, '}') // Remove spaces after closing braces
.trim();
}
}