From 329567153bc7a1f1f2d6ab98c4be0582b1080021 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 3 Nov 2025 07:49:31 -0500 Subject: [PATCH] fix: make plot node selection smoother --- .pre-commit-config.yaml | 1 + lib/crewai/src/crewai/flow/__init__.py | 8 +- .../src/crewai/flow/visualization/__init__.py | 4 - .../flow/visualization/assets/interactive.js | 3149 +++++++++-------- .../assets/interactive_flow.html.j2 | 8 +- .../flow/visualization/assets/style.css | 2 + .../src/crewai/flow/visualization/builder.py | 197 +- lib/crewai/tests/test_flow_visualization.py | 83 +- pyproject.toml | 8 +- 9 files changed, 1725 insertions(+), 1735 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f3bc4094e..adea827bb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,6 +19,7 @@ repos: language: system pass_filenames: true types: [python] + exclude: ^(lib/crewai/src/crewai/cli/templates/|lib/crewai/tests/|lib/crewai-tools/tests/) - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.9.3 hooks: diff --git a/lib/crewai/src/crewai/flow/__init__.py b/lib/crewai/src/crewai/flow/__init__.py index bda0186c7..bdb28fabd 100644 --- a/lib/crewai/src/crewai/flow/__init__.py +++ b/lib/crewai/src/crewai/flow/__init__.py @@ -1,12 +1,10 @@ +from crewai.flow.flow import Flow, and_, listen, or_, router, start +from crewai.flow.persistence import persist from crewai.flow.visualization import ( FlowStructure, build_flow_structure, - print_structure_summary, - structure_to_dict, visualize_flow_structure, ) -from crewai.flow.flow import Flow, and_, listen, or_, router, start -from crewai.flow.persistence import persist __all__ = [ @@ -17,9 +15,7 @@ __all__ = [ "listen", "or_", "persist", - "print_structure_summary", "router", "start", - "structure_to_dict", "visualize_flow_structure", ] diff --git a/lib/crewai/src/crewai/flow/visualization/__init__.py b/lib/crewai/src/crewai/flow/visualization/__init__.py index 98665f642..4d77c764d 100644 --- a/lib/crewai/src/crewai/flow/visualization/__init__.py +++ b/lib/crewai/src/crewai/flow/visualization/__init__.py @@ -3,8 +3,6 @@ from crewai.flow.visualization.builder import ( build_flow_structure, calculate_execution_paths, - print_structure_summary, - structure_to_dict, ) from crewai.flow.visualization.renderers import render_interactive from crewai.flow.visualization.types import FlowStructure, NodeMetadata, StructureEdge @@ -18,8 +16,6 @@ __all__ = [ "StructureEdge", "build_flow_structure", "calculate_execution_paths", - "print_structure_summary", "render_interactive", - "structure_to_dict", "visualize_flow_structure", ] diff --git a/lib/crewai/src/crewai/flow/visualization/assets/interactive.js b/lib/crewai/src/crewai/flow/visualization/assets/interactive.js index c6998becd..8d4fe9bd9 100644 --- a/lib/crewai/src/crewai/flow/visualization/assets/interactive.js +++ b/lib/crewai/src/crewai/flow/visualization/assets/interactive.js @@ -1,1039 +1,1186 @@ +"use strict"; + +/** + * Flow Visualization Interactive Script + * Handles the interactive network visualization for CrewAI flows + */ + +// ============================================================================ +// Constants +// ============================================================================ + +const CONSTANTS = { + NODE: { + BASE_WIDTH: 200, + BASE_HEIGHT: 60, + BORDER_RADIUS: 20, + TEXT_SIZE: 13, + TEXT_PADDING: 8, + TEXT_BG_RADIUS: 6, + HOVER_SCALE: 1.04, + PRESSED_SCALE: 0.98, + }, + EDGE: { + DEFAULT_WIDTH: 2, + HIGHLIGHTED_WIDTH: 8, + ANIMATION_DURATION: 300, + DEFAULT_SHADOW_SIZE: 4, + HIGHLIGHTED_SHADOW_SIZE: 20, + }, + ANIMATION: { + DURATION: 300, + EASE_OUT_CUBIC: (t) => 1 - Math.pow(1 - t, 3), + }, + NETWORK: { + STABILIZATION_ITERATIONS: 200, + NODE_DISTANCE: 180, + SPRING_LENGTH: 150, + LEVEL_SEPARATION: 180, + NODE_SPACING: 220, + TREE_SPACING: 250, + }, + DRAWER: { + WIDTH: 400, + OFFSET_SCALE: 0.3, + }, +}; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Loads the vis-network library from CDN + */ function loadVisCDN() { return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = 'https://unpkg.com/vis-network@9.1.2/dist/vis-network.min.js'; + const script = document.createElement("script"); + script.src = "https://unpkg.com/vis-network@9.1.2/dist/vis-network.min.js"; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } -(async () => { +/** + * Draws a rounded rectangle on a canvas context + */ +function drawRoundedRect(ctx, x, y, width, height, radius) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); +} - try { - await loadVisCDN(); - const nodes = new vis.DataSet('{{ nodes_list_json }}'); - const edges = new vis.DataSet('{{ edges_list_json }}'); +/** + * Highlights Python code using Prism + */ +function highlightPython(code) { + return Prism.highlight(code, Prism.languages.python, "python"); +} - const container = document.getElementById('network'); - const data = { - nodes: nodes, - edges: edges - }; +// ============================================================================ +// Node Renderer +// ============================================================================ - const options = { - nodes: { - shape: 'custom', - shadow: false, - chosen: false, - size: 30, - ctxRenderer: function ({ctx, id, x, y, state: {selected, hover}, style, label}) { - const node = nodes.get(id); - if (!node || !node.nodeStyle) return {}; +class NodeRenderer { + constructor(nodes, networkManager) { + this.nodes = nodes; + this.networkManager = networkManager; + } - const nodeStyle = node.nodeStyle; - const baseWidth = 200; - const baseHeight = 60; + render({ ctx, id, x, y, state, style, label }) { + const node = this.nodes.get(id); + if (!node || !node.nodeStyle) return {}; - let scale = 1.0; - if (pressedNodeId === id) { - scale = 0.98; - } else if (hoveredNodeId === id) { - scale = 1.04; - } + const scale = this.getNodeScale(id); + const isActiveDrawer = + this.networkManager.drawerManager?.activeNodeId === id; + const nodeStyle = node.nodeStyle; + const width = CONSTANTS.NODE.BASE_WIDTH * scale; + const height = CONSTANTS.NODE.BASE_HEIGHT * scale; - const isActiveDrawer = activeDrawerNodeId === id; + return { + drawNode: () => { + ctx.save(); + this.applyNodeOpacity(ctx, node); + this.applyShadow(ctx, node, isActiveDrawer); + this.drawNodeShape( + ctx, + x, + y, + width, + height, + scale, + nodeStyle, + isActiveDrawer, + ); + this.drawNodeText(ctx, x, y, scale, nodeStyle); + ctx.restore(); + }, + nodeDimensions: { width, height }, + }; + } - const width = baseWidth * scale; - const height = baseHeight * scale; + getNodeScale(id) { + if (this.networkManager.pressedNodeId === id) { + return CONSTANTS.NODE.PRESSED_SCALE; + } else if (this.networkManager.hoveredNodeId === id) { + return CONSTANTS.NODE.HOVER_SCALE; + } + return 1.0; + } - return { - drawNode() { - ctx.save(); + applyNodeOpacity(ctx, node) { + const nodeOpacity = node.opacity !== undefined ? node.opacity : 1.0; + ctx.globalAlpha = nodeOpacity; + } - const nodeOpacity = node.opacity !== undefined ? node.opacity : 1.0; - ctx.globalAlpha = nodeOpacity; + applyShadow(ctx, node, isActiveDrawer) { + if (node.shadow && node.shadow.enabled) { + ctx.shadowColor = node.shadow.color || "rgba(0,0,0,0.1)"; + ctx.shadowBlur = node.shadow.size || 8; + ctx.shadowOffsetX = node.shadow.x || 0; + ctx.shadowOffsetY = node.shadow.y || 0; + } else if (isActiveDrawer) { + ctx.shadowColor = "{{ CREWAI_ORANGE }}"; + ctx.shadowBlur = 20; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + } + } - if (node.shadow && node.shadow.enabled) { - ctx.shadowColor = node.shadow.color || '{{ CREWAI_ORANGE }}'; - ctx.shadowBlur = node.shadow.size || 20; - ctx.shadowOffsetX = node.shadow.x || 0; - ctx.shadowOffsetY = node.shadow.y || 0; - } else if (isActiveDrawer) { - ctx.shadowColor = '{{ CREWAI_ORANGE }}'; - ctx.shadowBlur = 20; - ctx.shadowOffsetX = 0; - ctx.shadowOffsetY = 0; - } else { - ctx.shadowColor = 'rgba(0,0,0,0.1)'; - ctx.shadowBlur = 8; - ctx.shadowOffsetX = 2; - ctx.shadowOffsetY = 2; - } + drawNodeShape(ctx, x, y, width, height, scale, nodeStyle, isActiveDrawer) { + const radius = CONSTANTS.NODE.BORDER_RADIUS * scale; + const rectX = x - width / 2; + const rectY = y - height / 2; - const radius = 20 * scale; - const rectX = x - width / 2; - const rectY = y - height / 2; + drawRoundedRect(ctx, rectX, rectY, width, height, radius); - ctx.beginPath(); - ctx.moveTo(rectX + radius, rectY); - ctx.lineTo(rectX + width - radius, rectY); - ctx.quadraticCurveTo(rectX + width, rectY, rectX + width, rectY + radius); - ctx.lineTo(rectX + width, rectY + height - radius); - ctx.quadraticCurveTo(rectX + width, rectY + height, rectX + width - radius, rectY + height); - ctx.lineTo(rectX + radius, rectY + height); - ctx.quadraticCurveTo(rectX, rectY + height, rectX, rectY + height - radius); - ctx.lineTo(rectX, rectY + radius); - ctx.quadraticCurveTo(rectX, rectY, rectX + radius, rectY); - ctx.closePath(); + ctx.fillStyle = nodeStyle.bgColor; + ctx.fill(); - ctx.fillStyle = nodeStyle.bgColor; - ctx.fill(); + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; - ctx.shadowColor = 'transparent'; - ctx.shadowBlur = 0; + ctx.strokeStyle = isActiveDrawer + ? "{{ CREWAI_ORANGE }}" + : nodeStyle.borderColor; + ctx.lineWidth = nodeStyle.borderWidth * scale; + ctx.stroke(); + } - const borderWidth = isActiveDrawer ? nodeStyle.borderWidth * 2 : nodeStyle.borderWidth; - ctx.strokeStyle = isActiveDrawer ? '{{ CREWAI_ORANGE }}' : nodeStyle.borderColor; - ctx.lineWidth = borderWidth * scale; - ctx.stroke(); + drawNodeText(ctx, x, y, scale, nodeStyle) { + ctx.font = `500 ${CONSTANTS.NODE.TEXT_SIZE * scale}px 'JetBrains Mono', 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; - ctx.font = `500 ${13 * scale}px 'JetBrains Mono', 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; + const textMetrics = ctx.measureText(nodeStyle.name); + const textWidth = textMetrics.width; + const textHeight = CONSTANTS.NODE.TEXT_SIZE * scale; + const textPadding = CONSTANTS.NODE.TEXT_PADDING * scale; + const textBgRadius = CONSTANTS.NODE.TEXT_BG_RADIUS * scale; - const textMetrics = ctx.measureText(nodeStyle.name); - const textWidth = textMetrics.width; - const textHeight = 13 * scale; - const textPadding = 8 * scale; - const textBgRadius = 6 * scale; + const textBgX = x - textWidth / 2 - textPadding; + const textBgY = y - textHeight / 2 - textPadding / 2; + const textBgWidth = textWidth + textPadding * 2; + const textBgHeight = textHeight + textPadding; - const textBgX = x - textWidth / 2 - textPadding; - const textBgY = y - textHeight / 2 - textPadding / 2; - const textBgWidth = textWidth + textPadding * 2; - const textBgHeight = textHeight + textPadding; + drawRoundedRect( + ctx, + textBgX, + textBgY, + textBgWidth, + textBgHeight, + textBgRadius, + ); - ctx.beginPath(); - ctx.moveTo(textBgX + textBgRadius, textBgY); - ctx.lineTo(textBgX + textBgWidth - textBgRadius, textBgY); - ctx.quadraticCurveTo(textBgX + textBgWidth, textBgY, textBgX + textBgWidth, textBgY + textBgRadius); - ctx.lineTo(textBgX + textBgWidth, textBgY + textBgHeight - textBgRadius); - ctx.quadraticCurveTo(textBgX + textBgWidth, textBgY + textBgHeight, textBgX + textBgWidth - textBgRadius, textBgY + textBgHeight); - ctx.lineTo(textBgX + textBgRadius, textBgY + textBgHeight); - ctx.quadraticCurveTo(textBgX, textBgY + textBgHeight, textBgX, textBgY + textBgHeight - textBgRadius); - ctx.lineTo(textBgX, textBgY + textBgRadius); - ctx.quadraticCurveTo(textBgX, textBgY, textBgX + textBgRadius, textBgY); - ctx.closePath(); + ctx.fillStyle = "rgba(255, 255, 255, 0.2)"; + ctx.fill(); - ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; - ctx.fill(); + ctx.fillStyle = nodeStyle.fontColor; + ctx.fillText(nodeStyle.name, x, y); + } +} - ctx.fillStyle = nodeStyle.fontColor; - ctx.fillText(nodeStyle.name, x, y); +// ============================================================================ +// Animation Manager +// ============================================================================ - ctx.restore(); - }, - nodeDimensions: {width, height} - }; - }, - scaling: { - min: 1, - max: 100 - } - }, - edges: { - width: 2, - hoverWidth: 0, - labelHighlightBold: false, - shadow: { - enabled: true, - color: 'rgba(0,0,0,0.08)', - size: 4, - x: 1, - y: 1 - }, - smooth: { - type: 'cubicBezier', - roundness: 0.5 - }, - font: { - size: 13, - align: 'middle', - color: 'transparent', - face: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', - strokeWidth: 0, - background: 'transparent', - vadjust: 0, - bold: { - face: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', - mod: 'bold', - vadjust: 0 - } - }, - arrows: { - to: { - enabled: true, - scaleFactor: 0.8, - type: 'triangle' - } - }, - arrowStrikethrough: true, - chosen: { - edge: false, - label: false - } - }, - physics: { - enabled: true, - hierarchicalRepulsion: { - nodeDistance: 180, - centralGravity: 0.0, - springLength: 150, - springConstant: 0.01, - damping: 0.09 - }, - solver: 'hierarchicalRepulsion', - stabilization: { - enabled: true, - iterations: 200, - updateInterval: 25 - } - }, - layout: { - hierarchical: { - enabled: true, - direction: 'UD', - sortMethod: 'directed', - levelSeparation: 180, - nodeSpacing: 220, - treeSpacing: 250 - } - }, - interaction: { - hover: true, - hoverConnectedEdges: false, - navigationButtons: false, - keyboard: true, - selectConnectedEdges: false, - multiselect: false - } - }; +class AnimationManager { + constructor() { + this.animations = new Map(); + } - const network = new vis.Network(container, data, options); + animateEdgeWidth( + edges, + edgeId, + targetWidth, + duration = CONSTANTS.EDGE.ANIMATION_DURATION, + ) { + this.cancel(edgeId); - const ideSelector = document.getElementById('ide-selector'); - const savedIDE = localStorage.getItem('preferred_ide') || 'auto'; - ideSelector.value = savedIDE; - ideSelector.addEventListener('change', function () { - localStorage.setItem('preferred_ide', this.value); + const edge = edges.get(edgeId); + if (!edge) return; + + const startWidth = edge.width || CONSTANTS.EDGE.DEFAULT_WIDTH; + const startTime = performance.now(); + + const animate = () => { + const elapsed = performance.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress); + const currentWidth = startWidth + (targetWidth - startWidth) * eased; + + edges.update({ id: edgeId, width: currentWidth }); + + if (progress < 1) { + const frameId = requestAnimationFrame(animate); + this.animations.set(edgeId, frameId); + } else { + this.animations.delete(edgeId); + } + }; + + animate(); + } + + cancel(id) { + if (this.animations.has(id)) { + cancelAnimationFrame(this.animations.get(id)); + this.animations.delete(id); + } + } + + cancelAll() { + this.animations.forEach((frameId) => cancelAnimationFrame(frameId)); + this.animations.clear(); + } +} + +// ============================================================================ +// Triggered By Highlighter +// ============================================================================ + +class TriggeredByHighlighter { + constructor(network, nodes, edges, highlightCanvas) { + this.network = network; + this.nodes = nodes; + this.edges = edges; + this.canvas = highlightCanvas; + this.ctx = highlightCanvas.getContext("2d"); + + this.highlightedNodes = []; + this.highlightedEdges = []; + this.activeDrawerNodeId = null; + this.activeDrawerEdges = []; + + this.setupCanvas(); + } + + setupCanvas() { + this.resizeCanvas(); + this.canvas.classList.remove("visible"); + window.addEventListener("resize", () => this.resizeCanvas()); + } + + resizeCanvas() { + this.canvas.width = window.innerWidth; + this.canvas.height = window.innerHeight; + } + + setActiveDrawer(nodeId, edges) { + this.activeDrawerNodeId = nodeId; + this.activeDrawerEdges = edges; + } + + highlightTriggeredByGroup(triggerNodeIds) { + this.clear(); + + if (!this.activeDrawerNodeId || !triggerNodeIds || triggerNodeIds.length === 0) { + console.warn("TriggeredByHighlighter: Missing activeDrawerNodeId or triggerNodeIds"); + return; + } + + const allEdges = this.edges.get(); + const pathNodes = new Set([this.activeDrawerNodeId]); + const connectingEdges = []; + const nodeData = '{{ nodeData }}'; + + triggerNodeIds.forEach(triggerNodeId => { + const directEdges = allEdges.filter( + (edge) => edge.from === triggerNodeId && edge.to === this.activeDrawerNodeId + ); + + if (directEdges.length > 0) { + directEdges.forEach(edge => { + connectingEdges.push(edge); + pathNodes.add(edge.from); + pathNodes.add(edge.to); }); + } else { + for (const [nodeName, nodeInfo] of Object.entries(nodeData)) { + if (nodeInfo.router_paths && nodeInfo.router_paths.includes(triggerNodeId)) { + const routerNode = nodeName; - const highlightCanvas = document.getElementById('highlight-canvas'); - const highlightCtx = highlightCanvas.getContext('2d'); - - function resizeHighlightCanvas() { - highlightCanvas.width = window.innerWidth; - highlightCanvas.height = window.innerHeight; - } - - resizeHighlightCanvas(); - window.addEventListener('resize', resizeHighlightCanvas); - - let highlightedNodes = []; - let highlightedEdges = []; - let nodeRestoreAnimationId = null; - let edgeRestoreAnimationId = null; - - function drawHighlightLayer() { - highlightCtx.clearRect(0, 0, highlightCanvas.width, highlightCanvas.height); - - if (highlightedNodes.length === 0) return; - - highlightedNodes.forEach(function (nodeId) { - const nodePosition = network.getPositions([nodeId])[nodeId]; - if (!nodePosition) return; - - const canvasPos = network.canvasToDOM(nodePosition); - const node = nodes.get(nodeId); - if (!node || !node.nodeStyle) return; - - const nodeStyle = node.nodeStyle; - const baseWidth = 200; - const baseHeight = 60; - const scale = 1.0; - const width = baseWidth * scale; - const height = baseHeight * scale; - - highlightCtx.save(); - - highlightCtx.shadowColor = '{{ CREWAI_ORANGE }}'; - highlightCtx.shadowBlur = 20; - highlightCtx.shadowOffsetX = 0; - highlightCtx.shadowOffsetY = 0; - - const radius = 20 * scale; - const rectX = canvasPos.x - width / 2; - const rectY = canvasPos.y - height / 2; - - highlightCtx.beginPath(); - highlightCtx.moveTo(rectX + radius, rectY); - highlightCtx.lineTo(rectX + width - radius, rectY); - highlightCtx.quadraticCurveTo(rectX + width, rectY, rectX + width, rectY + radius); - highlightCtx.lineTo(rectX + width, rectY + height - radius); - highlightCtx.quadraticCurveTo(rectX + width, rectY + height, rectX + width - radius, rectY + height); - highlightCtx.lineTo(rectX + radius, rectY + height); - highlightCtx.quadraticCurveTo(rectX, rectY + height, rectX, rectY + height - radius); - highlightCtx.lineTo(rectX, rectY + radius); - highlightCtx.quadraticCurveTo(rectX, rectY, rectX + radius, rectY); - highlightCtx.closePath(); - - highlightCtx.fillStyle = nodeStyle.bgColor; - highlightCtx.fill(); - - highlightCtx.shadowColor = 'transparent'; - highlightCtx.shadowBlur = 0; - - highlightCtx.strokeStyle = '{{ CREWAI_ORANGE }}'; - highlightCtx.lineWidth = nodeStyle.borderWidth * 2 * scale; - highlightCtx.stroke(); - - highlightCtx.fillStyle = nodeStyle.fontColor; - highlightCtx.font = `500 ${15 * scale}px Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`; - highlightCtx.textAlign = 'center'; - highlightCtx.textBaseline = 'middle'; - highlightCtx.fillText(nodeStyle.name, canvasPos.x, canvasPos.y); - - highlightCtx.restore(); - }); - } - - function highlightTriggeredBy(triggerNodeId) { - clearTriggeredByHighlight(); - - if (activeDrawerEdges && activeDrawerEdges.length > 0) { - activeDrawerEdges.forEach(function (edgeId) { - edges.update({ - id: edgeId, - width: 2, - opacity: 1.0 - }); - }); - activeDrawerEdges = []; - } - - if (!activeDrawerNodeId || !triggerNodeId) return; - - const allEdges = edges.get(); - let connectingEdges = []; - let actualTriggerNodeId = triggerNodeId; - - connectingEdges = allEdges.filter(edge => - edge.from === triggerNodeId && edge.to === activeDrawerNodeId + const routerEdges = allEdges.filter( + (edge) => edge.from === routerNode && edge.dashes ); - if (connectingEdges.length === 0) { - const incomingRouterEdges = allEdges.filter(edge => - edge.to === activeDrawerNodeId && edge.dashes - ); + for (const routerEdge of routerEdges) { + if (routerEdge.to === this.activeDrawerNodeId) { + connectingEdges.push(routerEdge); + pathNodes.add(routerNode); + pathNodes.add(this.activeDrawerNodeId); + break; + } - if (incomingRouterEdges.length > 0) { - incomingRouterEdges.forEach(function (edge) { - connectingEdges.push(edge); - actualTriggerNodeId = edge.from; - }); - } else { - const outgoingRouterEdges = allEdges.filter(edge => - edge.from === activeDrawerNodeId && edge.dashes - ); + const intermediateNode = routerEdge.to; + const pathToActive = allEdges.filter( + (edge) => edge.from === intermediateNode && edge.to === this.activeDrawerNodeId + ); - const nodeData = '{{ nodeData }}'; - for (const [nodeName, nodeInfo] of Object.entries(nodeData)) { - if (nodeInfo.trigger_methods && nodeInfo.trigger_methods.includes(triggerNodeId)) { - const edgeToTarget = outgoingRouterEdges.find(e => e.to === nodeName); - if (edgeToTarget) { - connectingEdges.push(edgeToTarget); - actualTriggerNodeId = nodeName; - break; - } - } - } - } + if (pathToActive.length > 0) { + connectingEdges.push(routerEdge); + connectingEdges.push(...pathToActive); + pathNodes.add(routerNode); + pathNodes.add(intermediateNode); + pathNodes.add(this.activeDrawerNodeId); + break; + } } - if (connectingEdges.length === 0) return; + if (connectingEdges.length > 0) break; + } + } + } + }); - highlightedNodes = [actualTriggerNodeId, activeDrawerNodeId]; - highlightedEdges = connectingEdges.map(e => e.id); + if (connectingEdges.length === 0) { + console.warn("TriggeredByHighlighter: No connecting edges found for group", { triggerNodeIds }); + return; + } - const allNodesList = nodes.get(); - const nodeAnimDuration = 300; - const nodeAnimStart = performance.now(); + this.highlightedNodes = Array.from(pathNodes); + this.highlightedEdges = connectingEdges.map((e) => e.id); - function animateNodeOpacity() { - const elapsed = performance.now() - nodeAnimStart; - const progress = Math.min(elapsed / nodeAnimDuration, 1); - const eased = 1 - Math.pow(1 - progress, 3); + this.animateNodeOpacity(); + this.animateEdgeStyles(); + } - allNodesList.forEach(function (node) { - const currentOpacity = node.opacity !== undefined ? node.opacity : 1.0; - const targetOpacity = highlightedNodes.includes(node.id) ? 1.0 : 0.2; - const newOpacity = currentOpacity + (targetOpacity - currentOpacity) * eased; + highlightAllRouterPaths() { + this.clear(); - nodes.update({ - id: node.id, - opacity: newOpacity - }); - }); + if (!this.activeDrawerNodeId) { + console.warn("TriggeredByHighlighter: Missing activeDrawerNodeId"); + return; + } - if (progress < 1) { - requestAnimationFrame(animateNodeOpacity); - } + const allEdges = this.edges.get(); + const nodeData = '{{ nodeData }}'; + const activeMetadata = nodeData[this.activeDrawerNodeId]; + + const outgoingRouterEdges = allEdges.filter( + (edge) => edge.from === this.activeDrawerNodeId && edge.dashes + ); + + let routerEdges = []; + const pathNodes = new Set(); + + if (outgoingRouterEdges.length > 0) { + routerEdges = outgoingRouterEdges; + pathNodes.add(this.activeDrawerNodeId); + routerEdges.forEach(edge => { + pathNodes.add(edge.to); + }); + } else if (activeMetadata && activeMetadata.router_paths && activeMetadata.router_paths.length > 0) { + activeMetadata.router_paths.forEach(pathName => { + for (const [nodeName, nodeInfo] of Object.entries(nodeData)) { + if (nodeInfo.router_paths && nodeInfo.router_paths.includes(pathName)) { + const edgeFromRouter = allEdges.filter( + (edge) => edge.from === nodeName && edge.to === this.activeDrawerNodeId && edge.dashes + ); + + if (edgeFromRouter.length > 0) { + routerEdges.push(...edgeFromRouter); + pathNodes.add(nodeName); + pathNodes.add(this.activeDrawerNodeId); + } + } + } + }); + } + + if (routerEdges.length === 0) { + console.warn("TriggeredByHighlighter: No router paths found for node", { + activeDrawerNodeId: this.activeDrawerNodeId, + outgoingEdges: outgoingRouterEdges.length, + hasRouterPathsMetadata: !!activeMetadata?.router_paths, + }); + return; + } + + this.highlightedNodes = Array.from(pathNodes); + this.highlightedEdges = routerEdges.map((e) => e.id); + + this.animateNodeOpacity(); + this.animateEdgeStyles(); + } + + highlightTriggeredBy(triggerNodeId) { + this.clear(); + + if (this.activeDrawerEdges && this.activeDrawerEdges.length > 0) { + this.activeDrawerEdges.forEach((edgeId) => { + this.edges.update({ + id: edgeId, + width: CONSTANTS.EDGE.DEFAULT_WIDTH, + opacity: 1.0, + }); + }); + this.activeDrawerEdges = []; + } + + if (!this.activeDrawerNodeId || !triggerNodeId) { + console.warn( + "TriggeredByHighlighter: Missing activeDrawerNodeId or triggerNodeId", + { + activeDrawerNodeId: this.activeDrawerNodeId, + triggerNodeId: triggerNodeId, + }, + ); + return; + } + + const allEdges = this.edges.get(); + let connectingEdges = []; + let actualTriggerNodeId = triggerNodeId; + + connectingEdges = allEdges.filter( + (edge) => + edge.from === triggerNodeId && edge.to === this.activeDrawerNodeId, + ); + + if (connectingEdges.length === 0) { + const incomingRouterEdges = allEdges.filter( + (edge) => edge.to === this.activeDrawerNodeId && edge.dashes, + ); + + if (incomingRouterEdges.length > 0) { + incomingRouterEdges.forEach((edge) => { + connectingEdges.push(edge); + actualTriggerNodeId = edge.from; + }); + } + } + + if (connectingEdges.length === 0) { + const outgoingRouterEdges = allEdges.filter( + (edge) => edge.from === this.activeDrawerNodeId && edge.dashes, + ); + + if (outgoingRouterEdges.length > 0) { + const nodeData = '{{ nodeData }}'; + for (const [nodeName, nodeInfo] of Object.entries(nodeData)) { + if ( + nodeInfo.trigger_methods && + nodeInfo.trigger_methods.includes(triggerNodeId) + ) { + const edgeToTarget = outgoingRouterEdges.find( + (e) => e.to === nodeName, + ); + if (edgeToTarget) { + connectingEdges.push(edgeToTarget); + actualTriggerNodeId = nodeName; + break; + } + } + } + } + } + + if (connectingEdges.length === 0) { + const nodeData = '{{ nodeData }}'; + + const activeMetadata = nodeData[this.activeDrawerNodeId]; + if ( + activeMetadata && + activeMetadata.trigger_methods && + activeMetadata.trigger_methods.includes(triggerNodeId) + ) { + for (const [nodeName, nodeInfo] of Object.entries(nodeData)) { + if ( + nodeInfo.router_paths && + nodeInfo.router_paths.includes(triggerNodeId) + ) { + const routerNode = nodeName; + + const routerEdges = allEdges.filter( + (edge) => edge.from === routerNode && edge.dashes, + ); + + for (const routerEdge of routerEdges) { + const intermediateNode = routerEdge.to; + + if (intermediateNode === this.activeDrawerNodeId) { + connectingEdges = [routerEdge]; + actualTriggerNodeId = routerNode; + break; + } + + const pathToActive = allEdges.filter( + (edge) => + edge.from === intermediateNode && + edge.to === this.activeDrawerNodeId, + ); + + if (pathToActive.length > 0) { + connectingEdges = [routerEdge, ...pathToActive]; + actualTriggerNodeId = routerNode; + break; + } } - animateNodeOpacity(); + if (connectingEdges.length > 0) break; + } + } + } + } - const allEdgesList = edges.get(); - const edgeAnimDuration = 300; - const edgeAnimStart = performance.now(); + if (connectingEdges.length === 0) { + const edgesWithLabel = allEdges.filter( + (edge) => + edge.dashes && + edge.label === triggerNodeId && + edge.to === this.activeDrawerNodeId, + ); - function animateEdgeStyles() { - const elapsed = performance.now() - edgeAnimStart; - const progress = Math.min(elapsed / edgeAnimDuration, 1); - const eased = 1 - Math.pow(1 - progress, 3); + if (edgesWithLabel.length > 0) { + connectingEdges = edgesWithLabel; + const firstEdge = edgesWithLabel[0]; + actualTriggerNodeId = firstEdge.from; + } + } - allEdgesList.forEach(function (edge) { - if (highlightedEdges.includes(edge.id)) { - const currentWidth = edge.width || 2; - const targetWidth = 8; - const newWidth = currentWidth + (targetWidth - currentWidth) * eased; + if (connectingEdges.length === 0) { + console.warn("TriggeredByHighlighter: No connecting edges found", { + triggerNodeId, + activeDrawerNodeId: this.activeDrawerNodeId, + allEdges: allEdges.length, + edgeDetails: allEdges.map((e) => ({ + from: e.from, + to: e.to, + label: e.label, + dashes: e.dashes, + })), + }); + return; + } - const currentShadowSize = edge.shadow?.size || 4; - const targetShadowSize = 20; - const newShadowSize = currentShadowSize + (targetShadowSize - currentShadowSize) * eased; + const pathNodes = new Set([actualTriggerNodeId, this.activeDrawerNodeId]); + connectingEdges.forEach((edge) => { + pathNodes.add(edge.from); + pathNodes.add(edge.to); + }); - const updateData = { - id: edge.id, - hidden: false, - opacity: 1.0, - width: newWidth, - color: { - color: '{{ CREWAI_ORANGE }}', - highlight: '{{ CREWAI_ORANGE }}' - }, - shadow: { - enabled: true, - color: '{{ CREWAI_ORANGE }}', - size: newShadowSize, - x: 0, - y: 0 - } - }; + this.highlightedNodes = Array.from(pathNodes); + this.highlightedEdges = connectingEdges.map((e) => e.id); - if (edge.dashes) { - const scale = Math.sqrt(newWidth / 2); - updateData.dashes = [15 * scale, 10 * scale]; - } + this.animateNodeOpacity(); + this.animateEdgeStyles(); + } - updateData.arrows = { - to: { - enabled: true, - scaleFactor: 0.8, - type: 'triangle' - } - }; + animateNodeOpacity() { + const allNodesList = this.nodes.get(); + const nodeAnimDuration = CONSTANTS.ANIMATION.DURATION; + const nodeAnimStart = performance.now(); - updateData.color = { - color: '{{ CREWAI_ORANGE }}', - highlight: '{{ CREWAI_ORANGE }}', - hover: '{{ CREWAI_ORANGE }}', - inherit: 'to' - }; + const animate = () => { + const elapsed = performance.now() - nodeAnimStart; + const progress = Math.min(elapsed / nodeAnimDuration, 1); + const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress); - edges.update(updateData); - } else { - edges.update({ - id: edge.id, - hidden: false, - opacity: 1.0, - width: 1, - color: { - color: 'transparent', - highlight: 'transparent' - }, - shadow: { - enabled: false - }, - font: { - color: 'transparent', - background: 'transparent' - } - }); - } - }); + allNodesList.forEach((node) => { + const currentOpacity = node.opacity !== undefined ? node.opacity : 1.0; + const targetOpacity = this.highlightedNodes.includes(node.id) + ? 1.0 + : 0.2; + const newOpacity = + currentOpacity + (targetOpacity - currentOpacity) * eased; - if (progress < 1) { - requestAnimationFrame(animateEdgeStyles); - } - } + this.nodes.update({ + id: node.id, + opacity: newOpacity, + }); + }); - animateEdgeStyles(); + if (progress < 1) { + requestAnimationFrame(animate); + } + }; - highlightCanvas.classList.add('visible'); + animate(); + } - setTimeout(function () { - drawHighlightLayer(); - }, 50); + animateEdgeStyles() { + const edgeIds = this.edges.getIds(); + const edgeAnimDuration = CONSTANTS.ANIMATION.DURATION; + const edgeAnimStart = performance.now(); + + const animate = () => { + const elapsed = performance.now() - edgeAnimStart; + const progress = Math.min(elapsed / edgeAnimDuration, 1); + const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress); + + edgeIds.forEach((edgeId) => { + const edge = this.edges.get(edgeId); + if (!edge) return; + + if (this.highlightedEdges.includes(edge.id)) { + const currentWidth = edge.width || CONSTANTS.EDGE.DEFAULT_WIDTH; + const targetWidth = CONSTANTS.EDGE.HIGHLIGHTED_WIDTH; + const newWidth = currentWidth + (targetWidth - currentWidth) * eased; + + const currentShadowSize = + edge.shadow?.size || CONSTANTS.EDGE.DEFAULT_SHADOW_SIZE; + const targetShadowSize = CONSTANTS.EDGE.HIGHLIGHTED_SHADOW_SIZE; + const newShadowSize = + currentShadowSize + (targetShadowSize - currentShadowSize) * eased; + + const updateData = { + id: edge.id, + hidden: false, + opacity: 1.0, + width: newWidth, + color: { + color: "{{ CREWAI_ORANGE }}", + highlight: "{{ CREWAI_ORANGE }}", + }, + shadow: { + enabled: true, + color: "{{ CREWAI_ORANGE }}", + size: newShadowSize, + x: 0, + y: 0, + }, + }; + + if (edge.dashes) { + const scale = Math.sqrt(newWidth / CONSTANTS.EDGE.DEFAULT_WIDTH); + updateData.dashes = [15 * scale, 10 * scale]; + } + + updateData.arrows = { + to: { + enabled: true, + scaleFactor: 0.8, + type: "triangle", + }, + }; + + updateData.color = { + color: "{{ CREWAI_ORANGE }}", + highlight: "{{ CREWAI_ORANGE }}", + hover: "{{ CREWAI_ORANGE }}", + inherit: "to", + }; + + this.edges.update(updateData); + } else { + const currentOpacity = edge.opacity !== undefined ? edge.opacity : 1.0; + const targetOpacity = 0.25; + const newOpacity = currentOpacity + (targetOpacity - currentOpacity) * eased; + + const currentWidth = edge.width !== undefined ? edge.width : CONSTANTS.EDGE.DEFAULT_WIDTH; + const targetWidth = 1; + const newWidth = currentWidth + (targetWidth - currentWidth) * eased; + + this.edges.update({ + id: edge.id, + hidden: false, + opacity: newOpacity, + width: newWidth, + color: { + color: "rgba(128, 128, 128, 0.3)", + highlight: "rgba(128, 128, 128, 0.3)", + }, + shadow: { + enabled: false, + }, + }); + } + }); + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + animate(); + } + + drawHighlightLayer() { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + if (this.highlightedNodes.length === 0) return; + + this.highlightedNodes.forEach((nodeId) => { + const nodePosition = this.network.getPositions([nodeId])[nodeId]; + if (!nodePosition) return; + + const canvasPos = this.network.canvasToDOM(nodePosition); + const node = this.nodes.get(nodeId); + if (!node || !node.nodeStyle) return; + + const nodeStyle = node.nodeStyle; + const scale = 1.0; + const width = CONSTANTS.NODE.BASE_WIDTH * scale; + const height = CONSTANTS.NODE.BASE_HEIGHT * scale; + + this.ctx.save(); + + this.ctx.shadowColor = "transparent"; + this.ctx.shadowBlur = 0; + this.ctx.shadowOffsetX = 0; + this.ctx.shadowOffsetY = 0; + + const radius = CONSTANTS.NODE.BORDER_RADIUS * scale; + const rectX = canvasPos.x - width / 2; + const rectY = canvasPos.y - height / 2; + + drawRoundedRect(this.ctx, rectX, rectY, width, height, radius); + + this.ctx.fillStyle = nodeStyle.bgColor; + this.ctx.fill(); + + this.ctx.shadowColor = "transparent"; + this.ctx.shadowBlur = 0; + + this.ctx.strokeStyle = "{{ CREWAI_ORANGE }}"; + this.ctx.lineWidth = nodeStyle.borderWidth * scale; + this.ctx.stroke(); + + this.ctx.fillStyle = nodeStyle.fontColor; + this.ctx.font = `500 ${15 * scale}px Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`; + this.ctx.textAlign = "center"; + this.ctx.textBaseline = "middle"; + this.ctx.fillText(nodeStyle.name, canvasPos.x, canvasPos.y); + + this.ctx.restore(); + }); + } + + clear() { + const allNodesList = this.nodes.get(); + const nodeRestoreAnimStart = performance.now(); + const nodeRestoreAnimDuration = CONSTANTS.ANIMATION.DURATION; + + const animate = () => { + const elapsed = performance.now() - nodeRestoreAnimStart; + const progress = Math.min(elapsed / nodeRestoreAnimDuration, 1); + const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress); + + allNodesList.forEach((node) => { + const currentOpacity = node.opacity !== undefined ? node.opacity : 1.0; + const targetOpacity = 1.0; + const newOpacity = + currentOpacity + (targetOpacity - currentOpacity) * eased; + this.nodes.update({ id: node.id, opacity: newOpacity }); + }); + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + animate(); + + const edgeIds = this.edges.getIds(); + const edgeRestoreAnimStart = performance.now(); + const edgeRestoreAnimDuration = CONSTANTS.ANIMATION.DURATION; + + const animateEdges = () => { + const elapsed = performance.now() - edgeRestoreAnimStart; + const progress = Math.min(elapsed / edgeRestoreAnimDuration, 1); + const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress); + + edgeIds.forEach((edgeId) => { + if (this.activeDrawerEdges.includes(edgeId)) { + return; } - function clearTriggeredByHighlight() { - const allNodesList = nodes.get(); - const nodeRestoreAnimStart = performance.now(); - const nodeRestoreAnimDuration = 300; + const edge = this.edges.get(edgeId); + if (!edge) return; - function animateNodeRestore() { - if (isAnimating) { - nodeRestoreAnimationId = null; - return; - } + const defaultColor = + edge.dashes || edge.label === "AND" + ? "{{ CREWAI_ORANGE }}" + : getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim(); + const currentOpacity = edge.opacity !== undefined ? edge.opacity : 1.0; + const currentWidth = + edge.width !== undefined ? edge.width : CONSTANTS.EDGE.DEFAULT_WIDTH; + const currentShadowSize = + edge.shadow && edge.shadow.size !== undefined + ? edge.shadow.size + : CONSTANTS.EDGE.DEFAULT_SHADOW_SIZE; - const elapsed = performance.now() - nodeRestoreAnimStart; - const progress = Math.min(elapsed / nodeRestoreAnimDuration, 1); - const eased = 1 - Math.pow(1 - progress, 3); + const targetOpacity = 1.0; + const targetWidth = CONSTANTS.EDGE.DEFAULT_WIDTH; + const targetShadowSize = CONSTANTS.EDGE.DEFAULT_SHADOW_SIZE; - allNodesList.forEach(function (node) { - const currentOpacity = node.opacity !== undefined ? node.opacity : 1.0; - const targetOpacity = 1.0; - const newOpacity = currentOpacity + (targetOpacity - currentOpacity) * eased; - nodes.update({id: node.id, opacity: newOpacity}); - }); + const newOpacity = + currentOpacity + (targetOpacity - currentOpacity) * eased; + const newWidth = currentWidth + (targetWidth - currentWidth) * eased; + const newShadowSize = + currentShadowSize + (targetShadowSize - currentShadowSize) * eased; - if (progress < 1) { - nodeRestoreAnimationId = requestAnimationFrame(animateNodeRestore); - } else { - nodeRestoreAnimationId = null; - } - } + const updateData = { + id: edge.id, + hidden: false, + opacity: newOpacity, + width: newWidth, + color: { + color: defaultColor, + highlight: defaultColor, + hover: defaultColor, + inherit: false, + }, + shadow: { + enabled: true, + color: "rgba(0,0,0,0.08)", + size: newShadowSize, + x: 1, + y: 1, + }, + font: { + color: "transparent", + background: "transparent", + }, + arrows: { + to: { + enabled: true, + scaleFactor: 0.8, + type: "triangle", + }, + }, + }; - if (nodeRestoreAnimationId) { - cancelAnimationFrame(nodeRestoreAnimationId); - } - nodeRestoreAnimationId = requestAnimationFrame(animateNodeRestore); - - const allEdgesList = edges.get(); - const edgeRestoreAnimStart = performance.now(); - const edgeRestoreAnimDuration = 300; - - function animateEdgeRestore() { - if (isAnimating) { - edgeRestoreAnimationId = null; - return; - } - - const elapsed = performance.now() - edgeRestoreAnimStart; - const progress = Math.min(elapsed / edgeRestoreAnimDuration, 1); - const eased = 1 - Math.pow(1 - progress, 3); - - allEdgesList.forEach(function (edge) { - if (activeDrawerEdges.includes(edge.id)) { - return; - } - - const defaultColor = edge.dashes || edge.label === 'AND' ? '{{ CREWAI_ORANGE }}' : '{{ GRAY }}'; - const currentOpacity = edge.opacity !== undefined ? edge.opacity : 1.0; - const currentWidth = edge.width !== undefined ? edge.width : 2; - const currentShadowSize = edge.shadow && edge.shadow.size !== undefined ? edge.shadow.size : 4; - - const targetOpacity = 1.0; - const targetWidth = 2; - const targetShadowSize = 4; - - const newOpacity = currentOpacity + (targetOpacity - currentOpacity) * eased; - const newWidth = currentWidth + (targetWidth - currentWidth) * eased; - const newShadowSize = currentShadowSize + (targetShadowSize - currentShadowSize) * eased; - - const updateData = { - id: edge.id, - hidden: false, - opacity: newOpacity, - width: newWidth, - color: { - color: defaultColor, - highlight: defaultColor - }, - shadow: { - enabled: true, - color: 'rgba(0,0,0,0.08)', - size: newShadowSize, - x: 1, - y: 1 - }, - font: { - color: 'transparent', - background: 'transparent' - } - }; - - if (edge.dashes) { - const scale = Math.sqrt(newWidth / 2); - updateData.dashes = [15 * scale, 10 * scale]; - } - - edges.update(updateData); - }); - - if (progress < 1) { - edgeRestoreAnimationId = requestAnimationFrame(animateEdgeRestore); - } else { - edgeRestoreAnimationId = null; - } - } - - if (edgeRestoreAnimationId) { - cancelAnimationFrame(edgeRestoreAnimationId); - } - edgeRestoreAnimationId = requestAnimationFrame(animateEdgeRestore); - - highlightedNodes = []; - highlightedEdges = []; - - highlightCanvas.style.transition = 'opacity 300ms ease-out'; - highlightCanvas.style.opacity = '0'; - setTimeout(function () { - highlightCanvas.classList.remove('visible'); - highlightCanvas.style.opacity = '1'; - highlightCanvas.style.transition = ''; - highlightCtx.clearRect(0, 0, highlightCanvas.width, highlightCanvas.height); - }, 300); + if (edge.dashes) { + const scale = Math.sqrt(newWidth / CONSTANTS.EDGE.DEFAULT_WIDTH); + updateData.dashes = [15 * scale, 10 * scale]; } - network.on('afterDrawing', function () { - if (highlightCanvas.classList.contains('visible')) { - drawHighlightLayer(); - } - }); + this.edges.update(updateData); + }); - let hoveredNodeId = null; - let pressedNodeId = null; - let isClicking = false; - let activeDrawerNodeId = null; - let activeDrawerEdges = []; + if (progress < 1) { + requestAnimationFrame(animateEdges); + } + }; - const edgeAnimations = {}; + animateEdges(); - function animateEdgeWidth(edgeId, targetWidth, duration) { - if (edgeAnimations[edgeId]) { - cancelAnimationFrame(edgeAnimations[edgeId].frameId); - } + this.highlightedNodes = []; + this.highlightedEdges = []; - const edge = edges.get(edgeId); - if (!edge) return; + this.canvas.style.transition = "opacity 300ms ease-out"; + this.canvas.style.opacity = "0"; + setTimeout(() => { + this.canvas.classList.remove("visible"); + this.canvas.style.opacity = "1"; + this.canvas.style.transition = ""; + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.network.redraw(); + }, 300); + } +} - const startWidth = edge.width || 2; - const startTime = performance.now(); +// ============================================================================ +// Drawer Manager +// ============================================================================ - function animate() { - const currentTime = performance.now(); - const elapsed = currentTime - startTime; - const progress = Math.min(elapsed / duration, 1); - const eased = 1 - Math.pow(1 - progress, 3); - const currentWidth = startWidth + (targetWidth - startWidth) * eased; +class DrawerManager { + constructor(network, nodes, edges, animationManager, triggeredByHighlighter) { + this.network = network; + this.nodes = nodes; + this.edges = edges; + this.animationManager = animationManager; + this.triggeredByHighlighter = triggeredByHighlighter; - edges.update({ - id: edgeId, - width: currentWidth - }); + this.elements = { + drawer: document.getElementById("drawer"), + overlay: document.getElementById("drawer-overlay"), + title: document.getElementById("drawer-node-name"), + content: document.getElementById("drawer-content"), + openIdeButton: document.getElementById("drawer-open-ide"), + closeButton: document.getElementById("drawer-close"), + navControls: document.querySelector(".nav-controls"), + }; - if (progress < 1) { - edgeAnimations[edgeId] = { - frameId: requestAnimationFrame(animate) - }; - } else { - delete edgeAnimations[edgeId]; - } - } + this.activeNodeId = null; + this.activeEdges = []; - animate(); - } + this.setupEventListeners(); + } - network.on('hoverNode', function (params) { - const nodeId = params.node; - hoveredNodeId = nodeId; - document.body.style.cursor = 'pointer'; - network.redraw(); - }); + setupEventListeners() { + this.elements.overlay.addEventListener("click", () => this.close()); + this.elements.closeButton.addEventListener("click", () => this.close()); - network.on('blurNode', function (params) { - const nodeId = params.node; - if (hoveredNodeId === nodeId) { - hoveredNodeId = null; - } - document.body.style.cursor = 'default'; - network.redraw(); - }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + this.close(); + } + }); + } - let pressedEdges = []; + open(nodeName, metadata) { + this.elements.title.textContent = nodeName; + this.setupIdeButton(metadata); + this.renderContent(nodeName, metadata); + this.animateOpen(); + } - network.on('selectNode', function (params) { - if (params.nodes.length > 0) { - const nodeId = params.nodes[0]; - pressedNodeId = nodeId; + setupIdeButton(metadata) { + if (metadata.source_file && metadata.source_start_line) { + this.elements.openIdeButton.style.display = "flex"; + this.elements.openIdeButton.onclick = () => this.openInIDE(metadata); + } else { + this.elements.openIdeButton.style.display = "none"; + } + } - const connectedEdges = network.getConnectedEdges(nodeId); - pressedEdges = connectedEdges; + openInIDE(metadata) { + const filePath = metadata.source_file; + const lineNum = metadata.source_start_line; + const detectedIDE = this.detectIDE(); - network.redraw(); - } - }); + const ideUrls = { + pycharm: `pycharm://open?file=${filePath}&line=${lineNum}`, + vscode: `vscode://file/${filePath}:${lineNum}`, + jetbrains: `jetbrains://open?file=${encodeURIComponent(filePath)}&line=${lineNum}`, + auto: `pycharm://open?file=${filePath}&line=${lineNum}`, + }; - network.on('deselectNode', function (params) { - if (pressedNodeId) { - const nodeId = pressedNodeId; + const ideUrl = ideUrls[detectedIDE] || ideUrls.auto; - setTimeout(function () { - if (isClicking) { - isClicking = false; - pressedNodeId = null; - pressedEdges = []; - return; - } + const link = document.createElement("a"); + link.href = ideUrl; + link.target = "_blank"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); - pressedNodeId = null; + const fallbackText = `${filePath}:${lineNum}`; + navigator.clipboard.writeText(fallbackText).catch((err) => { + console.error("Failed to copy:", err); + }); + } - pressedEdges.forEach(function (edgeId) { - if (!activeDrawerEdges.includes(edgeId)) { - animateEdgeWidth(edgeId, 2, 150); - } - }); - pressedEdges = []; - network.redraw(); - }, 10); - } - }); - let highlightedNodeId = null; - let highlightedSourceNodeId = null; - let highlightedEdgeId = null; - let highlightTimeout = null; - let originalNodeData = null; - let originalSourceNodeData = null; - let originalEdgeData = null; - let isAnimating = false; + detectIDE() { + const savedIDE = localStorage.getItem("preferred_ide"); + if (savedIDE) return savedIDE; + if (navigator.userAgent.includes("JetBrains")) return "jetbrains"; + return "auto"; + } - function clearHighlights() { - isAnimating = false; + renderContent(nodeName, metadata) { + let content = ""; - if (highlightTimeout) { - clearTimeout(highlightTimeout); - highlightTimeout = null; - } + content += this.renderMetadata(metadata); - if (originalNodeData && originalNodeData.originalOpacities) { - originalNodeData.originalOpacities.forEach((opacity, nodeId) => { - nodes.update({ - id: nodeId, - opacity: opacity - }); - }); - } + if (metadata.source_code) { + content += this.renderSourceCode(metadata); + } - if (originalNodeData && originalNodeData.originalEdgesMap) { - originalNodeData.originalEdgesMap.forEach((edgeData, edgeId) => { - edges.update({ - id: edgeId, - opacity: edgeData.opacity, - width: edgeData.width, - color: edgeData.color - }); - }); - } + this.elements.content.innerHTML = content; + this.attachContentEventListeners(nodeName); + } - if (highlightedNodeId) { - if (originalNodeData && originalNodeData.shadow) { - nodes.update({ - id: highlightedNodeId, - shadow: originalNodeData.shadow - }); - } else { - nodes.update({ - id: highlightedNodeId, - shadow: { - enabled: true, - color: 'rgba(0,0,0,0.1)', - size: 8, - x: 2, - y: 2 - } - }); - } - highlightedNodeId = null; - originalNodeData = null; - } + renderTriggerCondition(metadata) { + if (metadata.trigger_condition) { + return this.renderConditionTree(metadata.trigger_condition); + } else if (metadata.trigger_methods) { + return ` + + `; + } + return ""; + } - if (highlightedEdgeId) { - if (originalEdgeData && originalEdgeData.shadow) { - edges.update({ - id: highlightedEdgeId, - shadow: originalEdgeData.shadow - }); - } else { - edges.update({ - id: highlightedEdgeId, - shadow: { - enabled: true, - color: 'rgba(0,0,0,0.08)', - size: 4, - x: 1, - y: 1 - } - }); - } - highlightedEdgeId = null; - originalEdgeData = null; - } + renderConditionTree(condition, depth = 0) { + if (typeof condition === "string") { + return `${condition}`; + } - if (highlightedSourceNodeId) { - if (originalSourceNodeData && originalSourceNodeData.shadow) { - nodes.update({ - id: highlightedSourceNodeId, - shadow: originalSourceNodeData.shadow - }); - } else { - nodes.update({ - id: highlightedSourceNodeId, - shadow: null - }); - } - highlightedSourceNodeId = null; - originalSourceNodeData = null; - } - } + if (condition.type === "AND" || condition.type === "OR") { + const conditionType = condition.type; + const color = conditionType === "AND" ? "{{ CREWAI_ORANGE }}" : "var(--text-secondary)"; + const bgColor = conditionType === "AND" ? "rgba(255,90,80,0.08)" : "rgba(102,102,102,0.06)"; + const hoverBg = conditionType === "AND" ? "rgba(255,90,80,0.15)" : "rgba(102,102,102,0.12)"; + const triggerIds = this.extractTriggerIds(condition); + const triggerIdsJson = JSON.stringify(triggerIds).replace(/"/g, '"'); - function highlightPython(code) { - return Prism.highlight(code, Prism.languages.python, 'python'); - } + const children = condition.conditions.map(sub => this.renderConditionTree(sub, depth + 1)).join(""); - function highlightJson(jsonString) { - let escaped = jsonString - .replace(/&/g, '&') - .replace(//g, '>'); - - let result = escaped - .replace(/(")([^&]+?)(")(\\s*)(:)/g, '$1$2$3$4$5') - .replace(/(:)(\\s*)(")([^&]*?)(")/g, '$1$2$3$4$5') - .replace(/(:)(\\s*)([-+]?\\d+\\.?\\d*)/g, '$1$2$3') - .replace(/:\\s*(true|false)\\b/g, ': $1') - .replace(/:\\s*null\\b/g, ': null') - .replace(/([{\\[\\]}])/g, '$1') - .replace(/,/g, ','); - - return result; - } - - network.on('click', function (params) { - if (params.nodes.length > 0) { - const nodeId = params.nodes[0]; - const node = nodes.get(nodeId); - const nodeData = '{{ nodeData }}'; - const metadata = nodeData[nodeId]; - - isClicking = true; - - clearTriggeredByHighlight(); - if (activeDrawerNodeId && activeDrawerNodeId !== nodeId) { - activeDrawerEdges.forEach(function (edgeId) { - animateEdgeWidth(edgeId, 2, 200); - }); - } - - activeDrawerNodeId = nodeId; - const connectedEdges = network.getConnectedEdges(nodeId); - activeDrawerEdges = connectedEdges; - - setTimeout(function () { - activeDrawerEdges.forEach(function (edgeId) { - animateEdgeWidth(edgeId, 5, 200); - }); - - network.redraw(); - }, 15); - - openDrawer(nodeId, metadata); - clearHighlights(); - } else if (params.edges.length === 0) { - clearHighlights(); - closeDrawer(); - } - }); - - function openDrawer(nodeName, metadata) { - const drawer = document.getElementById('drawer'); - const overlay = document.getElementById('drawer-overlay'); - const drawerTitle = document.getElementById('drawer-node-name'); - const drawerContent = document.getElementById('drawer-content'); - const openIdeButton = document.getElementById('drawer-open-ide'); - - drawerTitle.textContent = nodeName; - if (metadata.source_file && metadata.source_start_line) { - openIdeButton.style.display = 'flex'; - openIdeButton.onclick = function () { - const filePath = metadata.source_file; - const lineNum = metadata.source_start_line; - - function detectIDE() { - const savedIDE = localStorage.getItem('preferred_ide'); - if (savedIDE) return savedIDE; - - if (navigator.userAgent.includes('JetBrains')) return 'jetbrains'; - - return 'auto'; - } - - const detectedIDE = detectIDE(); - let ideUrl; - - if (detectedIDE === 'pycharm' || detectedIDE === 'auto') { - ideUrl = `pycharm://open?file=${filePath}&line=${lineNum}`; - } else if (detectedIDE === 'vscode') { - ideUrl = `vscode://file/${filePath}:${lineNum}`; - } else if (detectedIDE === 'jetbrains') { - ideUrl = `jetbrains://open?file=${encodeURIComponent(filePath)}&line=${lineNum}`; - } else { - ideUrl = `pycharm://open?file=${filePath}&line=${lineNum}`; - } - const link = document.createElement('a'); - link.href = ideUrl; - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - const fallbackText = `${filePath}:${lineNum}`; - navigator.clipboard.writeText(fallbackText).catch(function (err) { - console.error('Failed to copy:', err); - }); - }; - } else { - openIdeButton.style.display = 'none'; - } - - let content = ''; - - let metadataContent = ''; - const nodeType = metadata.type || 'unknown'; - const typeBadgeColor = nodeType === 'start' || nodeType === 'router' ? '{{ CREWAI_ORANGE }}' : '{{ DARK_GRAY }}'; - metadataContent += ` -
-
Type
- ${nodeType} + return ` +
+
+ ${conditionType} +
+
+ ${children} +
- `; + `; + } - if (metadata.condition_type) { - let conditionColor, conditionBg; - if (metadata.condition_type === 'AND') { - conditionColor = '{{ CREWAI_ORANGE }}'; - conditionBg = 'rgba(255,90,80,0.12)'; - } else if (metadata.condition_type === 'IF') { - conditionColor = '{{ CREWAI_ORANGE }}'; - conditionBg = 'rgba(255,90,80,0.18)'; - } else { - conditionColor = '{{ GRAY }}'; - conditionBg = 'rgba(102,102,102,0.12)'; - } - metadataContent += ` + return ""; + } + + extractTriggerIds(condition) { + if (typeof condition === "string") { + return [condition]; + } + + if (condition.type === "AND" || condition.type === "OR") { + const ids = []; + condition.conditions.forEach(sub => { + ids.push(...this.extractTriggerIds(sub)); + }); + return ids; + } + + return []; + } + + renderMetadata(metadata) { + let metadataContent = ""; + + const nodeType = metadata.type || "unknown"; + const typeBadgeColor = + nodeType === "start" || nodeType === "router" + ? "{{ CREWAI_ORANGE }}" + : "{{ DARK_GRAY }}"; + metadataContent += `
-
Condition
- ${metadata.condition_type} +
Type
+ ${nodeType}
`; - } - if (metadata.trigger_methods && metadata.trigger_methods.length > 0) { - metadataContent += ` -
-
Triggered By
-
    - ${metadata.trigger_methods.map(t => `
  • ${t}
  • `).join('')} -
-
- `; - } + if (metadata.condition_type) { + const conditionColors = { + AND: { color: "{{ CREWAI_ORANGE }}", bg: "rgba(255,90,80,0.12)" }, + IF: { color: "{{ CREWAI_ORANGE }}", bg: "rgba(255,90,80,0.18)" }, + default: { color: "{{ GRAY }}", bg: "rgba(102,102,102,0.12)" }, + }; - if (metadata.router_paths && metadata.router_paths.length > 0) { - metadataContent += ` -
-
Router Paths
-
    - ${metadata.router_paths.map(p => `
  • ${p}
  • `).join('')} -
-
- `; - } + const { color, bg } = + conditionColors[metadata.condition_type] || conditionColors.default; - if (metadataContent) { - content += ``; - } + metadataContent += ` +
+
Condition
+ ${metadata.condition_type} +
+ `; + } - if (metadata.source_code) { - let lines = metadata.source_lines || metadata.source_code.split('\n'); - if (metadata.source_lines) { - lines = lines.map(function(line) { return line.replace(/\n$/, ''); }); - } + if (metadata.trigger_condition || (metadata.trigger_methods && metadata.trigger_methods.length > 0)) { + metadataContent += ` +
+
Triggered By
+ ${this.renderTriggerCondition(metadata)} +
+ `; + } - let minIndent = Infinity; - lines.forEach(function (line) { - if (line.trim().length > 0) { - const match = line.match(/^\s*/); - const indent = match ? match[0].length : 0; - minIndent = Math.min(minIndent, indent); - } - }); + if (metadata.router_paths && metadata.router_paths.length > 0) { + const routerPathsJson = JSON.stringify(metadata.router_paths).replace(/"/g, '"'); + metadataContent += ` +
+
+ Router Paths +
+
    + ${metadata.router_paths.map((p) => `
  • ${p}
  • `).join("")} +
+
+ `; + } - const dedentedLines = lines.map(function (line) { - if (line.trim().length === 0) return ''; - return line.substring(minIndent); - }); + return metadataContent + ? `` + : ""; + } - const startLine = metadata.source_start_line || 1; + renderSourceCode(metadata) { + let lines = metadata.source_lines || metadata.source_code.split("\n"); + if (metadata.source_lines) { + lines = lines.map((line) => line.replace(/\n$/, "")); + } - const codeToHighlight = dedentedLines.join('\n').trim(); + let minIndent = Infinity; + lines.forEach((line) => { + if (line.trim().length > 0) { + const match = line.match(/^\s*/); + const indent = match ? match[0].length : 0; + minIndent = Math.min(minIndent, indent); + } + }); - const highlightedCode = highlightPython(codeToHighlight); + const dedentedLines = lines.map((line) => { + return line.trim().length === 0 ? "" : line.substring(minIndent); + }); - const highlightedLines = highlightedCode.split('\n'); - const numberedLines = highlightedLines.map(function (line, index) { - const lineNum = startLine + index; - return '
' + lineNum + '' + line + '
'; - }).join(''); + const startLine = metadata.source_start_line || 1; + const codeToHighlight = dedentedLines.join("\n").trim(); + const highlightedCode = highlightPython(codeToHighlight); - const titleText = 'Source Code'; + const highlightedLines = highlightedCode.split("\n"); + const numberedLines = highlightedLines + .map((line, index) => { + const lineNum = startLine + index; + return `
${lineNum}${line}
`; + }) + .join(""); - let classSection = ''; - if (metadata.class_signature) { - const highlightedClass = highlightPython(metadata.class_signature); + let classSection = ""; + if (metadata.class_signature) { + const highlightedClass = highlightPython(metadata.class_signature); + let highlightedClassSignature = highlightedClass; - let highlightedClassSignature = highlightedClass; - if (metadata.class_line_number) { - highlightedClassSignature = '' + metadata.class_line_number + '' + highlightedClass; - } + if (metadata.class_line_number) { + highlightedClassSignature = `${metadata.class_line_number}${highlightedClass}`; + } - classSection = ` -
-
Class
-
-
-
${highlightedClassSignature}
-
+ classSection = ` +
+
Class
+
+
+
${highlightedClassSignature}
-
- `; - } +
+
+ `; + } - content += ` + return `
-
${titleText}
+
Source Code
@@ -1041,7 +1188,7 @@ function loadVisCDN() {
-
`; - } - - drawerContent.innerHTML = content; - - const copyButtons = drawerContent.querySelectorAll('.code-copy-button'); - copyButtons.forEach(function (button) { - button.addEventListener('click', function () { - const codeText = button.getAttribute('data-code'); - - navigator.clipboard.writeText(codeText).then(function () { - button.classList.add('copied'); - setTimeout(function () { - button.classList.remove('copied'); - }, 2000); - }).catch(function (err) { - console.error('Failed to copy:', err); - }); - }); - }); - - const accordionHeaders = drawerContent.querySelectorAll('.accordion-header'); - accordionHeaders.forEach(function (header) { - header.addEventListener('click', function () { - const accordionSection = header.closest('.accordion-section'); - - accordionSection.classList.toggle('expanded'); - }); - }); - - const triggerLinks = drawerContent.querySelectorAll('.drawer-code-link'); - triggerLinks.forEach(function (link) { - link.addEventListener('click', function () { - const targetNodeId = link.getAttribute('data-node-id'); - const currentNodeId = nodeName; - - if (targetNodeId) { - if (nodeRestoreAnimationId) { - cancelAnimationFrame(nodeRestoreAnimationId); - nodeRestoreAnimationId = null; - } - if (edgeRestoreAnimationId) { - cancelAnimationFrame(edgeRestoreAnimationId); - edgeRestoreAnimationId = null; - } - - if (isAnimating) { - clearHighlights(); - } - - const allCurrentEdges = edges.get(); - allCurrentEdges.forEach(edge => { - if (activeDrawerEdges.includes(edge.id)) { - return; - } - edges.update({ - id: edge.id, - opacity: 1.0, - width: 2 - }); - }); - - let edge = edges.get().find(function (e) { - return e.from === targetNodeId && e.to === currentNodeId; - }); - - let isForwardAnimation = false; - if (!edge) { - edge = edges.get().find(function (e) { - return e.from === currentNodeId && e.to === targetNodeId; - }); - isForwardAnimation = true; - } - - let actualTargetNodeId = targetNodeId; - let intermediateNodeId = null; - const targetNode = nodes.get(targetNodeId); - - if (!targetNode) { - const allNodesData = '{{ nodeData }}'; - - let routerNodeId = null; - for (const nodeId in allNodesData) { - const nodeMetadata = allNodesData[nodeId]; - if (nodeMetadata.router_paths && nodeMetadata.router_paths.includes(targetNodeId)) { - routerNodeId = nodeId; - break; - } - } - - if (routerNodeId) { - const allEdges = edges.get(); - edge = allEdges.find(function (e) { - return e.from === routerNodeId && e.to === currentNodeId; - }); - - if (edge) { - actualTargetNodeId = routerNodeId; - isForwardAnimation = false; - } else { - - const listenersOfPath = []; - for (const nodeId in allNodesData) { - const nodeMetadata = allNodesData[nodeId]; - if (nodeId !== currentNodeId && nodeMetadata.trigger_methods && nodeMetadata.trigger_methods.includes(targetNodeId)) { - listenersOfPath.push(nodeId); - } - } - - for (let i = 0; i < listenersOfPath.length; i++) { - const listenerNodeId = listenersOfPath[i]; - const edgeToCurrentNode = allEdges.find(function (e) { - return e.from === listenerNodeId && e.to === currentNodeId; - }); - - if (edgeToCurrentNode) { - actualTargetNodeId = routerNodeId; - intermediateNodeId = listenerNodeId; - isForwardAnimation = true; - edge = allEdges.find(function (e) { - return e.from === routerNodeId && e.to === listenerNodeId; - }); - break; - } - } - } - } - - if (!edge) { - for (const nodeId in allNodesData) { - const nodeMetadata = allNodesData[nodeId]; - if (nodeMetadata.trigger_methods && nodeMetadata.trigger_methods.includes(targetNodeId)) { - actualTargetNodeId = nodeId; - - const allEdges = edges.get(); - edge = allEdges.find(function (e) { - return e.from === currentNodeId && e.to === actualTargetNodeId; - }); - - break; - } - } - } - } - - let nodesToHide = []; - let edgesToHide = []; - let edgesToRestore = []; - - const nodeData = nodes.get(actualTargetNodeId); - if (nodeData) { - let animationSourceId, animationTargetId; - if (intermediateNodeId) { - animationSourceId = actualTargetNodeId; - animationTargetId = intermediateNodeId; - } else { - animationSourceId = isForwardAnimation ? currentNodeId : actualTargetNodeId; - animationTargetId = isForwardAnimation ? actualTargetNodeId : currentNodeId; - } - - const allNodes = nodes.get(); - const originalNodeOpacities = new Map(); - - const activeNodeIds = [animationSourceId, animationTargetId]; - if (intermediateNodeId) { - activeNodeIds.push(intermediateNodeId); - } - if (currentNodeId) { - activeNodeIds.push(currentNodeId); - } - - allNodes.forEach(node => { - originalNodeOpacities.set(node.id, node.opacity !== undefined ? node.opacity : 1); - if (activeNodeIds.includes(node.id)) { - nodes.update({ - id: node.id, - opacity: 1.0 - }); - } else { - nodes.update({ - id: node.id, - opacity: 0.2 - }); - } - }); - - const allEdges = edges.get(); - const originalEdgesMap = new Map(); - allEdges.forEach(edge => { - originalEdgesMap.set(edge.id, { - opacity: edge.opacity !== undefined ? edge.opacity : 1.0, - width: edge.width || 2, - color: edge.color - }); - edges.update({ - id: edge.id, - opacity: 0.2 - }); - }); - - const sourceNodeData = nodes.get(animationSourceId); - const targetNodeData = nodes.get(animationTargetId); - const sourceOriginalShadow = sourceNodeData ? sourceNodeData.shadow : null; - - originalNodeData = { - shadow: targetNodeData ? targetNodeData.shadow : null, - opacity: targetNodeData ? targetNodeData.opacity : 1, - originalOpacities: originalNodeOpacities, - originalEdgesMap: originalEdgesMap - }; - originalSourceNodeData = { - shadow: sourceOriginalShadow - }; - highlightedNodeId = animationTargetId; - highlightedSourceNodeId = animationSourceId; - isAnimating = true; - - const phase1Duration = 150; - const phase1Start = Date.now(); - - function animateSourcePulse() { - if (!isAnimating) { - return; - } - - const elapsed = Date.now() - phase1Start; - const progress = Math.min(elapsed / phase1Duration, 1); - - const eased = progress < 0.5 - ? 2 * progress * progress - : 1 - Math.pow(-2 * progress + 2, 2) / 2; - - const pulseSize = eased * 15; - - nodes.update({ - id: animationSourceId, - shadow: { - enabled: true, - color: '{{ CREWAI_ORANGE }}', - size: pulseSize, - x: 0, - y: 0 - } - }); - - if (progress < 1 && isAnimating) { - requestAnimationFrame(animateSourcePulse); - } else { - startPhase2(); - } - } - - function startPhase2() { - const phase2Duration = 400; - const phase2Start = Date.now(); - - if (edge) { - originalEdgeData = { - shadow: edge.shadow, - level: edge.level - }; - highlightedEdgeId = edge.id; - } - - let secondHopEdge = null; - if (intermediateNodeId && currentNodeId) { - const allEdges = edges.get(); - secondHopEdge = allEdges.find(function (e) { - return e.from === intermediateNodeId && e.to === currentNodeId; - }); - } - - function animateTravel() { - if (!isAnimating) return; - - const elapsed = Date.now() - phase2Start; - const progress = Math.min(elapsed / phase2Duration, 1); - - const eased = 1 - Math.pow(1 - progress, 3); - - if (edge) { - const edgeGlowSize = eased * 15; - const edgeWidth = 2 + (eased * 6); - edges.update({ - id: edge.id, - width: edgeWidth, - opacity: 1.0, - color: { - color: '{{ CREWAI_ORANGE }}', - highlight: '{{ CREWAI_ORANGE }}' - }, - shadow: { - enabled: true, - color: '{{ CREWAI_ORANGE }}', - size: edgeGlowSize, - x: 0, - y: 0 - } - }); - } - - if (secondHopEdge && progress > 0.5) { - const secondHopProgress = (progress - 0.5) / 0.5; - const secondHopEased = 1 - Math.pow(1 - secondHopProgress, 3); - const secondEdgeGlowSize = secondHopEased * 15; - const secondEdgeWidth = 2 + (secondHopEased * 6); - - edges.update({ - id: secondHopEdge.id, - width: secondEdgeWidth, - opacity: 1.0, - color: { - color: '{{ CREWAI_ORANGE }}', - highlight: '{{ CREWAI_ORANGE }}' - }, - shadow: { - enabled: true, - color: '{{ CREWAI_ORANGE }}', - size: secondEdgeGlowSize, - x: 0, - y: 0 - } - }); - } - - if (progress > 0.3) { - const nodeProgress = (progress - 0.3) / 0.7; - const nodeEased = 1 - Math.pow(1 - nodeProgress, 3); - const glowSize = nodeEased * 25; - - nodes.update({ - id: animationTargetId, - shadow: { - enabled: true, - color: '{{ CREWAI_ORANGE }}', - size: glowSize, - x: 0, - y: 0 - } - }); - } - - if (progress < 1 && isAnimating) { - requestAnimationFrame(animateTravel); - } else { - nodes.update({ - id: animationSourceId, - shadow: null - }); - nodes.update({ - id: animationTargetId, - shadow: null - }); - } - } - - animateTravel(); - } - - animateSourcePulse(); - } else if (edge) { - clearHighlights(); - - originalEdgeData = { - shadow: edge.shadow - }; - highlightedEdgeId = edge.id; - isAnimating = true; - - const animationDuration = 300; - const startTime = Date.now(); - - function animateEdgeGlow() { - if (!isAnimating) return; - - const elapsed = Date.now() - startTime; - const progress = Math.min(elapsed / animationDuration, 1); - const eased = 1 - Math.pow(1 - progress, 3); - const edgeGlowSize = eased * 15; - - edges.update({ - id: edge.id, - shadow: { - enabled: true, - color: '{{ CREWAI_ORANGE }}', - size: edgeGlowSize, - x: 0, - y: 0 - } - }); - - if (progress < 1 && isAnimating) { - requestAnimationFrame(animateEdgeGlow); - } - } - - animateEdgeGlow(); - } - - } - }); - }); - - const triggeredByLinks = drawerContent.querySelectorAll('.drawer-code-link[data-node-id]'); - triggeredByLinks.forEach(function (link) { - link.addEventListener('click', function (e) { - e.preventDefault(); - e.stopPropagation(); - const triggerNodeId = this.getAttribute('data-node-id'); - highlightTriggeredBy(triggerNodeId); - }); - }); - - drawer.style.visibility = 'visible'; - - const wasAlreadyOpen = drawer.classList.contains('open'); - requestAnimationFrame(function () { - drawer.classList.add('open'); - overlay.classList.add('visible'); - document.querySelector('.nav-controls').classList.add('drawer-open'); - - if (!wasAlreadyOpen) { - setTimeout(function () { - const currentScale = network.getScale(); - const currentPosition = network.getViewPosition(); - - const drawerWidth = 400; - const offsetX = (drawerWidth * 0.3) / currentScale; - network.moveTo({ - position: { - x: currentPosition.x + offsetX, - y: currentPosition.y - }, - scale: currentScale, - animation: { - duration: 300, - easingFunction: 'easeInOutQuad' - } - }); - }, 50); // Small delay to let drawer animation start - } - }); - } - - function closeDrawer() { - const drawer = document.getElementById('drawer'); - const overlay = document.getElementById('drawer-overlay'); - drawer.classList.remove('open'); - overlay.classList.remove('visible'); - document.querySelector('.nav-controls').classList.remove('drawer-open'); - - clearTriggeredByHighlight(); - if (activeDrawerNodeId) { - activeDrawerEdges.forEach(function (edgeId) { - animateEdgeWidth(edgeId, 2, 200); - }); - activeDrawerNodeId = null; - activeDrawerEdges = []; - - network.redraw(); - } - setTimeout(function () { - network.fit({ - animation: { - duration: 300, - easingFunction: 'easeInOutQuad' - } - }); - }, 50); - - setTimeout(function () { - if (!drawer.classList.contains('open')) { - drawer.style.visibility = 'hidden'; - } - }, 300); - } - - document.getElementById('drawer-overlay').addEventListener('click', function () { - closeDrawer(); - clearHighlights(); - }); - document.getElementById('drawer-close').addEventListener('click', closeDrawer); - - document.addEventListener('keydown', function (e) { - if (e.key === 'Escape') { - closeDrawer(); - } - }); - - network.once('stabilizationIterationsDone', function () { - network.fit(); - }); - - - document.getElementById('zoom-in').addEventListener('click', function () { - const scale = network.getScale(); - network.moveTo({ - scale: scale * 1.2, - animation: { - duration: 200, - easingFunction: 'easeInOutQuad' - } - }); - }); - - document.getElementById('zoom-out').addEventListener('click', function () { - const scale = network.getScale(); - network.moveTo({ - scale: scale * 0.8, - animation: { - duration: 200, - easingFunction: 'easeInOutQuad' - } - }); - }); - - document.getElementById('fit').addEventListener('click', function () { - network.fit({ - animation: { - duration: 300, - easingFunction: 'easeInOutQuad' - } - }); - }); - - document.getElementById('export-png').addEventListener('click', function () { - const script = document.createElement('script'); - script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'; - script.onload = function () { - html2canvas(document.getElementById('network-container')).then(function (canvas) { - const link = document.createElement('a'); - link.download = 'flow_dag.png'; - link.href = canvas.toDataURL(); - link.click(); - }); - }; - document.head.appendChild(script); - }); - - document.getElementById('export-pdf').addEventListener('click', function () { - const script1 = document.createElement('script'); - script1.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'; - script1.onload = function () { - const script2 = document.createElement('script'); - script2.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js'; - script2.onload = function () { - html2canvas(document.getElementById('network-container')).then(function (canvas) { - const imgData = canvas.toDataURL('image/png'); - const {jsPDF} = window.jspdf; - const pdf = new jsPDF({ - orientation: canvas.width > canvas.height ? 'landscape' : 'portrait', - unit: 'px', - format: [canvas.width, canvas.height] - }); - pdf.addImage(imgData, 'PNG', 0, 0, canvas.width, canvas.height); - pdf.save('flow_dag.pdf'); - }); - }; - document.head.appendChild(script2); - }; - document.head.appendChild(script1); - }); - - document.getElementById('export-json').addEventListener('click', function () { - const dagData = '{{ dagData }}'; - const dataStr = JSON.stringify(dagData, null, 2); - const blob = new Blob([dataStr], {type: 'application/json'}); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.download = 'flow_dag.json'; - link.href = url; - link.click(); - URL.revokeObjectURL(url); - }); - - const themeToggle = document.getElementById('theme-toggle'); - const htmlElement = document.documentElement; - - function getCSSVariable(name) { - return getComputedStyle(htmlElement).getPropertyValue(name).trim(); - } - - function updateEdgeLabelColors() { - edges.forEach(function (edge) { - edges.update({ - id: edge.id, - font: { - color: 'transparent', - background: 'transparent' - } - }); - }); - } - - const savedTheme = localStorage.getItem('theme') || 'light'; - if (savedTheme === 'dark') { - htmlElement.setAttribute('data-theme', 'dark'); - themeToggle.textContent = '☀️'; - themeToggle.title = 'Toggle Light Mode'; - setTimeout(updateEdgeLabelColors, 0); - } - - themeToggle.addEventListener('click', function () { - const currentTheme = htmlElement.getAttribute('data-theme'); - const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; - - if (newTheme === 'dark') { - htmlElement.setAttribute('data-theme', 'dark'); - themeToggle.textContent = '☀️'; - themeToggle.title = 'Toggle Light Mode'; - } else { - htmlElement.removeAttribute('data-theme'); - themeToggle.textContent = '🌙'; - themeToggle.title = 'Toggle Dark Mode'; - } - - localStorage.setItem('theme', newTheme); - setTimeout(updateEdgeLabelColors, 50); - }); - } catch (e) { - console.error(e); + } + + attachContentEventListeners(nodeName) { + const copyButtons = + this.elements.content.querySelectorAll(".code-copy-button"); + copyButtons.forEach((button) => { + button.addEventListener("click", () => { + const codeText = button.getAttribute("data-code"); + navigator.clipboard + .writeText(codeText) + .then(() => { + button.classList.add("copied"); + setTimeout(() => button.classList.remove("copied"), 2000); + }) + .catch((err) => console.error("Failed to copy:", err)); + }); + }); + + const accordionHeaders = + this.elements.content.querySelectorAll(".accordion-header"); + accordionHeaders.forEach((header) => { + header.addEventListener("click", () => { + const accordionSection = header.closest(".accordion-section"); + accordionSection.classList.toggle("expanded"); + }); + }); + + const triggeredByLinks = this.elements.content.querySelectorAll( + ".drawer-code-link[data-node-id]", + ); + triggeredByLinks.forEach((link) => { + link.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const triggerNodeId = link.getAttribute("data-node-id"); + this.triggeredByHighlighter.highlightTriggeredBy(triggerNodeId); + }); + }); + + const conditionGroups = this.elements.content.querySelectorAll( + ".condition-group[data-trigger-group]", + ); + conditionGroups.forEach((group) => { + group.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const triggerIds = JSON.parse(group.getAttribute("data-trigger-group")); + this.triggeredByHighlighter.highlightTriggeredByGroup(triggerIds); + }); + }); + + const routerPathsTitle = this.elements.content.querySelector( + ".router-paths-title[data-router-paths]", + ); + if (routerPathsTitle) { + routerPathsTitle.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + this.triggeredByHighlighter.highlightAllRouterPaths(); + }); } -})() + } + + animateOpen() { + this.elements.drawer.style.visibility = "visible"; + const wasAlreadyOpen = this.elements.drawer.classList.contains("open"); + + requestAnimationFrame(() => { + this.elements.drawer.classList.add("open"); + this.elements.overlay.classList.add("visible"); + this.elements.navControls.classList.add("drawer-open"); + + if (!wasAlreadyOpen) { + setTimeout(() => { + const currentScale = this.network.getScale(); + const currentPosition = this.network.getViewPosition(); + const offsetX = + (CONSTANTS.DRAWER.WIDTH * CONSTANTS.DRAWER.OFFSET_SCALE) / + currentScale; + + this.network.moveTo({ + position: { + x: currentPosition.x + offsetX, + y: currentPosition.y, + }, + scale: currentScale, + animation: { + duration: 300, + easingFunction: "easeInOutQuad", + }, + }); + }, 50); + } + }); + } + + close() { + this.elements.drawer.classList.remove("open"); + this.elements.overlay.classList.remove("visible"); + this.elements.navControls.classList.remove("drawer-open"); + + if (this.activeNodeId) { + this.activeEdges.forEach((edgeId) => { + this.animationManager.animateEdgeWidth( + this.edges, + edgeId, + CONSTANTS.EDGE.DEFAULT_WIDTH, + 200, + ); + }); + this.activeNodeId = null; + this.activeEdges = []; + } + + this.triggeredByHighlighter.clear(); + + setTimeout(() => { + this.network.fit({ + animation: { + duration: 300, + easingFunction: "easeInOutQuad", + }, + }); + }, 50); + + setTimeout(() => { + if (!this.elements.drawer.classList.contains("open")) { + this.elements.drawer.style.visibility = "hidden"; + } + }, 300); + } + + setActiveNode(nodeId, connectedEdges) { + this.activeNodeId = nodeId; + this.activeEdges = connectedEdges; + } +} + +// ============================================================================ +// Network Manager +// ============================================================================ + +class NetworkManager { + constructor() { + this.network = null; + this.nodes = null; + this.edges = null; + this.animationManager = new AnimationManager(); + this.drawerManager = null; + this.triggeredByHighlighter = null; + + this.hoveredNodeId = null; + this.pressedNodeId = null; + this.pressedEdges = []; + this.isClicking = false; + } + + async initialize() { + try { + await loadVisCDN(); + + this.nodes = new vis.DataSet('{{ nodes_list_json }}'); + this.edges = new vis.DataSet('{{ edges_list_json }}'); + + const container = document.getElementById("network"); + const options = this.createNetworkOptions(); + + this.network = new vis.Network( + container, + { nodes: this.nodes, edges: this.edges }, + options, + ); + + this.triggeredByHighlighter = new TriggeredByHighlighter( + this.network, + this.nodes, + this.edges, + document.getElementById("highlight-canvas"), + ); + + this.drawerManager = new DrawerManager( + this.network, + this.nodes, + this.edges, + this.animationManager, + this.triggeredByHighlighter, + ); + + this.setupEventListeners(); + this.setupControls(); + this.setupTheme(); + + this.network.once("stabilizationIterationsDone", () => { + this.network.fit(); + }); + } catch (error) { + console.error("Failed to initialize network:", error); + } + } + + createNetworkOptions() { + const nodeRenderer = new NodeRenderer(this.nodes, this); + + return { + nodes: { + shape: "custom", + shadow: false, + chosen: false, + size: 30, + ctxRenderer: (params) => nodeRenderer.render(params), + scaling: { + min: 1, + max: 100, + }, + }, + edges: { + width: CONSTANTS.EDGE.DEFAULT_WIDTH, + hoverWidth: 0, + labelHighlightBold: false, + shadow: false, + smooth: { + type: "cubicBezier", + roundness: 0.5, + }, + font: { + size: 13, + align: "middle", + color: "transparent", + face: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + strokeWidth: 0, + background: "transparent", + vadjust: 0, + }, + arrows: { + to: { + enabled: true, + scaleFactor: 0.8, + type: "triangle", + }, + }, + arrowStrikethrough: true, + chosen: { + edge: false, + label: false, + }, + }, + physics: { + enabled: true, + hierarchicalRepulsion: { + nodeDistance: CONSTANTS.NETWORK.NODE_DISTANCE, + centralGravity: 0.0, + springLength: CONSTANTS.NETWORK.SPRING_LENGTH, + springConstant: 0.01, + damping: 0.09, + }, + solver: "hierarchicalRepulsion", + stabilization: { + enabled: true, + iterations: CONSTANTS.NETWORK.STABILIZATION_ITERATIONS, + updateInterval: 25, + }, + }, + layout: { + hierarchical: { + enabled: true, + direction: "UD", + sortMethod: "directed", + levelSeparation: CONSTANTS.NETWORK.LEVEL_SEPARATION, + nodeSpacing: CONSTANTS.NETWORK.NODE_SPACING, + treeSpacing: CONSTANTS.NETWORK.TREE_SPACING, + }, + }, + interaction: { + hover: true, + hoverConnectedEdges: false, + navigationButtons: false, + keyboard: true, + selectConnectedEdges: false, + multiselect: false, + }, + }; + } + + setupEventListeners() { + this.network.on("hoverNode", (params) => { + this.hoveredNodeId = params.node; + document.body.style.cursor = "pointer"; + this.network.redraw(); + }); + + this.network.on("blurNode", (params) => { + if (this.hoveredNodeId === params.node) { + this.hoveredNodeId = null; + } + document.body.style.cursor = "default"; + this.network.redraw(); + }); + + this.network.on("selectNode", (params) => { + if (params.nodes.length > 0) { + this.pressedNodeId = params.nodes[0]; + this.pressedEdges = this.network.getConnectedEdges(this.pressedNodeId); + this.network.redraw(); + } + }); + + this.network.on("deselectNode", () => { + if (this.pressedNodeId) { + setTimeout(() => { + if (this.isClicking) { + this.isClicking = false; + this.pressedNodeId = null; + this.pressedEdges = []; + return; + } + + this.pressedEdges.forEach((edgeId) => { + if (!this.drawerManager.activeEdges.includes(edgeId)) { + this.animationManager.animateEdgeWidth( + this.edges, + edgeId, + CONSTANTS.EDGE.DEFAULT_WIDTH, + 150, + ); + } + }); + + this.pressedNodeId = null; + this.pressedEdges = []; + this.network.redraw(); + }, 10); + } + }); + + this.network.on("click", (params) => { + if (params.nodes.length > 0) { + this.pressedNodeId = params.nodes[0]; + this.network.redraw(); + this.handleNodeClick(params.nodes[0]); + } else if (params.edges.length === 0) { + this.pressedNodeId = null; + this.network.redraw(); + this.drawerManager.close(); + } + }); + + this.network.on("afterDrawing", () => { + if (this.triggeredByHighlighter.canvas.classList.contains("visible")) { + this.triggeredByHighlighter.drawHighlightLayer(); + } + }); + } + + handleNodeClick(nodeId) { + const node = this.nodes.get(nodeId); + const nodeData = '{{ nodeData }}'; + const metadata = nodeData[nodeId]; + + this.isClicking = true; + + if ( + this.drawerManager.activeNodeId && + this.drawerManager.activeNodeId !== nodeId + ) { + this.drawerManager.activeEdges.forEach((edgeId) => { + this.animationManager.animateEdgeWidth( + this.edges, + edgeId, + CONSTANTS.EDGE.DEFAULT_WIDTH, + 200, + ); + }); + this.triggeredByHighlighter.clear(); + } + + const connectedEdges = this.network.getConnectedEdges(nodeId); + this.drawerManager.setActiveNode(nodeId, connectedEdges); + this.triggeredByHighlighter.setActiveDrawer(nodeId, connectedEdges); + + setTimeout(() => { + connectedEdges.forEach((edgeId) => { + this.animationManager.animateEdgeWidth(this.edges, edgeId, 5, 200); + }); + }, 15); + + this.drawerManager.open(nodeId, metadata); + } + + setupControls() { + const ideSelector = document.getElementById("ide-selector"); + const savedIDE = localStorage.getItem("preferred_ide") || "auto"; + ideSelector.value = savedIDE; + ideSelector.addEventListener("change", function () { + localStorage.setItem("preferred_ide", this.value); + }); + + document.getElementById("zoom-in").addEventListener("click", () => { + const scale = this.network.getScale(); + this.network.moveTo({ + scale: scale * 1.2, + animation: { duration: 200, easingFunction: "easeInOutQuad" }, + }); + }); + + document.getElementById("zoom-out").addEventListener("click", () => { + const scale = this.network.getScale(); + this.network.moveTo({ + scale: scale * 0.8, + animation: { duration: 200, easingFunction: "easeInOutQuad" }, + }); + }); + + document.getElementById("fit").addEventListener("click", () => { + this.network.fit({ + animation: { duration: 300, easingFunction: "easeInOutQuad" }, + }); + }); + + this.setupExportControls(); + } + + setupExportControls() { + document.getElementById("export-png").addEventListener("click", () => { + const script = document.createElement("script"); + script.src = + "https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"; + script.onload = () => { + html2canvas(document.getElementById("network-container")).then( + (canvas) => { + const link = document.createElement("a"); + link.download = "flow_dag.png"; + link.href = canvas.toDataURL(); + link.click(); + }, + ); + }; + document.head.appendChild(script); + }); + + document.getElementById("export-pdf").addEventListener("click", () => { + const script1 = document.createElement("script"); + script1.src = + "https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"; + script1.onload = () => { + const script2 = document.createElement("script"); + script2.src = + "https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"; + script2.onload = () => { + html2canvas(document.getElementById("network-container")).then( + (canvas) => { + const imgData = canvas.toDataURL("image/png"); + const { jsPDF } = window.jspdf; + const pdf = new jsPDF({ + orientation: + canvas.width > canvas.height ? "landscape" : "portrait", + unit: "px", + format: [canvas.width, canvas.height], + }); + pdf.addImage(imgData, "PNG", 0, 0, canvas.width, canvas.height); + pdf.save("flow_dag.pdf"); + }, + ); + }; + document.head.appendChild(script2); + }; + document.head.appendChild(script1); + }); + + document.getElementById("export-json").addEventListener("click", () => { + const dagData = '{{ dagData }}'; + const dataStr = JSON.stringify(dagData, null, 2); + const blob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.download = "flow_dag.json"; + link.href = url; + link.click(); + URL.revokeObjectURL(url); + }); + } + + setupTheme() { + const themeToggle = document.getElementById("theme-toggle"); + const htmlElement = document.documentElement; + + const updateEdgeColors = () => { + const orEdgeColor = getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim(); + + this.edges.forEach((edge) => { + let edgeColor; + if (edge.dashes || edge.label === "AND") { + edgeColor = "{{ CREWAI_ORANGE }}"; + } else { + edgeColor = orEdgeColor; + } + + const updateData = { + id: edge.id, + color: { + color: edgeColor, + highlight: edgeColor, + }, + font: { + color: "transparent", + background: "transparent", + }, + }; + + this.edges.update(updateData); + }); + + this.network.redraw(); + }; + + const savedTheme = localStorage.getItem("theme") || "light"; + if (savedTheme === "dark") { + htmlElement.setAttribute("data-theme", "dark"); + themeToggle.textContent = "☀️"; + themeToggle.title = "Toggle Light Mode"; + setTimeout(updateEdgeColors, 0); + } else { + setTimeout(updateEdgeColors, 0); + } + + themeToggle.addEventListener("click", () => { + const currentTheme = htmlElement.getAttribute("data-theme"); + const newTheme = currentTheme === "dark" ? "light" : "dark"; + + if (newTheme === "dark") { + htmlElement.setAttribute("data-theme", "dark"); + themeToggle.textContent = "☀️"; + themeToggle.title = "Toggle Light Mode"; + } else { + htmlElement.removeAttribute("data-theme"); + themeToggle.textContent = "🌙"; + themeToggle.title = "Toggle Dark Mode"; + } + + localStorage.setItem("theme", newTheme); + setTimeout(updateEdgeColors, 50); + }); + } +} + +// ============================================================================ +// Application Entry Point +// ============================================================================ + +(async () => { + const networkManager = new NetworkManager(); + await networkManager.initialize(); +})(); diff --git a/lib/crewai/src/crewai/flow/visualization/assets/interactive_flow.html.j2 b/lib/crewai/src/crewai/flow/visualization/assets/interactive_flow.html.j2 index dcec5e2c4..2f374f4bc 100644 --- a/lib/crewai/src/crewai/flow/visualization/assets/interactive_flow.html.j2 +++ b/lib/crewai/src/crewai/flow/visualization/assets/interactive_flow.html.j2 @@ -66,19 +66,19 @@
Edge Types
- + Router Paths
- - + + OR Conditions
- + AND Conditions
diff --git a/lib/crewai/src/crewai/flow/visualization/assets/style.css b/lib/crewai/src/crewai/flow/visualization/assets/style.css index 1bb55825a..724ec5cbb 100644 --- a/lib/crewai/src/crewai/flow/visualization/assets/style.css +++ b/lib/crewai/src/crewai/flow/visualization/assets/style.css @@ -12,6 +12,7 @@ --shadow-strong: rgba(0, 0, 0, 0.15); --edge-label-text: '{{ GRAY }}'; --edge-label-bg: rgba(255, 255, 255, 0.8); + --edge-or-color: #000000; } [data-theme="dark"] { @@ -28,6 +29,7 @@ --shadow-strong: rgba(0, 0, 0, 0.5); --edge-label-text: #c9d1d9; --edge-label-bg: rgba(22, 27, 34, 0.9); + --edge-or-color: #ffffff; } @keyframes dash { diff --git a/lib/crewai/src/crewai/flow/visualization/builder.py b/lib/crewai/src/crewai/flow/visualization/builder.py index 861cbe42a..8a7ffece1 100644 --- a/lib/crewai/src/crewai/flow/visualization/builder.py +++ b/lib/crewai/src/crewai/flow/visualization/builder.py @@ -1,14 +1,15 @@ """Flow structure builder for analyzing Flow execution.""" from __future__ import annotations + from collections import defaultdict import inspect from typing import TYPE_CHECKING, Any -from crewai.flow.constants import OR_CONDITION +from crewai.flow.constants import AND_CONDITION, OR_CONDITION +from crewai.flow.flow_wrappers import FlowCondition from crewai.flow.types import FlowMethodName from crewai.flow.utils import ( - _extract_all_methods_recursive, is_flow_condition_dict, is_simple_flow_condition, ) @@ -21,7 +22,7 @@ if TYPE_CHECKING: def _extract_direct_or_triggers( - condition: str | dict[str, Any] | list[Any], + condition: str | dict[str, Any] | list[Any] | FlowCondition, ) -> list[str]: """Extract direct OR-level trigger strings from a condition. @@ -43,16 +44,15 @@ def _extract_direct_or_triggers( if isinstance(condition, str): return [condition] if isinstance(condition, dict): - cond_type = condition.get("type", "OR") + cond_type = condition.get("type", OR_CONDITION) conditions_list = condition.get("conditions", []) - if cond_type == "OR": + if cond_type == OR_CONDITION: strings = [] for sub_cond in conditions_list: strings.extend(_extract_direct_or_triggers(sub_cond)) return strings - else: - return [] + return [] if isinstance(condition, list): strings = [] for item in condition: @@ -64,7 +64,7 @@ def _extract_direct_or_triggers( def _extract_all_trigger_names( - condition: str | dict[str, Any] | list[Any], + condition: str | dict[str, Any] | list[Any] | FlowCondition, ) -> list[str]: """Extract ALL trigger names from a condition for display purposes. @@ -101,6 +101,76 @@ def _extract_all_trigger_names( return [] +def _create_edges_from_condition( + condition: str | dict[str, Any] | list[Any] | FlowCondition, + target: str, + nodes: dict[str, NodeMetadata], +) -> list[StructureEdge]: + """Create edges from a condition tree, preserving AND/OR semantics. + + This function recursively processes the condition tree and creates edges + with the appropriate condition_type for each trigger. + + For AND conditions, all triggers get edges with condition_type="AND". + For OR conditions, triggers get edges with condition_type="OR". + + Args: + condition: The condition tree (string, dict, or list). + target: The target node name. + nodes: Dictionary of all nodes for validation. + + Returns: + List of StructureEdge objects representing the condition. + """ + edges: list[StructureEdge] = [] + + if isinstance(condition, str): + if condition in nodes: + edges.append( + StructureEdge( + source=condition, + target=target, + condition_type=OR_CONDITION, + is_router_path=False, + ) + ) + elif callable(condition) and hasattr(condition, "__name__"): + method_name = condition.__name__ + if method_name in nodes: + edges.append( + StructureEdge( + source=method_name, + target=target, + condition_type=OR_CONDITION, + is_router_path=False, + ) + ) + elif isinstance(condition, dict): + cond_type = condition.get("type", OR_CONDITION) + conditions_list = condition.get("conditions", []) + + if cond_type == AND_CONDITION: + triggers = _extract_all_trigger_names(condition) + edges.extend( + StructureEdge( + source=trigger, + target=target, + condition_type=AND_CONDITION, + is_router_path=False, + ) + for trigger in triggers + if trigger in nodes + ) + else: + for sub_cond in conditions_list: + edges.extend(_create_edges_from_condition(sub_cond, target, nodes)) + elif isinstance(condition, list): + for item in condition: + edges.extend(_create_edges_from_condition(item, target, nodes)) + + return edges + + def build_flow_structure(flow: Flow[Any]) -> FlowStructure: """Build a structure representation of a Flow's execution. @@ -228,28 +298,22 @@ def build_flow_structure(flow: Flow[Any]) -> FlowStructure: nodes[method_name] = node_metadata for listener_name, condition_data in flow._listeners.items(): - condition_type: str | None = None - trigger_methods_list: list[str] = [] - if is_simple_flow_condition(condition_data): cond_type, methods = condition_data - condition_type = cond_type - trigger_methods_list = [str(m) for m in methods] - elif is_flow_condition_dict(condition_data): - condition_type = condition_data.get("type", OR_CONDITION) - methods_recursive = _extract_all_methods_recursive(condition_data, flow) - trigger_methods_list = [str(m) for m in methods_recursive] - - edges.extend( - StructureEdge( - source=str(trigger_method), - target=str(listener_name), - condition_type=condition_type, - is_router_path=False, + edges.extend( + StructureEdge( + source=str(trigger_method), + target=str(listener_name), + condition_type=cond_type, + is_router_path=False, + ) + for trigger_method in methods + if str(trigger_method) in nodes + ) + elif is_flow_condition_dict(condition_data): + edges.extend( + _create_edges_from_condition(condition_data, str(listener_name), nodes) ) - for trigger_method in trigger_methods_list - if trigger_method in nodes - ) for router_method_name in router_methods: if router_method_name not in flow._router_paths: @@ -299,76 +363,6 @@ def build_flow_structure(flow: Flow[Any]) -> FlowStructure: ) -def structure_to_dict(structure: FlowStructure) -> dict[str, Any]: - """Convert FlowStructure to plain dictionary for serialization. - - Args: - structure: FlowStructure to convert. - - Returns: - Plain dictionary representation. - """ - return { - "nodes": dict(structure["nodes"]), - "edges": list(structure["edges"]), - "start_methods": list(structure["start_methods"]), - "router_methods": list(structure["router_methods"]), - } - - -def print_structure_summary(structure: FlowStructure) -> str: - """Generate human-readable summary of Flow structure. - - Args: - structure: FlowStructure to summarize. - - Returns: - Formatted string summary. - """ - lines: list[str] = [] - lines.append("Flow Execution Structure") - lines.append("=" * 50) - lines.append(f"Total nodes: {len(structure['nodes'])}") - lines.append(f"Total edges: {len(structure['edges'])}") - lines.append(f"Start methods: {len(structure['start_methods'])}") - lines.append(f"Router methods: {len(structure['router_methods'])}") - lines.append("") - - if structure["start_methods"]: - lines.append("Start Methods:") - for method_name in structure["start_methods"]: - node = structure["nodes"][method_name] - lines.append(f" - {method_name}") - if node.get("condition_type"): - lines.append(f" Condition: {node['condition_type']}") - if node.get("trigger_methods"): - lines.append(f" Triggers on: {', '.join(node['trigger_methods'])}") - lines.append("") - - if structure["router_methods"]: - lines.append("Router Methods:") - for method_name in structure["router_methods"]: - node = structure["nodes"][method_name] - lines.append(f" - {method_name}") - if node.get("router_paths"): - lines.append(f" Paths: {', '.join(node['router_paths'])}") - lines.append("") - - if structure["edges"]: - lines.append("Connections:") - for edge in structure["edges"]: - edge_type = "" - if edge["is_router_path"]: - edge_type = " [Router Path]" - elif edge["condition_type"]: - edge_type = f" [{edge['condition_type']}]" - - lines.append(f" {edge['source']} -> {edge['target']}{edge_type}") - lines.append("") - - return "\n".join(lines) - - def calculate_execution_paths(structure: FlowStructure) -> int: """Calculate number of possible execution paths through the flow. @@ -396,6 +390,15 @@ def calculate_execution_paths(structure: FlowStructure) -> int: return 0 def count_paths_from(node: str, visited: set[str]) -> int: + """Recursively count execution paths from a given node. + + Args: + node: Node name to start counting from. + visited: Set of already visited nodes to prevent cycles. + + Returns: + Number of execution paths from this node to terminal nodes. + """ if node in terminal_nodes: return 1 diff --git a/lib/crewai/tests/test_flow_visualization.py b/lib/crewai/tests/test_flow_visualization.py index 1fd78340e..dad192529 100644 --- a/lib/crewai/tests/test_flow_visualization.py +++ b/lib/crewai/tests/test_flow_visualization.py @@ -10,8 +10,6 @@ import pytest from crewai.flow.flow import Flow, and_, listen, or_, router, start from crewai.flow.visualization import ( build_flow_structure, - print_structure_summary, - structure_to_dict, visualize_flow_structure, ) @@ -144,65 +142,6 @@ def test_build_flow_structure_with_and_or_conditions(): assert len(or_edges) == 2 -def test_structure_to_dict(): - """Test converting flow structure to dictionary format.""" - flow = SimpleFlow() - structure = build_flow_structure(flow) - dag_dict = structure_to_dict(structure) - - assert "nodes" in dag_dict - assert "edges" in dag_dict - assert "start_methods" in dag_dict - assert "router_methods" in dag_dict - - assert "begin" in dag_dict["nodes"] - assert "process" in dag_dict["nodes"] - - begin_node = dag_dict["nodes"]["begin"] - assert begin_node["type"] == "start" - assert "method_signature" in begin_node - assert "source_code" in begin_node - - assert len(dag_dict["edges"]) == 1 - edge = dag_dict["edges"][0] - assert "source" in edge - assert "target" in edge - assert "condition_type" in edge - assert "is_router_path" in edge - - -def test_structure_to_dict_with_router(): - """Test dictionary conversion for flow with router.""" - flow = RouterFlow() - structure = build_flow_structure(flow) - dag_dict = structure_to_dict(structure) - - decide_node = dag_dict["nodes"]["decide"] - assert decide_node["type"] == "router" - assert decide_node["is_router"] is True - - if "router_paths" in decide_node: - assert len(decide_node["router_paths"]) >= 1 - - router_edges = [edge for edge in dag_dict["edges"] if edge["is_router_path"]] - assert len(router_edges) >= 1 - - -def test_structure_to_dict_with_complex_conditions(): - """Test dictionary conversion for flow with complex conditions.""" - flow = ComplexFlow() - structure = build_flow_structure(flow) - dag_dict = structure_to_dict(structure) - - converge_and_node = dag_dict["nodes"]["converge_and"] - assert converge_and_node["condition_type"] == "AND" - assert "trigger_condition" in converge_and_node - assert converge_and_node["trigger_condition"]["type"] == "AND" - - converge_or_node = dag_dict["nodes"]["converge_or"] - assert converge_or_node["condition_type"] == "OR" - - def test_visualize_flow_structure_creates_html(): """Test that visualization generates valid HTML file.""" flow = SimpleFlow() @@ -243,7 +182,7 @@ def test_visualize_flow_structure_creates_assets(): js_content = js_file.read_text(encoding="utf-8") assert len(js_content) > 0 - assert "var nodes" in js_content or "const nodes" in js_content + assert "NetworkManager" in js_content def test_visualize_flow_structure_json_data(): @@ -268,22 +207,6 @@ def test_visualize_flow_structure_json_data(): assert "path_b" in js_content -def test_print_structure_summary(): - """Test printing flow structure summary.""" - flow = ComplexFlow() - structure = build_flow_structure(flow) - - output = print_structure_summary(structure) - - assert "Total nodes:" in output - assert "Total edges:" in output - assert "Start methods:" in output - assert "Router methods:" in output - - assert "start_a" in output - assert "start_b" in output - - def test_node_metadata_includes_source_info(): """Test that nodes include source code and line number information.""" flow = SimpleFlow() @@ -364,8 +287,7 @@ def test_visualization_handles_special_characters(): assert len(structure["nodes"]) == 2 - dag_dict = structure_to_dict(structure) - json_str = json.dumps(dag_dict) + json_str = json.dumps(structure) assert json_str is not None assert "method_with_underscore" in json_str assert "another_method_123" in json_str @@ -390,7 +312,6 @@ def test_topological_path_counting(): """Test that topological path counting is accurate.""" flow = ComplexFlow() structure = build_flow_structure(flow) - dag_dict = structure_to_dict(structure) assert len(structure["nodes"]) > 0 assert len(structure["edges"]) > 0 diff --git a/pyproject.toml b/pyproject.toml index cee6a04e0..f99ed190b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,13 +117,7 @@ warn_return_any = true show_error_codes = true warn_unused_ignores = true python_version = "3.12" -exclude = [ - "lib/crewai/src/crewai/cli/templates", - "lib/crewai/tests/", - # crewai-tools - "lib/crewai-tools/tests/", - "lib/crewai/src/crewai/experimental/a2a" -] +exclude = "(?x)(^lib/crewai/src/crewai/cli/templates/ | ^lib/crewai/tests/ | ^lib/crewai-tools/tests/)" plugins = ["pydantic.mypy", "crewai.mypy"]