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 `
Nodes: '{{ dag_nodes_count }}'
-Edges: '{{ dag_edges_count }}'
-Topological Paths: '{{ execution_paths }}'
-