diff --git a/lib/crewai/src/crewai/flow/flow.py b/lib/crewai/src/crewai/flow/flow.py index 187ff482c..42b36eb1f 100644 --- a/lib/crewai/src/crewai/flow/flow.py +++ b/lib/crewai/src/crewai/flow/flow.py @@ -428,6 +428,8 @@ class FlowMeta(type): possible_returns = get_possible_return_constants(attr_value) if possible_returns: router_paths[attr_name] = possible_returns + else: + router_paths[attr_name] = [] cls._start_methods = start_methods # type: ignore[attr-defined] cls._listeners = listeners # type: ignore[attr-defined] diff --git a/lib/crewai/src/crewai/flow/types.py b/lib/crewai/src/crewai/flow/types.py index 819f9b09a..024de41df 100644 --- a/lib/crewai/src/crewai/flow/types.py +++ b/lib/crewai/src/crewai/flow/types.py @@ -21,6 +21,7 @@ P = ParamSpec("P") R = TypeVar("R", covariant=True) FlowMethodName = NewType("FlowMethodName", str) +FlowRouteName = NewType("FlowRouteName", str) PendingListenerKey = NewType( "PendingListenerKey", Annotated[str, "nested flow conditions use 'listener_name:object_id'"], diff --git a/lib/crewai/src/crewai/flow/utils.py b/lib/crewai/src/crewai/flow/utils.py index bad9d9670..55db5d9c5 100644 --- a/lib/crewai/src/crewai/flow/utils.py +++ b/lib/crewai/src/crewai/flow/utils.py @@ -19,11 +19,11 @@ import ast from collections import defaultdict, deque import inspect import textwrap -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from typing_extensions import TypeIs -from crewai.flow.constants import OR_CONDITION, AND_CONDITION +from crewai.flow.constants import AND_CONDITION, OR_CONDITION from crewai.flow.flow_wrappers import ( FlowCondition, FlowConditions, @@ -33,6 +33,7 @@ from crewai.flow.flow_wrappers import ( from crewai.flow.types import FlowMethodCallable, FlowMethodName from crewai.utilities.printer import Printer + if TYPE_CHECKING: from crewai.flow.flow import Flow @@ -40,6 +41,22 @@ _printer = Printer() def get_possible_return_constants(function: Any) -> list[str] | None: + """Extract possible string return values from a function using AST parsing. + + This function analyzes the source code of a router method to identify + all possible string values it might return. It handles: + - Direct string literals: return "value" + - Variable assignments: x = "value"; return x + - Dictionary lookups: d = {"k": "v"}; return d[key] + - Conditional returns: return "a" if cond else "b" + - State attributes: return self.state.attr (infers from class context) + + Args: + function: The function to analyze. + + Returns: + List of possible string return values, or None if analysis fails. + """ try: source = inspect.getsource(function) except OSError: @@ -82,6 +99,7 @@ def get_possible_return_constants(function: Any) -> list[str] | None: return_values: set[str] = set() dict_definitions: dict[str, list[str]] = {} variable_values: dict[str, list[str]] = {} + state_attribute_values: dict[str, list[str]] = {} def extract_string_constants(node: ast.expr) -> list[str]: """Recursively extract all string constants from an AST node.""" @@ -91,6 +109,17 @@ def get_possible_return_constants(function: Any) -> list[str] | None: elif isinstance(node, ast.IfExp): strings.extend(extract_string_constants(node.body)) strings.extend(extract_string_constants(node.orelse)) + elif isinstance(node, ast.Call): + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "get" + and len(node.args) >= 2 + ): + default_arg = node.args[1] + if isinstance(default_arg, ast.Constant) and isinstance( + default_arg.value, str + ): + strings.append(default_arg.value) return strings class VariableAssignmentVisitor(ast.NodeVisitor): @@ -124,6 +153,22 @@ def get_possible_return_constants(function: Any) -> list[str] | None: self.generic_visit(node) + def get_attribute_chain(node: ast.expr) -> str | None: + """Extract the full attribute chain from an AST node. + + Examples: + self.state.run_type -> "self.state.run_type" + x.y.z -> "x.y.z" + simple_var -> "simple_var" + """ + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + base = get_attribute_chain(node.value) + if base: + return f"{base}.{node.attr}" + return None + class ReturnVisitor(ast.NodeVisitor): def visit_Return(self, node: ast.Return) -> None: if ( @@ -139,21 +184,94 @@ def get_possible_return_constants(function: Any) -> list[str] | None: for v in dict_definitions[var_name_dict]: return_values.add(v) elif node.value: - var_name_ret: str | None = None - if isinstance(node.value, ast.Name): - var_name_ret = node.value.id - elif isinstance(node.value, ast.Attribute): - var_name_ret = f"{node.value.value.id if isinstance(node.value.value, ast.Name) else '_'}.{node.value.attr}" + var_name_ret = get_attribute_chain(node.value) if var_name_ret and var_name_ret in variable_values: for v in variable_values[var_name_ret]: return_values.add(v) + elif var_name_ret and var_name_ret in state_attribute_values: + for v in state_attribute_values[var_name_ret]: + return_values.add(v) self.generic_visit(node) def visit_If(self, node: ast.If) -> None: self.generic_visit(node) + # Try to get the class context to infer state attribute values + try: + if hasattr(function, "__self__"): + # Method is bound, get the class + class_obj = function.__self__.__class__ + elif hasattr(function, "__qualname__") and "." in function.__qualname__: + # Method is unbound but we can try to get class from module + class_name = function.__qualname__.rsplit(".", 1)[0] + if hasattr(function, "__globals__"): + class_obj = function.__globals__.get(class_name) + else: + class_obj = None + else: + class_obj = None + + if class_obj is not None: + try: + class_source = inspect.getsource(class_obj) + class_source = textwrap.dedent(class_source) + class_ast = ast.parse(class_source) + + # Look for comparisons and assignments involving state attributes + class StateAttributeVisitor(ast.NodeVisitor): + def visit_Compare(self, node: ast.Compare) -> None: + """Find comparisons like: self.state.attr == "value" """ + left_attr = get_attribute_chain(node.left) + + if left_attr: + for comparator in node.comparators: + if isinstance(comparator, ast.Constant) and isinstance( + comparator.value, str + ): + if left_attr not in state_attribute_values: + state_attribute_values[left_attr] = [] + if ( + comparator.value + not in state_attribute_values[left_attr] + ): + state_attribute_values[left_attr].append( + comparator.value + ) + + # Also check right side + for comparator in node.comparators: + right_attr = get_attribute_chain(comparator) + if ( + right_attr + and isinstance(node.left, ast.Constant) + and isinstance(node.left.value, str) + ): + if right_attr not in state_attribute_values: + state_attribute_values[right_attr] = [] + if ( + node.left.value + not in state_attribute_values[right_attr] + ): + state_attribute_values[right_attr].append( + node.left.value + ) + + self.generic_visit(node) + + StateAttributeVisitor().visit(class_ast) + except Exception as e: + _printer.print( + f"Could not analyze class context for {function.__name__}: {e}", + color="yellow", + ) + except Exception as e: + _printer.print( + f"Could not introspect class for {function.__name__}: {e}", + color="yellow", + ) + VariableAssignmentVisitor().visit(code_ast) ReturnVisitor().visit(code_ast) diff --git a/lib/crewai/src/crewai/flow/visualization/assets/interactive.js b/lib/crewai/src/crewai/flow/visualization/assets/interactive.js index 8d4fe9bd9..10788727f 100644 --- a/lib/crewai/src/crewai/flow/visualization/assets/interactive.js +++ b/lib/crewai/src/crewai/flow/visualization/assets/interactive.js @@ -1,24 +1,16 @@ "use strict"; -/** - * Flow Visualization Interactive Script - * Handles the interactive network visualization for CrewAI flows - */ - -// ============================================================================ -// Constants -// ============================================================================ - const CONSTANTS = { NODE: { - BASE_WIDTH: 200, - BASE_HEIGHT: 60, + BASE_WIDTH: 220, + BASE_HEIGHT: 100, BORDER_RADIUS: 20, TEXT_SIZE: 13, - TEXT_PADDING: 8, + TEXT_PADDING: 16, TEXT_BG_RADIUS: 6, - HOVER_SCALE: 1.04, - PRESSED_SCALE: 0.98, + HOVER_SCALE: 1.00, + PRESSED_SCALE: 1.16, + SELECTED_SCALE: 1.05, }, EDGE: { DEFAULT_WIDTH: 2, @@ -32,26 +24,18 @@ const CONSTANTS = { 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, + STABILIZATION_ITERATIONS: 300, + NODE_DISTANCE: 225, + SPRING_LENGTH: 100, + LEVEL_SEPARATION: 150, + NODE_SPACING: 350, 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"); @@ -62,9 +46,6 @@ function loadVisCDN() { }); } -/** - * Draws a rounded rectangle on a canvas context - */ function drawRoundedRect(ctx, x, y, width, height, radius) { ctx.beginPath(); ctx.moveTo(x + radius, y); @@ -79,50 +60,54 @@ function drawRoundedRect(ctx, x, y, width, height, radius) { ctx.closePath(); } -/** - * Highlights Python code using Prism - */ function highlightPython(code) { return Prism.highlight(code, Prism.languages.python, "python"); } -// ============================================================================ -// Node Renderer -// ============================================================================ - class NodeRenderer { constructor(nodes, networkManager) { this.nodes = nodes; this.networkManager = networkManager; + this.nodeScales = new Map(); + this.scaleAnimations = new Map(); + this.hoverGlowIntensities = new Map(); + this.glowAnimations = new Map(); + this.colorCache = new Map(); + this.tempCanvas = document.createElement('canvas'); + this.tempCanvas.width = 1; + this.tempCanvas.height = 1; + this.tempCtx = this.tempCanvas.getContext('2d'); } - render({ ctx, id, x, y, state, style, label }) { + render({ ctx, id, x, y }) { const node = this.nodes.get(id); - if (!node || !node.nodeStyle) return {}; + if (!node?.nodeStyle) return {}; const scale = this.getNodeScale(id); - const isActiveDrawer = - this.networkManager.drawerManager?.activeNodeId === id; + const isActiveDrawer = this.networkManager.drawerManager?.activeNodeId === id; + const isHovered = this.networkManager.hoveredNodeId === id && !isActiveDrawer; const nodeStyle = node.nodeStyle; - const width = CONSTANTS.NODE.BASE_WIDTH * scale; - const height = CONSTANTS.NODE.BASE_HEIGHT * scale; + + // Manage hover glow intensity animation + const glowIntensity = this.getHoverGlowIntensity(id, isHovered); + + ctx.font = `500 ${CONSTANTS.NODE.TEXT_SIZE * scale}px 'JetBrains Mono', 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace`; + 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 width = textWidth + textPadding * 5; + const height = textHeight + textPadding * 2.5; 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); + const opacity = node.opacity !== undefined ? node.opacity : 1.0; + this.applyShadow(ctx, node, glowIntensity, opacity); + ctx.globalAlpha = opacity; + this.drawNodeShape(ctx, x, y, width, height, scale, nodeStyle, opacity, node); + this.drawNodeText(ctx, x, y, scale, nodeStyle, opacity, node); ctx.restore(); }, nodeDimensions: { width, height }, @@ -130,54 +115,378 @@ class NodeRenderer { } getNodeScale(id) { - if (this.networkManager.pressedNodeId === id) { - return CONSTANTS.NODE.PRESSED_SCALE; + const isActiveDrawer = this.networkManager.drawerManager?.activeNodeId === id; + + let targetScale = 1.0; + if (isActiveDrawer) { + targetScale = CONSTANTS.NODE.SELECTED_SCALE; + } else if (this.networkManager.pressedNodeId === id) { + targetScale = CONSTANTS.NODE.PRESSED_SCALE; } else if (this.networkManager.hoveredNodeId === id) { - return CONSTANTS.NODE.HOVER_SCALE; + targetScale = CONSTANTS.NODE.HOVER_SCALE; } - return 1.0; + + const currentScale = this.nodeScales.get(id) ?? 1.0; + const runningAnimation = this.scaleAnimations.get(id); + const animationTarget = runningAnimation?.targetScale; + + if (Math.abs(targetScale - currentScale) > 0.001) { + if (runningAnimation && animationTarget !== targetScale) { + cancelAnimationFrame(runningAnimation.frameId); + this.scaleAnimations.delete(id); + } + + if (!this.scaleAnimations.has(id)) { + this.animateScale(id, currentScale, targetScale); + } + } + + return currentScale; } - applyNodeOpacity(ctx, node) { - const nodeOpacity = node.opacity !== undefined ? node.opacity : 1.0; - ctx.globalAlpha = nodeOpacity; + animateScale(id, startScale, targetScale) { + const startTime = performance.now(); + const duration = 150; + + const animate = () => { + const elapsed = performance.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress); + + const currentScale = startScale + (targetScale - startScale) * eased; + this.nodeScales.set(id, currentScale); + + if (progress < 1) { + const frameId = requestAnimationFrame(animate); + this.scaleAnimations.set(id, { frameId, targetScale }); + } else { + this.scaleAnimations.delete(id); + this.nodeScales.set(id, targetScale); + } + + this.networkManager.network?.redraw(); + }; + + animate(); } - applyShadow(ctx, node, isActiveDrawer) { - if (node.shadow && node.shadow.enabled) { + getHoverGlowIntensity(id, isHovered) { + const targetIntensity = isHovered ? 1.0 : 0.0; + const currentIntensity = this.hoverGlowIntensities.get(id) ?? 0.0; + const runningAnimation = this.glowAnimations.get(id); + const animationTarget = runningAnimation?.targetIntensity; + + if (Math.abs(targetIntensity - currentIntensity) > 0.001) { + if (runningAnimation && animationTarget !== targetIntensity) { + cancelAnimationFrame(runningAnimation.frameId); + this.glowAnimations.delete(id); + } + + if (!this.glowAnimations.has(id)) { + this.animateGlowIntensity(id, currentIntensity, targetIntensity); + } + } + + return currentIntensity; + } + + animateGlowIntensity(id, startIntensity, targetIntensity) { + const startTime = performance.now(); + const duration = 200; + + const animate = () => { + const elapsed = performance.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress); + + const currentIntensity = startIntensity + (targetIntensity - startIntensity) * eased; + this.hoverGlowIntensities.set(id, currentIntensity); + + if (progress < 1) { + const frameId = requestAnimationFrame(animate); + this.glowAnimations.set(id, { frameId, targetIntensity }); + } else { + this.glowAnimations.delete(id); + this.hoverGlowIntensities.set(id, targetIntensity); + } + + this.networkManager.network?.redraw(); + }; + + animate(); + } + + applyShadow(ctx, node, glowIntensity = 0, nodeOpacity = 1.0) { + if (glowIntensity > 0.001) { + // Save current alpha and apply glow at full opacity + const currentAlpha = ctx.globalAlpha; + ctx.globalAlpha = 1.0; + + const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark'; + + // Use CrewAI orange for hover glow in both themes + const glowR = 255; + const glowG = 90; + const glowB = 80; + const blurRadius = isDarkMode ? 20 : 35; + + // Scale glow intensity proportionally based on node opacity + // When node is inactive (opacity < 1.0), reduce glow intensity accordingly + const scaledGlowIntensity = glowIntensity * nodeOpacity; + + const glowColor = `rgba(${glowR}, ${glowG}, ${glowB}, ${scaledGlowIntensity})`; + + ctx.shadowColor = glowColor; + ctx.shadowBlur = blurRadius * scaledGlowIntensity; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + + // Restore the original alpha + ctx.globalAlpha = currentAlpha; + return; + } + + if (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; + return; } + + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; } - drawNodeShape(ctx, x, y, width, height, scale, nodeStyle, isActiveDrawer) { + resolveCSSVariable(color) { + if (color?.startsWith('var(')) { + const varName = color.match(/var\((--[^)]+)\)/)?.[1]; + if (varName) { + return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); + } + } + return color; + } + + + parseColor(color) { + const cacheKey = `parse_${color}`; + if (this.colorCache.has(cacheKey)) { + return this.colorCache.get(cacheKey); + } + + this.tempCtx.fillStyle = color; + this.tempCtx.fillRect(0, 0, 1, 1); + const [r, g, b] = this.tempCtx.getImageData(0, 0, 1, 1).data; + + const result = { r, g, b }; + this.colorCache.set(cacheKey, result); + return result; + } + + darkenColor(color, opacity) { + if (opacity >= 0.9) return color; + + const { r, g, b } = this.parseColor(color); + + const t = (opacity - 0.85) / (1.0 - 0.85); + const normalizedT = Math.max(0, Math.min(1, t)); + + const minBrightness = 0.4; + const brightness = minBrightness + (1.0 - minBrightness) * normalizedT; + + const newR = Math.floor(r * brightness); + const newG = Math.floor(g * brightness); + const newB = Math.floor(b * brightness); + + return `rgb(${newR}, ${newG}, ${newB})`; + } + + desaturateColor(color, opacity) { + if (opacity >= 0.9) return color; + + const { r, g, b } = this.parseColor(color); + + // Convert to HSL to adjust saturation and lightness + const max = Math.max(r, g, b) / 255; + const min = Math.min(r, g, b) / 255; + const l = (max + min) / 2; + let h = 0, s = 0; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + if (max === r / 255) { + h = ((g / 255 - b / 255) / d + (g < b ? 6 : 0)) / 6; + } else if (max === g / 255) { + h = ((b / 255 - r / 255) / d + 2) / 6; + } else { + h = ((r / 255 - g / 255) / d + 4) / 6; + } + } + + // Reduce saturation and lightness by 40% + s = s * 0.6; + const newL = l * 0.6; + + // Convert back to RGB + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + + let newR, newG, newB; + if (s === 0) { + newR = newG = newB = Math.floor(newL * 255); + } else { + const q = newL < 0.5 ? newL * (1 + s) : newL + s - newL * s; + const p = 2 * newL - q; + newR = Math.floor(hue2rgb(p, q, h + 1/3) * 255); + newG = Math.floor(hue2rgb(p, q, h) * 255); + newB = Math.floor(hue2rgb(p, q, h - 1/3) * 255); + } + + return `rgb(${newR}, ${newG}, ${newB})`; + } + + drawNodeShape(ctx, x, y, width, height, scale, nodeStyle, opacity = 1.0, node = null) { const radius = CONSTANTS.NODE.BORDER_RADIUS * scale; const rectX = x - width / 2; const rectY = y - height / 2; - drawRoundedRect(ctx, rectX, rectY, width, height, radius); + const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark'; + const nodeData = '{{ nodeData }}'; + const metadata = node ? nodeData[node.id] : null; + const isStartNode = metadata && metadata.type === 'start'; - ctx.fillStyle = nodeStyle.bgColor; + let nodeColor; + + if (isDarkMode || isStartNode) { + // In dark mode or for start nodes, use the theme color + nodeColor = this.resolveCSSVariable(nodeStyle.bgColor); + } else { + // In light mode for non-start nodes, use white + nodeColor = 'rgb(255, 255, 255)'; + } + + // Parse the base color to get RGB values + let { r, g, b } = this.parseColor(nodeColor); + + // For inactive nodes, check if node is in highlighted list + // If drawer is open and node is not highlighted, it's inactive + const isDrawerOpen = this.networkManager.drawerManager?.activeNodeId !== null; + const isHighlighted = this.networkManager.triggeredByHighlighter?.highlightedNodes?.includes(node?.id); + const isActiveNode = this.networkManager.drawerManager?.activeNodeId === node?.id; + const hasHighlightedNodes = this.networkManager.triggeredByHighlighter?.highlightedNodes?.length > 0; + + // Non-prominent nodes: drawer is open, has highlighted nodes, but this node is not highlighted or active + const isNonProminent = isDrawerOpen && hasHighlightedNodes && !isHighlighted && !isActiveNode; + + // Inactive nodes: drawer is open but no highlighted nodes, and this node is not active + const isInactive = isDrawerOpen && !hasHighlightedNodes && !isActiveNode; + + if (isNonProminent || isInactive) { + // Make non-prominent and inactive nodes a darker version of the normal active color + const darkenFactor = 0.4; // Keep 40% of original color (darken by 60%) + r = Math.round(r * darkenFactor); + g = Math.round(g * darkenFactor); + b = Math.round(b * darkenFactor); + } + + // Draw base shape with frosted glass effect + ctx.beginPath(); + drawRoundedRect(ctx, rectX, rectY, width, height, radius); + // Use full opacity for all nodes + const glassOpacity = 1.0; + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${glassOpacity})`; ctx.fill(); + // Calculate text label area to exclude from frosted overlay + const textPadding = CONSTANTS.NODE.TEXT_PADDING * scale; + const textBgRadius = CONSTANTS.NODE.TEXT_BG_RADIUS * scale; + + ctx.font = `500 ${CONSTANTS.NODE.TEXT_SIZE * scale}px 'JetBrains Mono', 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace`; + const textMetrics = ctx.measureText(nodeStyle.name); + const textWidth = textMetrics.width; + const textHeight = CONSTANTS.NODE.TEXT_SIZE * scale; + const textBgWidth = textWidth + textPadding * 2; + const textBgHeight = textHeight + textPadding * 0.75; + const textBgX = x - textBgWidth / 2; + const textBgY = y - textBgHeight / 2; + + // Add frosted overlay (clipped to node shape, excluding text area) + ctx.save(); + ctx.beginPath(); + drawRoundedRect(ctx, rectX, rectY, width, height, radius); + ctx.clip(); + + // Cut out the text label area from the frosted overlay + ctx.beginPath(); + drawRoundedRect(ctx, rectX, rectY, width, height, radius); + drawRoundedRect(ctx, textBgX, textBgY, textBgWidth, textBgHeight, textBgRadius); + ctx.clip('evenodd'); + + // For inactive nodes, use stronger absolute frost values + // For active nodes, scale frost with opacity + let frostTop, frostMid, frostBottom; + if (isInactive) { + // Inactive nodes get stronger, more consistent frost + frostTop = 0.45; + frostMid = 0.35; + frostBottom = 0.25; + } else { + // Active nodes get opacity-scaled frost + frostTop = opacity * 0.3; + frostMid = opacity * 0.2; + frostBottom = opacity * 0.15; + } + + // Stronger white overlay for frosted appearance + const frostOverlay = ctx.createLinearGradient(rectX, rectY, rectX, rectY + height); + frostOverlay.addColorStop(0, `rgba(255, 255, 255, ${frostTop})`); + frostOverlay.addColorStop(0.5, `rgba(255, 255, 255, ${frostMid})`); + frostOverlay.addColorStop(1, `rgba(255, 255, 255, ${frostBottom})`); + + ctx.fillStyle = frostOverlay; + ctx.fillRect(rectX, rectY, width, height); + ctx.restore(); + ctx.shadowColor = "transparent"; ctx.shadowBlur = 0; - ctx.strokeStyle = isActiveDrawer - ? "{{ CREWAI_ORANGE }}" - : nodeStyle.borderColor; + // Draw border at full opacity (desaturated for inactive nodes) + // Reset globalAlpha to 1.0 so the border is always fully visible + ctx.save(); + ctx.globalAlpha = 1.0; + ctx.beginPath(); + drawRoundedRect(ctx, rectX, rectY, width, height, radius); + const borderColor = this.resolveCSSVariable(nodeStyle.borderColor); + let finalBorderColor = this.desaturateColor(borderColor, opacity); + + // Darken border color for non-prominent and inactive nodes + if (isNonProminent || isInactive) { + const borderRGB = this.parseColor(finalBorderColor); + const darkenFactor = 0.4; + const darkenedR = Math.round(borderRGB.r * darkenFactor); + const darkenedG = Math.round(borderRGB.g * darkenFactor); + const darkenedB = Math.round(borderRGB.b * darkenFactor); + finalBorderColor = `rgb(${darkenedR}, ${darkenedG}, ${darkenedB})`; + } + + ctx.strokeStyle = finalBorderColor; ctx.lineWidth = nodeStyle.borderWidth * scale; ctx.stroke(); + ctx.restore(); } - drawNodeText(ctx, x, y, scale, nodeStyle) { + drawNodeText(ctx, x, y, scale, nodeStyle, opacity = 1.0, node = null) { ctx.font = `500 ${CONSTANTS.NODE.TEXT_SIZE * scale}px 'JetBrains Mono', 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; @@ -188,10 +497,10 @@ class NodeRenderer { const textPadding = CONSTANTS.NODE.TEXT_PADDING * scale; const textBgRadius = CONSTANTS.NODE.TEXT_BG_RADIUS * scale; - const textBgX = x - textWidth / 2 - textPadding; - const textBgY = y - textHeight / 2 - textPadding / 2; const textBgWidth = textWidth + textPadding * 2; - const textBgHeight = textHeight + textPadding; + const textBgHeight = textHeight + textPadding * 0.75; + const textBgX = x - textBgWidth / 2; + const textBgY = y - textBgHeight / 2; drawRoundedRect( ctx, @@ -202,18 +511,71 @@ class NodeRenderer { textBgRadius, ); - ctx.fillStyle = "rgba(255, 255, 255, 0.2)"; + const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark'; + const nodeData = '{{ nodeData }}'; + const metadata = node ? nodeData[node.id] : null; + const isStartNode = metadata && metadata.type === 'start'; + + // Check if this is an inactive or non-prominent node using the same logic as drawNodeShape + const isDrawerOpen = this.networkManager.drawerManager?.activeNodeId !== null; + const isHighlighted = this.networkManager.triggeredByHighlighter?.highlightedNodes?.includes(node?.id); + const isActiveNode = this.networkManager.drawerManager?.activeNodeId === node?.id; + const hasHighlightedNodes = this.networkManager.triggeredByHighlighter?.highlightedNodes?.length > 0; + + const isNonProminent = isDrawerOpen && hasHighlightedNodes && !isHighlighted && !isActiveNode; + const isInactive = isDrawerOpen && !hasHighlightedNodes && !isActiveNode; + + // Get the base node color to darken it for inactive nodes + let nodeColor; + if (isDarkMode || isStartNode) { + nodeColor = this.resolveCSSVariable(nodeStyle.bgColor); + } else { + nodeColor = 'rgb(255, 255, 255)'; + } + const { r, g, b } = this.parseColor(nodeColor); + + let labelBgR = 255, labelBgG = 255, labelBgB = 255; + let labelBgOpacity = 0.2 * opacity; + + if (isNonProminent || isInactive) { + // Darken the base node color for non-prominent and inactive label backgrounds + const darkenFactor = 0.4; + labelBgR = Math.round(r * darkenFactor); + labelBgG = Math.round(g * darkenFactor); + labelBgB = Math.round(b * darkenFactor); + labelBgOpacity = 0.5; + } else if (!isDarkMode && !isStartNode) { + // In light mode for non-start nodes, use gray for active node labels + labelBgR = labelBgG = labelBgB = 128; + labelBgOpacity = 0.25; + } + + ctx.fillStyle = `rgba(${labelBgR}, ${labelBgG}, ${labelBgB}, ${labelBgOpacity})`; ctx.fill(); - ctx.fillStyle = nodeStyle.fontColor; + // For start nodes or dark mode, use theme color; in light mode, use dark text + let fontColor; + if (isDarkMode || isStartNode) { + fontColor = this.resolveCSSVariable(nodeStyle.fontColor); + } else { + fontColor = 'rgb(30, 30, 30)'; + } + + // Darken font color for non-prominent and inactive nodes + if (isNonProminent || isInactive) { + const fontRGB = this.parseColor(fontColor); + const darkenFactor = 0.4; + const darkenedR = Math.round(fontRGB.r * darkenFactor); + const darkenedG = Math.round(fontRGB.g * darkenFactor); + const darkenedB = Math.round(fontRGB.b * darkenFactor); + fontColor = `rgb(${darkenedR}, ${darkenedG}, ${darkenedB})`; + } + + ctx.fillStyle = fontColor; ctx.fillText(nodeStyle.name, x, y); } } -// ============================================================================ -// Animation Manager -// ============================================================================ - class AnimationManager { constructor() { this.animations = new Map(); @@ -265,10 +627,6 @@ class AnimationManager { } } -// ============================================================================ -// Triggered By Highlighter -// ============================================================================ - class TriggeredByHighlighter { constructor(network, nodes, edges, highlightCanvas) { this.network = network; @@ -305,7 +663,6 @@ class TriggeredByHighlighter { this.clear(); if (!this.activeDrawerNodeId || !triggerNodeIds || triggerNodeIds.length === 0) { - console.warn("TriggeredByHighlighter: Missing activeDrawerNodeId or triggerNodeIds"); return; } @@ -333,38 +690,74 @@ class TriggeredByHighlighter { const routerEdges = allEdges.filter( (edge) => edge.from === routerNode && edge.dashes ); + let foundEdge = false; for (const routerEdge of routerEdges) { - if (routerEdge.to === this.activeDrawerNodeId) { + if (routerEdge.label === triggerNodeId) { connectingEdges.push(routerEdge); pathNodes.add(routerNode); - pathNodes.add(this.activeDrawerNodeId); - break; - } + pathNodes.add(routerEdge.to); - const intermediateNode = routerEdge.to; - const pathToActive = allEdges.filter( - (edge) => edge.from === intermediateNode && edge.to === this.activeDrawerNodeId - ); + if (routerEdge.to !== this.activeDrawerNodeId) { + const pathToActive = allEdges.filter( + (edge) => edge.from === routerEdge.to && edge.to === this.activeDrawerNodeId + ); - if (pathToActive.length > 0) { - connectingEdges.push(routerEdge); - connectingEdges.push(...pathToActive); - pathNodes.add(routerNode); - pathNodes.add(intermediateNode); - pathNodes.add(this.activeDrawerNodeId); + if (pathToActive.length > 0) { + connectingEdges.push(...pathToActive); + pathNodes.add(this.activeDrawerNodeId); + } + } + + foundEdge = true; break; } } - if (connectingEdges.length > 0) break; + if (!foundEdge) { + for (const routerEdge of routerEdges) { + if (routerEdge.to === triggerNodeId) { + connectingEdges.push(routerEdge); + pathNodes.add(routerNode); + pathNodes.add(routerEdge.to); + + const pathToActive = allEdges.filter( + (edge) => edge.from === triggerNodeId && edge.to === this.activeDrawerNodeId + ); + + if (pathToActive.length > 0) { + connectingEdges.push(...pathToActive); + pathNodes.add(this.activeDrawerNodeId); + } + + foundEdge = true; + break; + } + } + } + + if (!foundEdge) { + const directRouterEdge = routerEdges.find( + (edge) => edge.to === this.activeDrawerNodeId + ); + + if (directRouterEdge) { + connectingEdges.push(directRouterEdge); + pathNodes.add(routerNode); + pathNodes.add(this.activeDrawerNodeId); + foundEdge = true; + } + } + + if (foundEdge) { + break; + } } } } }); if (connectingEdges.length === 0) { - console.warn("TriggeredByHighlighter: No connecting edges found for group", { triggerNodeIds }); return; } @@ -379,7 +772,6 @@ class TriggeredByHighlighter { this.clear(); if (!this.activeDrawerNodeId) { - console.warn("TriggeredByHighlighter: Missing activeDrawerNodeId"); return; } @@ -419,11 +811,6 @@ class TriggeredByHighlighter { } if (routerEdges.length === 0) { - console.warn("TriggeredByHighlighter: No router paths found for node", { - activeDrawerNodeId: this.activeDrawerNodeId, - outgoingEdges: outgoingRouterEdges.length, - hasRouterPathsMetadata: !!activeMetadata?.router_paths, - }); return; } @@ -438,24 +825,12 @@ class TriggeredByHighlighter { 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, - }); - }); + // Animate the activeDrawerEdges back to default + this.resetEdgesToDefault(this.activeDrawerEdges); this.activeDrawerEdges = []; } if (!this.activeDrawerNodeId || !triggerNodeId) { - console.warn( - "TriggeredByHighlighter: Missing activeDrawerNodeId or triggerNodeId", - { - activeDrawerNodeId: this.activeDrawerNodeId, - triggerNodeId: triggerNodeId, - }, - ); return; } @@ -570,17 +945,6 @@ class TriggeredByHighlighter { } 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; } @@ -601,6 +965,7 @@ class TriggeredByHighlighter { const allNodesList = this.nodes.get(); const nodeAnimDuration = CONSTANTS.ANIMATION.DURATION; const nodeAnimStart = performance.now(); + const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark'; const animate = () => { const elapsed = performance.now() - nodeAnimStart; @@ -609,9 +974,11 @@ class TriggeredByHighlighter { allNodesList.forEach((node) => { const currentOpacity = node.opacity !== undefined ? node.opacity : 1.0; + // Keep inactive nodes at full opacity + const inactiveOpacity = 1.0; const targetOpacity = this.highlightedNodes.includes(node.id) ? 1.0 - : 0.2; + : inactiveOpacity; const newOpacity = currentOpacity + (targetOpacity - currentOpacity) * eased; @@ -621,6 +988,8 @@ class TriggeredByHighlighter { }); }); + this.network.redraw(); + if (progress < 1) { requestAnimationFrame(animate); } @@ -654,18 +1023,23 @@ class TriggeredByHighlighter { const newShadowSize = currentShadowSize + (targetShadowSize - currentShadowSize) * eased; + const isAndOrRouter = edge.dashes || edge.label === "AND"; + const highlightColor = isAndOrRouter + ? "{{ CREWAI_ORANGE }}" + : getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim(); + const updateData = { id: edge.id, hidden: false, opacity: 1.0, width: newWidth, color: { - color: "{{ CREWAI_ORANGE }}", - highlight: "{{ CREWAI_ORANGE }}", + color: highlightColor, + highlight: highlightColor, }, shadow: { enabled: true, - color: "{{ CREWAI_ORANGE }}", + color: highlightColor, size: newShadowSize, x: 0, y: 0, @@ -686,30 +1060,52 @@ class TriggeredByHighlighter { }; updateData.color = { - color: "{{ CREWAI_ORANGE }}", - highlight: "{{ CREWAI_ORANGE }}", - hover: "{{ CREWAI_ORANGE }}", + color: highlightColor, + highlight: highlightColor, + hover: highlightColor, inherit: "to", }; this.edges.update(updateData); } else { const currentOpacity = edge.opacity !== undefined ? edge.opacity : 1.0; - const targetOpacity = 0.25; + // Keep inactive edges at full opacity + const targetOpacity = 1.0; const newOpacity = currentOpacity + (targetOpacity - currentOpacity) * eased; const currentWidth = edge.width !== undefined ? edge.width : CONSTANTS.EDGE.DEFAULT_WIDTH; - const targetWidth = 1; + const targetWidth = 1.2; const newWidth = currentWidth + (targetWidth - currentWidth) * eased; + // Keep the original edge color instead of turning gray + const isAndOrRouter = edge.dashes || edge.label === "AND"; + const baseColor = isAndOrRouter + ? "{{ CREWAI_ORANGE }}" + : getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim(); + + // Convert color to rgba with opacity for vis.js + let inactiveEdgeColor; + if (baseColor.startsWith('#')) { + // Convert hex to rgba + const hex = baseColor.replace('#', ''); + const r = parseInt(hex.substr(0, 2), 16); + const g = parseInt(hex.substr(2, 2), 16); + const b = parseInt(hex.substr(4, 2), 16); + inactiveEdgeColor = `rgba(${r}, ${g}, ${b}, ${newOpacity})`; + } else if (baseColor.startsWith('rgb(')) { + inactiveEdgeColor = baseColor.replace('rgb(', `rgba(`).replace(')', `, ${newOpacity})`); + } else { + inactiveEdgeColor = baseColor; + } + 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)", + color: inactiveEdgeColor, + highlight: inactiveEdgeColor, + hover: inactiveEdgeColor, }, shadow: { enabled: false, @@ -726,55 +1122,91 @@ class TriggeredByHighlighter { animate(); } - drawHighlightLayer() { - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + resetEdgesToDefault(edgeIds = null, excludeEdges = []) { + const targetEdgeIds = edgeIds || this.edges.getIds(); + const edgeAnimDuration = CONSTANTS.ANIMATION.DURATION; + const edgeAnimStart = performance.now(); - if (this.highlightedNodes.length === 0) return; + const animate = () => { + const elapsed = performance.now() - edgeAnimStart; + const progress = Math.min(elapsed / edgeAnimDuration, 1); + const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress); - this.highlightedNodes.forEach((nodeId) => { - const nodePosition = this.network.getPositions([nodeId])[nodeId]; - if (!nodePosition) return; + targetEdgeIds.forEach((edgeId) => { + if (excludeEdges.includes(edgeId)) { + return; + } - const canvasPos = this.network.canvasToDOM(nodePosition); - const node = this.nodes.get(nodeId); - if (!node || !node.nodeStyle) return; + const edge = this.edges.get(edgeId); + if (!edge) return; - const nodeStyle = node.nodeStyle; - const scale = 1.0; - const width = CONSTANTS.NODE.BASE_WIDTH * scale; - const height = CONSTANTS.NODE.BASE_HEIGHT * scale; + 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; - this.ctx.save(); + const targetOpacity = 1.0; + const targetWidth = CONSTANTS.EDGE.DEFAULT_WIDTH; + const targetShadowSize = CONSTANTS.EDGE.DEFAULT_SHADOW_SIZE; - this.ctx.shadowColor = "transparent"; - this.ctx.shadowBlur = 0; - this.ctx.shadowOffsetX = 0; - this.ctx.shadowOffsetY = 0; + const newOpacity = + currentOpacity + (targetOpacity - currentOpacity) * eased; + const newWidth = currentWidth + (targetWidth - currentWidth) * eased; + const newShadowSize = + currentShadowSize + (targetShadowSize - currentShadowSize) * eased; - const radius = CONSTANTS.NODE.BORDER_RADIUS * scale; - const rectX = canvasPos.x - width / 2; - const rectY = canvasPos.y - height / 2; + 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", + }, + }, + }; - drawRoundedRect(this.ctx, rectX, rectY, width, height, radius); + if (edge.dashes) { + const scale = Math.sqrt(newWidth / CONSTANTS.EDGE.DEFAULT_WIDTH); + updateData.dashes = [15 * scale, 10 * scale]; + } - this.ctx.fillStyle = nodeStyle.bgColor; - this.ctx.fill(); + this.edges.update(updateData); + }); - this.ctx.shadowColor = "transparent"; - this.ctx.shadowBlur = 0; + if (progress < 1) { + requestAnimationFrame(animate); + } + }; - 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(); - }); + animate(); } clear() { @@ -890,7 +1322,7 @@ class TriggeredByHighlighter { this.highlightedNodes = []; this.highlightedEdges = []; - this.canvas.style.transition = "opacity 300ms ease-out"; + this.canvas.style.transition = `opacity ${CONSTANTS.ANIMATION.DURATION}ms ease-out`; this.canvas.style.opacity = "0"; setTimeout(() => { this.canvas.classList.remove("visible"); @@ -898,21 +1330,18 @@ class TriggeredByHighlighter { this.canvas.style.transition = ""; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.network.redraw(); - }, 300); + }, CONSTANTS.ANIMATION.DURATION); } } -// ============================================================================ -// Drawer Manager -// ============================================================================ - class DrawerManager { - constructor(network, nodes, edges, animationManager, triggeredByHighlighter) { + constructor(network, nodes, edges, animationManager, triggeredByHighlighter, networkManager) { this.network = network; this.nodes = nodes; this.edges = edges; this.animationManager = animationManager; this.triggeredByHighlighter = triggeredByHighlighter; + this.networkManager = networkManager; this.elements = { drawer: document.getElementById("drawer"), @@ -922,6 +1351,7 @@ class DrawerManager { openIdeButton: document.getElementById("drawer-open-ide"), closeButton: document.getElementById("drawer-close"), navControls: document.querySelector(".nav-controls"), + legendPanel: document.getElementById("legend-panel"), }; this.activeNodeId = null; @@ -979,9 +1409,7 @@ class DrawerManager { document.body.removeChild(link); const fallbackText = `${filePath}:${lineNum}`; - navigator.clipboard.writeText(fallbackText).catch((err) => { - console.error("Failed to copy:", err); - }); + navigator.clipboard.writeText(fallbackText).catch(() => {}); } detectIDE() { @@ -1002,21 +1430,109 @@ class DrawerManager { this.elements.content.innerHTML = content; this.attachContentEventListeners(nodeName); + + // Initialize Lucide icons in the newly rendered drawer content + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } } renderTriggerCondition(metadata) { if (metadata.trigger_condition) { return this.renderConditionTree(metadata.trigger_condition); } else if (metadata.trigger_methods) { + const uniqueTriggers = [...new Set(metadata.trigger_methods)]; + const grouped = this.groupByIdenticalAction(uniqueTriggers); + return ` `; } return ""; } + groupByIdenticalAction(triggerIds) { + const nodeData = '{{ nodeData }}'; + const allEdges = this.edges.get(); + const activeNodeId = this.activeNodeId; + + const triggerPaths = new Map(); + + triggerIds.forEach(triggerId => { + const pathSignature = this.getPathSignature(triggerId, activeNodeId, allEdges, nodeData); + if (!triggerPaths.has(pathSignature)) { + triggerPaths.set(pathSignature, []); + } + triggerPaths.get(pathSignature).push(triggerId); + }); + + return Array.from(triggerPaths.values()).map(items => ({ items })); + } + + getPathSignature(triggerNodeId, activeNodeId, allEdges, nodeData) { + const connectingEdges = []; + const direct = allEdges.filter( + (edge) => edge.from === triggerNodeId && edge.to === activeNodeId + ); + if (direct.length > 0) { + return direct.map(e => e.id).sort().join(','); + } + + const activeMetadata = nodeData[activeNodeId]; + 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 routerEdges = allEdges.filter( + (edge) => edge.from === nodeName && edge.dashes + ); + + const matchingEdge = routerEdges.find(edge => edge.label === triggerNodeId); + if (matchingEdge) { + if (matchingEdge.to === activeNodeId) { + return matchingEdge.id; + } + + const pathToActive = allEdges.filter( + (edge) => edge.from === matchingEdge.to && edge.to === activeNodeId + ); + + if (pathToActive.length > 0) { + return [matchingEdge.id, ...pathToActive.map(e => e.id)].sort().join(','); + } + } + + for (const routerEdge of routerEdges) { + if (routerEdge.to === activeNodeId) { + return routerEdge.id; + } + } + } + } + } + + return triggerNodeId; + } + renderConditionTree(condition, depth = 0) { if (typeof condition === "string") { return `${condition}`; @@ -1031,12 +1547,43 @@ class DrawerManager { const triggerIds = this.extractTriggerIds(condition); const triggerIdsJson = JSON.stringify(triggerIds).replace(/"/g, '"'); - const children = condition.conditions.map(sub => this.renderConditionTree(sub, depth + 1)).join(""); + const stringChildren = condition.conditions.filter(c => typeof c === 'string'); + const nonStringChildren = condition.conditions.filter(c => typeof c !== 'string'); + + let children = ""; + + if (nonStringChildren.length > 0) { + children += nonStringChildren.map(sub => this.renderConditionTree(sub, depth + 1)).join(""); + } + + if (stringChildren.length > 0) { + const grouped = this.groupByIdenticalAction(stringChildren); + children += grouped.map((group) => { + if (group.items.length === 1) { + return this.renderConditionTree(group.items[0], depth + 1); + } else { + const groupId = group.items.join(','); + const groupColor = conditionType === "AND" ? "{{ CREWAI_ORANGE }}" : "var(--text-secondary)"; + const groupBgColor = conditionType === "AND" ? "rgba(255,90,80,0.08)" : "rgba(102,102,102,0.06)"; + const groupHoverBg = conditionType === "AND" ? "rgba(255,90,80,0.15)" : "rgba(102,102,102,0.12)"; + return ` +
+
+ ${group.items.length} routes +
+
+ ${group.items.map((t) => `${t}`).join("")} +
+
+ `; + } + }).join(""); + } return `
-
- ${conditionType} +
+ ${conditionType}
${children} @@ -1065,6 +1612,7 @@ class DrawerManager { } renderMetadata(metadata) { + console.log('renderMetadata called with:', metadata); let metadataContent = ""; const nodeType = metadata.type || "unknown"; @@ -1097,7 +1645,13 @@ class DrawerManager { `; } + console.log('Checking trigger data:', { + has_trigger_condition: !!metadata.trigger_condition, + has_trigger_methods: !!(metadata.trigger_methods && metadata.trigger_methods.length > 0) + }); + if (metadata.trigger_condition || (metadata.trigger_methods && metadata.trigger_methods.length > 0)) { + console.log('Rendering Triggered By section'); metadataContent += `
Triggered By
@@ -1107,14 +1661,15 @@ class DrawerManager { } if (metadata.router_paths && metadata.router_paths.length > 0) { - const routerPathsJson = JSON.stringify(metadata.router_paths).replace(/"/g, '"'); + const uniqueRouterPaths = [...new Set(metadata.router_paths)]; + const routerPathsJson = JSON.stringify(uniqueRouterPaths).replace(/"/g, '"'); metadataContent += `
- Router Paths + Router Paths
    - ${metadata.router_paths.map((p) => `
  • ${p}
  • `).join("")} + ${uniqueRouterPaths.map((p) => `
  • ${p}
  • `).join("")}
`; @@ -1243,6 +1798,19 @@ class DrawerManager { }); }); + const triggerGroups = this.elements.content.querySelectorAll( + ".trigger-group[data-trigger-items]", + ); + triggerGroups.forEach((group) => { + group.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const triggerItems = group.getAttribute("data-trigger-items").split(','); + this.triggeredByHighlighter.highlightTriggeredByGroup(triggerItems); + }); + }); + const conditionGroups = this.elements.content.querySelectorAll( ".condition-group[data-trigger-group]", ); @@ -1271,39 +1839,55 @@ class DrawerManager { this.elements.drawer.style.visibility = "visible"; const wasAlreadyOpen = this.elements.drawer.classList.contains("open"); - requestAnimationFrame(() => { + if (!wasAlreadyOpen) { + // Save current position and scale before opening drawer + const currentPosition = this.networkManager.network.getViewPosition(); + const currentScale = this.networkManager.network.getScale(); + this.networkManager.positionBeforeDrawer = { + position: currentPosition, + scale: currentScale + }; + + const targetPosition = this.networkManager.calculateNetworkPosition(true); 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); - } - }); + this.elements.legendPanel.classList.add("drawer-open"); + this.networkManager.animateToPosition(targetPosition); + } else { + this.elements.drawer.classList.add("open"); + this.elements.overlay.classList.add("visible"); + this.elements.navControls.classList.add("drawer-open"); + this.elements.legendPanel.classList.add("drawer-open"); + } } close() { + // Animate accordions closed before removing classes + const accordions = this.elements.drawer.querySelectorAll(".accordion-section.expanded"); + accordions.forEach(accordion => { + const content = accordion.querySelector(".accordion-content"); + if (content) { + // Set explicit height for smooth animation + content.style.height = content.scrollHeight + "px"; + // Force reflow + content.offsetHeight; + // Trigger collapse animation + content.style.height = "0px"; + } + // Remove expanded class after animation + setTimeout(() => { + accordion.classList.remove("expanded"); + if (content) { + content.style.height = ""; + } + }, CONSTANTS.ANIMATION.DURATION); + }); + this.elements.drawer.classList.remove("open"); this.elements.overlay.classList.remove("visible"); this.elements.navControls.classList.remove("drawer-open"); + this.elements.legendPanel.classList.remove("drawer-open"); if (this.activeNodeId) { this.activeEdges.forEach((edgeId) => { @@ -1311,7 +1895,7 @@ class DrawerManager { this.edges, edgeId, CONSTANTS.EDGE.DEFAULT_WIDTH, - 200, + CONSTANTS.ANIMATION.DURATION, ); }); this.activeNodeId = null; @@ -1319,21 +1903,21 @@ class DrawerManager { } this.triggeredByHighlighter.clear(); + this.elements.drawer.offsetHeight; - setTimeout(() => { - this.network.fit({ - animation: { - duration: 300, - easingFunction: "easeInOutQuad", - }, - }); - }, 50); + // Restore the position before the drawer was opened + if (this.networkManager.positionBeforeDrawer) { + this.networkManager.animateToPosition(this.networkManager.positionBeforeDrawer); + this.networkManager.positionBeforeDrawer = null; + } else { + this.networkManager.fitToAvailableSpace(); + } setTimeout(() => { if (!this.elements.drawer.classList.contains("open")) { this.elements.drawer.style.visibility = "hidden"; } - }, 300); + }, CONSTANTS.ANIMATION.DURATION); } setActiveNode(nodeId, connectedEdges) { @@ -1359,6 +1943,7 @@ class NetworkManager { this.pressedNodeId = null; this.pressedEdges = []; this.isClicking = false; + this.positionBeforeDrawer = null; } async initialize() { @@ -1390,6 +1975,7 @@ class NetworkManager { this.edges, this.animationManager, this.triggeredByHighlighter, + this, ); this.setupEventListeners(); @@ -1397,7 +1983,7 @@ class NetworkManager { this.setupTheme(); this.network.once("stabilizationIterationsDone", () => { - this.network.fit(); + this.fitToAvailableSpace(true); }); } catch (error) { console.error("Failed to initialize network:", error); @@ -1405,7 +1991,11 @@ class NetworkManager { } createNetworkOptions() { - const nodeRenderer = new NodeRenderer(this.nodes, this); + this.nodeRenderer = new NodeRenderer(this.nodes, this); + const nodesArray = this.nodes.get(); + const hasExplicitPositions = nodesArray.some(node => + node.x !== undefined && node.y !== undefined + ); return { nodes: { @@ -1413,7 +2003,7 @@ class NetworkManager { shadow: false, chosen: false, size: 30, - ctxRenderer: (params) => nodeRenderer.render(params), + ctxRenderer: (params) => this.nodeRenderer.render(params), scaling: { min: 1, max: 100, @@ -1425,8 +2015,10 @@ class NetworkManager { labelHighlightBold: false, shadow: false, smooth: { + enabled: true, type: "cubicBezier", - roundness: 0.5, + roundness: 0.35, + forceDirection: 'vertical', }, font: { size: 13, @@ -1451,7 +2043,7 @@ class NetworkManager { }, }, physics: { - enabled: true, + enabled: false, hierarchicalRepulsion: { nodeDistance: CONSTANTS.NETWORK.NODE_DISTANCE, centralGravity: 0.0, @@ -1461,19 +2053,22 @@ class NetworkManager { }, solver: "hierarchicalRepulsion", stabilization: { - enabled: true, + enabled: false, iterations: CONSTANTS.NETWORK.STABILIZATION_ITERATIONS, updateInterval: 25, }, }, layout: { hierarchical: { - enabled: true, + enabled: !hasExplicitPositions, direction: "UD", sortMethod: "directed", levelSeparation: CONSTANTS.NETWORK.LEVEL_SEPARATION, nodeSpacing: CONSTANTS.NETWORK.NODE_SPACING, treeSpacing: CONSTANTS.NETWORK.TREE_SPACING, + edgeMinimization: false, + blockShifting: true, + parentCentralization: true, }, }, interaction: { @@ -1550,11 +2145,6 @@ class NetworkManager { } }); - this.network.on("afterDrawing", () => { - if (this.triggeredByHighlighter.canvas.classList.contains("visible")) { - this.triggeredByHighlighter.drawHighlightLayer(); - } - }); } handleNodeClick(nodeId) { @@ -1563,23 +2153,36 @@ class NetworkManager { 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(); + if (this.drawerManager && this.drawerManager.activeNodeId === nodeId) { + this.drawerManager.close(); + return; } const connectedEdges = this.network.getConnectedEdges(nodeId); + + const allEdges = this.edges.get(); + const connectedNodeIds = new Set([nodeId]); + + connectedEdges.forEach((edgeId) => { + const edge = allEdges.find(e => e.id === edgeId); + if (edge) { + if (edge.from === nodeId) { + connectedNodeIds.add(edge.to); + } else if (edge.to === nodeId) { + connectedNodeIds.add(edge.from); + } + } + }); + const allNodes = this.nodes.get(); + allNodes.forEach((n) => { + this.nodes.update({ id: n.id, opacity: 1.0 }); + }); + this.triggeredByHighlighter.highlightedNodes = []; + this.triggeredByHighlighter.highlightedEdges = []; + + // Animate all edges back to default, excluding the ones we'll highlight + this.triggeredByHighlighter.resetEdgesToDefault(null, connectedEdges); + this.drawerManager.setActiveNode(nodeId, connectedEdges); this.triggeredByHighlighter.setActiveDrawer(nodeId, connectedEdges); @@ -1590,6 +2193,7 @@ class NetworkManager { }, 15); this.drawerManager.open(nodeId, metadata); + this.network.redraw(); } setupControls() { @@ -1617,9 +2221,7 @@ class NetworkManager { }); document.getElementById("fit").addEventListener("click", () => { - this.network.fit({ - animation: { duration: 300, easingFunction: "easeInOutQuad" }, - }); + this.fitToAvailableSpace(); }); this.setupExportControls(); @@ -1634,7 +2236,7 @@ class NetworkManager { html2canvas(document.getElementById("network-container")).then( (canvas) => { const link = document.createElement("a"); - link.download = "flow_dag.png"; + link.download = "flow.png"; link.href = canvas.toDataURL(); link.click(); }, @@ -1663,7 +2265,7 @@ class NetworkManager { format: [canvas.width, canvas.height], }); pdf.addImage(imgData, "PNG", 0, 0, canvas.width, canvas.height); - pdf.save("flow_dag.pdf"); + pdf.save("flow.pdf"); }, ); }; @@ -1672,17 +2274,90 @@ class NetworkManager { 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); + // 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); + // }); + } + + calculateNetworkPosition(isDrawerOpen, centerScreen = false) { + const infoBox = document.getElementById("info"); + const infoRect = infoBox.getBoundingClientRect(); + const leftEdge = infoRect.right + 40; // 40px padding after legend + const rightEdge = isDrawerOpen ? window.innerWidth - CONSTANTS.DRAWER.WIDTH - 40 : window.innerWidth - 40; + const availableWidth = rightEdge - leftEdge; + + // Use true screen center for initial position, otherwise use available space center + const canvas = this.network ? this.network.canvas.frame.canvas : document.getElementById("network"); + const canvasRect = canvas.getBoundingClientRect(); + const domCenterX = centerScreen ? canvasRect.left + canvas.clientWidth / 2 : leftEdge + (availableWidth / 2); + + const nodePositions = this.network.getPositions(); + const nodeIds = Object.keys(nodePositions); + + if (nodeIds.length === 0) return null; + const canvasWidth = canvas.clientWidth; + const canvasHeight = canvas.clientHeight; + + const padding = 30; + const maxNodeWidth = 200; + const maxNodeHeight = 60; + + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + nodeIds.forEach(id => { + const pos = nodePositions[id]; + minX = Math.min(minX, pos.x - maxNodeWidth / 2); + maxX = Math.max(maxX, pos.x + maxNodeWidth / 2); + minY = Math.min(minY, pos.y - maxNodeHeight / 2); + maxY = Math.max(maxY, pos.y + maxNodeHeight / 2); }); + + const networkWidth = maxX - minX; + const networkHeight = maxY - minY; + const networkCenterX = (minX + maxX) / 2; + const networkCenterY = (minY + maxY) / 2; + const scaleX = availableWidth / (networkWidth + padding * 2); + const scaleY = (canvasHeight - padding * 2) / (networkHeight + padding * 2); + const scale = Math.min(scaleX, scaleY); + const targetDOMX = domCenterX; + const canvasCenterDOMX = canvasRect.left + canvasWidth / 2; + const domShift = targetDOMX - canvasCenterDOMX; + const networkShift = domShift / scale; + + return { + position: { + x: networkCenterX - networkShift, + y: networkCenterY, + }, + scale: scale, + }; + } + + animateToPosition(targetPosition) { + if (!targetPosition) return; + + this.network.moveTo({ + position: targetPosition.position, + scale: targetPosition.scale, + animation: { + duration: 300, + easingFunction: "easeInOutCubic" + }, + }); + } + + fitToAvailableSpace(centerScreen = false) { + const drawer = document.getElementById("drawer"); + const isDrawerOpen = drawer.classList.contains("open"); + const targetPosition = this.calculateNetworkPosition(isDrawerOpen, centerScreen); + this.animateToPosition(targetPosition); } setupTheme() { @@ -1718,33 +2393,55 @@ class NetworkManager { 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); - } + const updateThemeIcon = (isDark) => { + const iconName = isDark ? 'sun' : 'moon'; + themeToggle.title = isDark ? "Toggle Light Mode" : "Toggle Dark Mode"; + // Replace the icon HTML entirely and reinitialize Lucide + themeToggle.innerHTML = ``; + + // Reinitialize Lucide icons for the specific button + if (typeof lucide !== 'undefined') { + lucide.createIcons({ + elements: themeToggle.querySelectorAll('[data-lucide]') + }); + } + }; + + // Set up click handler FIRST before any icon updates 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"; + updateThemeIcon(true); } else { htmlElement.removeAttribute("data-theme"); - themeToggle.textContent = "🌙"; - themeToggle.title = "Toggle Dark Mode"; + updateThemeIcon(false); } localStorage.setItem("theme", newTheme); + + // Clear color cache to ensure theme-dependent colors are recalculated + if (this.nodeRenderer) { + this.nodeRenderer.colorCache.clear(); + } + + // Update edge colors and redraw network with new theme setTimeout(updateEdgeColors, 50); }); + + // Initialize theme after click handler is set up + const savedTheme = localStorage.getItem("theme") || "dark"; + if (savedTheme === "dark") { + htmlElement.setAttribute("data-theme", "dark"); + updateThemeIcon(true); + setTimeout(updateEdgeColors, 0); + } else { + updateThemeIcon(false); + setTimeout(updateEdgeColors, 0); + } } } @@ -1753,6 +2450,16 @@ class NetworkManager { // ============================================================================ (async () => { + // Initialize Lucide icons first (before theme setup) + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } + const networkManager = new NetworkManager(); await networkManager.initialize(); + + // Re-initialize Lucide icons after theme is set up + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } })(); 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 2f374f4bc..876286e67 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 @@ -6,6 +6,7 @@ + @@ -23,93 +24,129 @@
Node Details
- +
-
+
CrewAI Logo -
-

Flow Execution

-
-

Nodes: '{{ dag_nodes_count }}'

-

Edges: '{{ dag_edges_count }}'

-

Topological Paths: '{{ execution_paths }}'

-
-
-
Node Types
-
-
- Start Methods -
-
-
- Router Methods -
-
-
- Listen Methods -
-
-
-
Edge Types
-
- - - - Router Paths -
-
- - - - OR Conditions -
-
- - - - AND Conditions -
-
-
- Interactions:
- • Drag to pan
- • Scroll to zoom

- IDE: - + style="width: 144px; height: auto;">
+
+ + +
+ +
+
+
+ '{{ dag_nodes_count }}' + Nodes +
+
+ '{{ dag_edges_count }}' + Edges +
+
+ '{{ execution_paths }}' + Paths +
+
+
+ + +
+
+
+
+ Start +
+
+
+ Router +
+
+
+ Listen +
+
+
+ + +
+
+
+ + + + Router +
+
+ + + + OR +
+
+ + + + AND +
+
+
+ + +
+
+ + +
+
+
diff --git a/lib/crewai/src/crewai/flow/visualization/assets/style.css b/lib/crewai/src/crewai/flow/visualization/assets/style.css index 724ec5cbb..7566d822e 100644 --- a/lib/crewai/src/crewai/flow/visualization/assets/style.css +++ b/lib/crewai/src/crewai/flow/visualization/assets/style.css @@ -13,6 +13,14 @@ --edge-label-text: '{{ GRAY }}'; --edge-label-bg: rgba(255, 255, 255, 0.8); --edge-or-color: #000000; + --edge-router-color: '{{ CREWAI_ORANGE }}'; + --node-border-start: #C94238; + --node-border-listen: #3D3D3D; + --node-bg-start: #FF7066; + --node-bg-router: #FFFFFF; + --node-bg-listen: #FFFFFF; + --node-text-color: #FFFFFF; + --nav-button-hover: #f5f5f5; } [data-theme="dark"] { @@ -30,6 +38,14 @@ --edge-label-text: #c9d1d9; --edge-label-bg: rgba(22, 27, 34, 0.9); --edge-or-color: #ffffff; + --edge-router-color: '{{ CREWAI_ORANGE }}'; + --node-border-start: #FF5A50; + --node-border-listen: #666666; + --node-bg-start: #B33830; + --node-bg-router: #3D3D3D; + --node-bg-listen: #3D3D3D; + --node-text-color: #FFFFFF; + --nav-button-hover: #30363d; } @keyframes dash { @@ -72,12 +88,10 @@ body { position: absolute; top: 20px; left: 20px; - background: var(--bg-secondary); + background: transparent; padding: 20px; border-radius: 8px; - box-shadow: 0 4px 12px var(--shadow-strong); max-width: 320px; - border: 1px solid var(--border-color); z-index: 10000; pointer-events: auto; transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; @@ -125,12 +139,16 @@ h3 { margin-right: 12px; border-radius: 3px; box-sizing: border-box; + transition: background 0.3s ease, border-color 0.3s ease; } .legend-item span { color: var(--text-secondary); font-size: 13px; transition: color 0.3s ease; } +.legend-item svg line { + transition: stroke 0.3s ease; +} .instructions { margin-top: 15px; padding-top: 15px; @@ -155,7 +173,7 @@ h3 { bottom: 20px; right: auto; display: grid; - grid-template-columns: repeat(4, 40px); + grid-template-columns: repeat(3, 40px); gap: 8px; z-index: 10002; pointer-events: auto; @@ -165,10 +183,187 @@ h3 { .nav-controls.drawer-open { } +#legend-panel { + position: fixed; + left: 164px; + bottom: 20px; + right: 20px; + height: 92px; + background: var(--bg-secondary); + backdrop-filter: blur(12px) saturate(180%); + -webkit-backdrop-filter: blur(12px) saturate(180%); + border: 1px solid var(--border-subtle); + border-radius: 6px; + box-shadow: 0 2px 8px var(--shadow-color); + display: grid; + grid-template-columns: repeat(4, 1fr); + align-items: center; + gap: 0; + padding: 0 24px; + box-sizing: border-box; + z-index: 10001; + pointer-events: auto; + transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease, right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +#legend-panel.drawer-open { + right: 405px; +} + +.legend-section { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + width: -webkit-fill-available; + width: -moz-available; + width: stretch; + position: relative; +} + +.legend-section:not(:last-child)::after { + content: ''; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + width: 1px; + height: 48px; + background: var(--border-color); + transition: background 0.3s ease; +} + +.legend-stats-row { + display: flex; + gap: 32px; + justify-content: center; + align-items: center; + min-width: 0; +} + +.legend-stat-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.stat-value { + font-size: 19px; + font-weight: 700; + color: var(--text-primary); + line-height: 1; + transition: color 0.3s ease; +} + +.stat-label { + font-size: 8px; + font-weight: 500; + text-transform: uppercase; + color: var(--text-secondary); + letter-spacing: 0.5px; + transition: color 0.3s ease; +} + +.legend-items-row { + display: flex; + gap: 16px; + align-items: center; + justify-content: center; + min-width: 0; +} + +.legend-group { + display: flex; + gap: 16px; + align-items: center; +} + +.legend-item-compact { + display: flex; + align-items: center; + gap: 6px; +} + +.legend-item-compact span { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + color: var(--text-secondary); + letter-spacing: 0.5px; + white-space: nowrap; + font-family: inherit; + line-height: 1; + transition: color 0.3s ease; +} + +.legend-color-small { + width: 17px; + height: 17px; + border-radius: 2px; + box-sizing: border-box; + flex-shrink: 0; + transition: background 0.3s ease, border-color 0.3s ease; +} + +.legend-item-compact svg { + display: block; + flex-shrink: 0; + width: 29px; + height: 14px; +} + +.legend-item-compact svg line { + transition: stroke 0.3s ease; +} + +.legend-ide-column { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + justify-content: center; + min-width: 0; + width: 100%; +} + +.legend-ide-label { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + color: var(--text-secondary); + letter-spacing: 0.5px; + transition: color 0.3s ease; + white-space: nowrap; +} + +.legend-ide-select { + width: 120px; + padding: 6px 10px; + border-radius: 4px; + border: 1px solid var(--border-subtle); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 11px; + cursor: pointer; + transition: all 0.3s ease; +} + +.legend-ide-select:hover { + border-color: var(--text-secondary); +} + +.legend-ide-select:focus { + outline: none; + border-color: '{{ CREWAI_ORANGE }}'; +} + .nav-button { width: 40px; height: 40px; background: var(--bg-secondary); + backdrop-filter: blur(12px) saturate(180%); + -webkit-backdrop-filter: blur(12px) saturate(180%); border: 1px solid var(--border-subtle); border-radius: 6px; display: flex; @@ -181,12 +376,12 @@ h3 { user-select: none; pointer-events: auto; position: relative; - z-index: 10001; + z-index: 10002; transition: background 0.3s ease, border-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease; } .nav-button:hover { - background: var(--border-subtle); + background: var(--nav-button-hover); } #drawer { @@ -198,9 +393,10 @@ h3 { background: var(--bg-drawer); box-shadow: -4px 0 12px var(--shadow-strong); transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.3s ease, box-shadow 0.3s ease; - z-index: 2000; - overflow-y: auto; - padding: 24px; + z-index: 10003; + overflow: hidden; + transform: translateZ(0); + isolation: isolate; } #drawer.open { @@ -247,17 +443,22 @@ h3 { justify-content: space-between; align-items: center; margin-bottom: 20px; - padding-bottom: 16px; + padding: 24px 24px 16px 24px; border-bottom: 2px solid '{{ CREWAI_ORANGE }}'; position: relative; z-index: 2001; } .drawer-title { - font-size: 20px; + font-size: 15px; font-weight: 700; color: var(--text-primary); transition: color 0.3s ease; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; } .drawer-close { @@ -269,12 +470,19 @@ h3 { padding: 4px 8px; line-height: 1; transition: color 0.3s ease; + display: flex; + align-items: center; + justify-content: center; } .drawer-close:hover { color: '{{ CREWAI_ORANGE }}'; } +.drawer-close i { + display: block; +} + .drawer-open-ide { background: '{{ CREWAI_ORANGE }}'; border: none; @@ -292,6 +500,9 @@ h3 { position: relative; z-index: 9999; pointer-events: auto; + white-space: nowrap; + flex-shrink: 0; + min-width: fit-content; } .drawer-open-ide:hover { @@ -305,14 +516,19 @@ h3 { box-shadow: 0 1px 4px rgba(255, 90, 80, 0.2); } -.drawer-open-ide svg { +.drawer-open-ide svg, +.drawer-open-ide i { width: 14px; height: 14px; + display: block; } .drawer-content { color: '{{ DARK_GRAY }}'; line-height: 1.6; + padding: 0 24px 24px 24px; + overflow-y: auto; + height: calc(100vh - 95px); } .drawer-section { @@ -328,6 +544,10 @@ h3 { position: relative; } +.drawer-metadata-grid:has(.drawer-section:nth-child(3):nth-last-child(1)) { + grid-template-columns: 1fr 2fr; +} + .drawer-metadata-grid::before { content: ''; position: absolute; @@ -419,20 +639,35 @@ h3 { grid-column: 2; display: flex; flex-direction: column; - justify-content: center; + justify-content: flex-start; + align-items: flex-start; } .drawer-metadata-grid:has(.drawer-section:nth-child(3):nth-last-child(1))::after { - right: 50%; + right: 66.666%; +} + +.drawer-metadata-grid:has(.drawer-section:nth-child(3):nth-last-child(1))::before { + left: 33.333%; +} + +.drawer-metadata-grid .drawer-section:nth-child(3):nth-last-child(1) .drawer-section-title { + align-self: flex-start; +} + +.drawer-metadata-grid .drawer-section:nth-child(3):nth-last-child(1) > *:not(.drawer-section-title) { + width: 100%; + align-self: stretch; } .drawer-section-title { font-size: 12px; text-transform: uppercase; - color: '{{ GRAY }}'; + color: var(--text-secondary); letter-spacing: 0.5px; margin-bottom: 8px; font-weight: 600; + transition: color 0.3s ease; } .drawer-badge { @@ -465,9 +700,44 @@ h3 { padding: 3px 0; } +.drawer-metadata-grid .drawer-section .drawer-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.drawer-metadata-grid .drawer-section .drawer-list li { + border-bottom: none; + padding: 0; +} + .drawer-metadata-grid .drawer-section:nth-child(3) .drawer-list li { border-bottom: none; - padding: 3px 0; + padding: 0; +} + +.drawer-metadata-grid .drawer-section { + overflow: visible; +} + +.drawer-metadata-grid .drawer-section .condition-group, +.drawer-metadata-grid .drawer-section .trigger-group { + width: 100%; + box-sizing: border-box; +} + +.drawer-metadata-grid .drawer-section .condition-children { + width: 100%; +} + +.drawer-metadata-grid .drawer-section .trigger-group-items { + width: 100%; +} + +.drawer-metadata-grid .drawer-section .drawer-code-link { + word-break: break-word; + overflow-wrap: break-word; + max-width: 100%; } .drawer-code { @@ -491,6 +761,7 @@ h3 { cursor: pointer; transition: all 0.2s; display: inline-block; + margin: 3px 2px; } .drawer-code-link:hover { diff --git a/lib/crewai/src/crewai/flow/visualization/builder.py b/lib/crewai/src/crewai/flow/visualization/builder.py index 8a7ffece1..33ec2c114 100644 --- a/lib/crewai/src/crewai/flow/visualization/builder.py +++ b/lib/crewai/src/crewai/flow/visualization/builder.py @@ -3,12 +3,13 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Iterable import inspect from typing import TYPE_CHECKING, Any 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.types import FlowMethodName, FlowRouteName from crewai.flow.utils import ( is_flow_condition_dict, is_simple_flow_condition, @@ -197,8 +198,6 @@ def build_flow_structure(flow: Flow[Any]) -> FlowStructure: node_metadata["type"] = "router" router_methods.append(method_name) - node_metadata["condition_type"] = "IF" - if method_name in flow._router_paths: node_metadata["router_paths"] = [ str(p) for p in flow._router_paths[method_name] @@ -210,9 +209,13 @@ def build_flow_structure(flow: Flow[Any]) -> FlowStructure: ] if hasattr(method, "__condition_type__") and method.__condition_type__: + node_metadata["trigger_condition_type"] = method.__condition_type__ if "condition_type" not in node_metadata: node_metadata["condition_type"] = method.__condition_type__ + if node_metadata.get("is_router") and "condition_type" not in node_metadata: + node_metadata["condition_type"] = "IF" + if ( hasattr(method, "__trigger_condition__") and method.__trigger_condition__ is not None @@ -298,6 +301,9 @@ def build_flow_structure(flow: Flow[Any]) -> FlowStructure: nodes[method_name] = node_metadata for listener_name, condition_data in flow._listeners.items(): + if listener_name in router_methods: + continue + if is_simple_flow_condition(condition_data): cond_type, methods = condition_data edges.extend( @@ -315,6 +321,60 @@ def build_flow_structure(flow: Flow[Any]) -> FlowStructure: _create_edges_from_condition(condition_data, str(listener_name), nodes) ) + for method_name, node_metadata in nodes.items(): # type: ignore[assignment] + if node_metadata.get("is_router") and "trigger_methods" in node_metadata: + trigger_methods = node_metadata["trigger_methods"] + condition_type = node_metadata.get("trigger_condition_type", OR_CONDITION) + + if "trigger_condition" in node_metadata: + edges.extend( + _create_edges_from_condition( + node_metadata["trigger_condition"], # type: ignore[arg-type] + method_name, + nodes, + ) + ) + else: + edges.extend( + StructureEdge( + source=trigger_method, + target=method_name, + condition_type=condition_type, + is_router_path=False, + ) + for trigger_method in trigger_methods + if trigger_method in nodes + ) + + for router_method_name in router_methods: + if router_method_name not in flow._router_paths: + flow._router_paths[FlowMethodName(router_method_name)] = [] + + inferred_paths: Iterable[FlowMethodName | FlowRouteName] = set( + flow._router_paths.get(FlowMethodName(router_method_name), []) + ) + + for condition_data in flow._listeners.values(): + trigger_strings: list[str] = [] + + if is_simple_flow_condition(condition_data): + _, methods = condition_data + trigger_strings = [str(m) for m in methods] + elif is_flow_condition_dict(condition_data): + trigger_strings = _extract_direct_or_triggers(condition_data) + + for trigger_str in trigger_strings: + if trigger_str not in nodes: + # This is likely a router path output + inferred_paths.add(trigger_str) # type: ignore[attr-defined] + + if inferred_paths: + flow._router_paths[FlowMethodName(router_method_name)] = list( + inferred_paths # type: ignore[arg-type] + ) + if router_method_name in nodes: + nodes[router_method_name]["router_paths"] = list(inferred_paths) + for router_method_name in router_methods: if router_method_name not in flow._router_paths: continue @@ -340,6 +400,7 @@ def build_flow_structure(flow: Flow[Any]) -> FlowStructure: target=str(listener_name), condition_type=None, is_router_path=True, + router_path_label=str(path), ) ) diff --git a/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py b/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py index 6ce0c0fc7..88242bea6 100644 --- a/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py +++ b/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py @@ -20,7 +20,7 @@ class CSSExtension(Extension): Provides {% css 'path/to/file.css' %} tag syntax. """ - tags: ClassVar[set[str]] = {"css"} # type: ignore[assignment] + tags: ClassVar[set[str]] = {"css"} # type: ignore[misc] def parse(self, parser: Parser) -> nodes.Node: """Parse {% css 'styles.css' %} tag. @@ -53,7 +53,7 @@ class JSExtension(Extension): Provides {% js 'path/to/file.js' %} tag syntax. """ - tags: ClassVar[set[str]] = {"js"} # type: ignore[assignment] + tags: ClassVar[set[str]] = {"js"} # type: ignore[misc] def parse(self, parser: Parser) -> nodes.Node: """Parse {% js 'script.js' %} tag. @@ -91,6 +91,116 @@ TEXT_PRIMARY = "#e6edf3" TEXT_SECONDARY = "#7d8590" +def calculate_node_positions( + dag: FlowStructure, +) -> dict[str, dict[str, int | float]]: + """Calculate hierarchical positions (level, x, y) for each node. + + Args: + dag: FlowStructure containing nodes and edges. + + Returns: + Dictionary mapping node names to their position data (level, x, y). + """ + children: dict[str, list[str]] = {name: [] for name in dag["nodes"]} + parents: dict[str, list[str]] = {name: [] for name in dag["nodes"]} + + for edge in dag["edges"]: + source = edge["source"] + target = edge["target"] + if source in children and target in children: + children[source].append(target) + parents[target].append(source) + + levels: dict[str, int] = {} + queue: list[tuple[str, int]] = [] + + for start_method in dag["start_methods"]: + if start_method in dag["nodes"]: + levels[start_method] = 0 + queue.append((start_method, 0)) + + visited: set[str] = set() + while queue: + node, level = queue.pop(0) + if node in visited: + continue + visited.add(node) + + if node not in levels or levels[node] < level: + levels[node] = level + + for child in children.get(node, []): + if child not in visited: + child_level = level + 1 + if child not in levels or levels[child] < child_level: + levels[child] = child_level + queue.append((child, child_level)) + + for name in dag["nodes"]: + if name not in levels: + levels[name] = 0 + + nodes_by_level: dict[int, list[str]] = {} + for node, level in levels.items(): + if level not in nodes_by_level: + nodes_by_level[level] = [] + nodes_by_level[level].append(node) + + positions: dict[str, dict[str, int | float]] = {} + level_separation = 300 # Vertical spacing between levels + node_spacing = 400 # Horizontal spacing between nodes + + parent_count: dict[str, int] = {} + for node, parent_list in parents.items(): + parent_count[node] = len(parent_list) + + for level, nodes_at_level in sorted(nodes_by_level.items()): + y = level * level_separation + + if level == 0: + num_nodes = len(nodes_at_level) + for i, node in enumerate(nodes_at_level): + x = (i - (num_nodes - 1) / 2) * node_spacing + positions[node] = {"level": level, "x": x, "y": y} + else: + for i, node in enumerate(nodes_at_level): + parent_list = parents.get(node, []) + parent_positions: list[float] = [ + positions[parent]["x"] + for parent in parent_list + if parent in positions + ] + + if parent_positions: + if len(parent_positions) > 1 and len(set(parent_positions)) == 1: + base_x = parent_positions[0] + avg_x = base_x + node_spacing * 0.4 + else: + avg_x = sum(parent_positions) / len(parent_positions) + else: + avg_x = i * node_spacing * 0.5 + + positions[node] = {"level": level, "x": avg_x, "y": y} + + nodes_at_level_sorted = sorted( + nodes_at_level, key=lambda n: positions[n]["x"] + ) + min_spacing = node_spacing * 0.6 # Minimum horizontal distance + + for i in range(len(nodes_at_level_sorted) - 1): + current_node = nodes_at_level_sorted[i] + next_node = nodes_at_level_sorted[i + 1] + + current_x = positions[current_node]["x"] + next_x = positions[next_node]["x"] + + if next_x - current_x < min_spacing: + positions[next_node]["x"] = current_x + min_spacing + + return positions + + def render_interactive( dag: FlowStructure, filename: str = "flow_dag.html", @@ -110,6 +220,8 @@ def render_interactive( Returns: Absolute path to generated HTML file in temporary directory. """ + node_positions = calculate_node_positions(dag) + nodes_list: list[dict[str, Any]] = [] for name, metadata in dag["nodes"].items(): node_type: str = metadata.get("type", "listen") @@ -120,37 +232,37 @@ def render_interactive( if node_type == "start": color_config = { - "background": CREWAI_ORANGE, - "border": CREWAI_ORANGE, + "background": "var(--node-bg-start)", + "border": "var(--node-border-start)", "highlight": { - "background": CREWAI_ORANGE, - "border": CREWAI_ORANGE, + "background": "var(--node-bg-start)", + "border": "var(--node-border-start)", }, } - font_color = WHITE - border_width = 2 + font_color = "var(--node-text-color)" + border_width = 3 elif node_type == "router": color_config = { - "background": DARK_GRAY, + "background": "var(--node-bg-router)", "border": CREWAI_ORANGE, "highlight": { - "background": DARK_GRAY, + "background": "var(--node-bg-router)", "border": CREWAI_ORANGE, }, } - font_color = WHITE + font_color = "var(--node-text-color)" border_width = 3 else: color_config = { - "background": DARK_GRAY, - "border": DARK_GRAY, + "background": "var(--node-bg-listen)", + "border": "var(--node-border-listen)", "highlight": { - "background": DARK_GRAY, - "border": DARK_GRAY, + "background": "var(--node-bg-listen)", + "border": "var(--node-border-listen)", }, } - font_color = WHITE - border_width = 2 + font_color = "var(--node-text-color)" + border_width = 3 title_parts: list[str] = [] @@ -215,25 +327,34 @@ def render_interactive( bg_color = color_config["background"] border_color = color_config["border"] - nodes_list.append( - { - "id": name, - "label": name, - "title": "".join(title_parts), - "shape": "custom", - "size": 30, - "nodeStyle": { - "name": name, - "bgColor": bg_color, - "borderColor": border_color, - "borderWidth": border_width, - "fontColor": font_color, - }, - "opacity": 1.0, - "glowSize": 0, - "glowColor": None, - } - ) + position_data = node_positions.get(name, {"level": 0, "x": 0, "y": 0}) + + node_data: dict[str, Any] = { + "id": name, + "label": name, + "title": "".join(title_parts), + "shape": "custom", + "size": 30, + "level": position_data["level"], + "nodeStyle": { + "name": name, + "bgColor": bg_color, + "borderColor": border_color, + "borderWidth": border_width, + "fontColor": font_color, + }, + "opacity": 1.0, + "glowSize": 0, + "glowColor": None, + } + + # Add x,y only for graphs with 3-4 nodes + total_nodes = len(dag["nodes"]) + if 3 <= total_nodes <= 4: + node_data["x"] = position_data["x"] + node_data["y"] = position_data["y"] + + nodes_list.append(node_data) execution_paths: int = calculate_execution_paths(dag) @@ -246,6 +367,8 @@ def render_interactive( if edge["is_router_path"]: edge_color = CREWAI_ORANGE edge_dashes = [15, 10] + if "router_path_label" in edge: + edge_label = edge["router_path_label"] elif edge["condition_type"] == "AND": edge_label = "AND" edge_color = CREWAI_ORANGE diff --git a/lib/crewai/src/crewai/flow/visualization/types.py b/lib/crewai/src/crewai/flow/visualization/types.py index 6cb165bc4..6ce57069e 100644 --- a/lib/crewai/src/crewai/flow/visualization/types.py +++ b/lib/crewai/src/crewai/flow/visualization/types.py @@ -10,6 +10,7 @@ class NodeMetadata(TypedDict, total=False): is_router: bool router_paths: list[str] condition_type: str | None + trigger_condition_type: str | None trigger_methods: list[str] trigger_condition: dict[str, Any] | None method_signature: dict[str, Any] @@ -22,13 +23,14 @@ class NodeMetadata(TypedDict, total=False): class_line_number: int -class StructureEdge(TypedDict): +class StructureEdge(TypedDict, total=False): """Represents a connection in the flow structure.""" source: str target: str condition_type: str | None is_router_path: bool + router_path_label: str class FlowStructure(TypedDict):