diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index cd18a9124..4d45e8950 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -23,7 +23,6 @@ dependencies = [ "chromadb~=1.1.0", "tokenizers>=0.20.3", "openpyxl>=3.1.5", - "pyvis>=0.3.2", # Authentication and Security "python-dotenv>=1.1.1", "pyjwt>=2.9.0", diff --git a/lib/crewai/src/crewai/events/event_listener.py b/lib/crewai/src/crewai/events/event_listener.py index 8140ccc2b..69e1891c0 100644 --- a/lib/crewai/src/crewai/events/event_listener.py +++ b/lib/crewai/src/crewai/events/event_listener.py @@ -88,6 +88,7 @@ class EventListener(BaseEventListener): text_stream = StringIO() knowledge_retrieval_in_progress = False knowledge_query_in_progress = False + method_branches: dict[str, Any] = Field(default_factory=dict) def __new__(cls): if cls._instance is None: @@ -101,6 +102,7 @@ class EventListener(BaseEventListener): self._telemetry = Telemetry() self._telemetry.set_tracer() self.execution_spans = {} + self.method_branches = {} self._initialized = True self.formatter = ConsoleFormatter(verbose=True) @@ -263,7 +265,8 @@ class EventListener(BaseEventListener): @crewai_event_bus.on(FlowCreatedEvent) def on_flow_created(source, event: FlowCreatedEvent): self._telemetry.flow_creation_span(event.flow_name) - self.formatter.create_flow_tree(event.flow_name, str(source.flow_id)) + tree = self.formatter.create_flow_tree(event.flow_name, str(source.flow_id)) + self.formatter.current_flow_tree = tree @crewai_event_bus.on(FlowStartedEvent) def on_flow_started(source, event: FlowStartedEvent): @@ -280,30 +283,36 @@ class EventListener(BaseEventListener): @crewai_event_bus.on(MethodExecutionStartedEvent) def on_method_execution_started(source, event: MethodExecutionStartedEvent): - self.formatter.update_method_status( - self.formatter.current_method_branch, + method_branch = self.method_branches.get(event.method_name) + updated_branch = self.formatter.update_method_status( + method_branch, self.formatter.current_flow_tree, event.method_name, "running", ) + self.method_branches[event.method_name] = updated_branch @crewai_event_bus.on(MethodExecutionFinishedEvent) def on_method_execution_finished(source, event: MethodExecutionFinishedEvent): - self.formatter.update_method_status( - self.formatter.current_method_branch, + method_branch = self.method_branches.get(event.method_name) + updated_branch = self.formatter.update_method_status( + method_branch, self.formatter.current_flow_tree, event.method_name, "completed", ) + self.method_branches[event.method_name] = updated_branch @crewai_event_bus.on(MethodExecutionFailedEvent) def on_method_execution_failed(source, event: MethodExecutionFailedEvent): - self.formatter.update_method_status( - self.formatter.current_method_branch, + method_branch = self.method_branches.get(event.method_name) + updated_branch = self.formatter.update_method_status( + method_branch, self.formatter.current_flow_tree, event.method_name, "failed", ) + self.method_branches[event.method_name] = updated_branch # ----------- TOOL USAGE EVENTS ----------- diff --git a/lib/crewai/src/crewai/events/utils/console_formatter.py b/lib/crewai/src/crewai/events/utils/console_formatter.py index c76853d02..6a04d1fa6 100644 --- a/lib/crewai/src/crewai/events/utils/console_formatter.py +++ b/lib/crewai/src/crewai/events/utils/console_formatter.py @@ -357,7 +357,14 @@ class ConsoleFormatter: return flow_tree def start_flow(self, flow_name: str, flow_id: str) -> Tree | None: - """Initialize a flow execution tree.""" + """Initialize or update a flow execution tree.""" + if self.current_flow_tree is not None: + for child in self.current_flow_tree.children: + if "Starting Flow" in str(child.label): + child.label = Text("🚀 Flow Started", style="green") + break + return self.current_flow_tree + flow_tree = Tree("") flow_label = Text() flow_label.append("🌊 Flow: ", style="blue bold") @@ -436,27 +443,38 @@ class ConsoleFormatter: prefix, style = "🔄 Running:", "yellow" elif status == "completed": prefix, style = "✅ Completed:", "green" - # Update initialization node when a method completes successfully for child in flow_tree.children: if "Starting Flow" in str(child.label): child.label = Text("Flow Method Step", style="white") break else: prefix, style = "❌ Failed:", "red" - # Update initialization node on failure for child in flow_tree.children: if "Starting Flow" in str(child.label): child.label = Text("❌ Flow Step Failed", style="red") break - if not method_branch: - # Find or create method branch - for branch in flow_tree.children: - if method_name in str(branch.label): - method_branch = branch - break - if not method_branch: - method_branch = flow_tree.add("") + if method_branch is not None: + if method_branch in flow_tree.children: + method_branch.label = Text(prefix, style=f"{style} bold") + Text( + f" {method_name}", style=style + ) + self.print(flow_tree) + self.print() + return method_branch + + for branch in flow_tree.children: + label_str = str(branch.label) + if f" {method_name}" in label_str and ( + "Running:" in label_str + or "Completed:" in label_str + or "Failed:" in label_str + ): + method_branch = branch + break + + if method_branch is None: + method_branch = flow_tree.add("") method_branch.label = Text(prefix, style=f"{style} bold") + Text( f" {method_name}", style=style @@ -464,6 +482,7 @@ class ConsoleFormatter: self.print(flow_tree) self.print() + return method_branch def get_llm_tree(self, tool_name: str): diff --git a/lib/crewai/src/crewai/flow/__init__.py b/lib/crewai/src/crewai/flow/__init__.py index 8e055d939..bda0186c7 100644 --- a/lib/crewai/src/crewai/flow/__init__.py +++ b/lib/crewai/src/crewai/flow/__init__.py @@ -1,5 +1,25 @@ +from crewai.flow.visualization import ( + FlowStructure, + build_flow_structure, + print_structure_summary, + structure_to_dict, + visualize_flow_structure, +) from crewai.flow.flow import Flow, and_, listen, or_, router, start from crewai.flow.persistence import persist -__all__ = ["Flow", "and_", "listen", "or_", "persist", "router", "start"] +__all__ = [ + "Flow", + "FlowStructure", + "and_", + "build_flow_structure", + "listen", + "or_", + "persist", + "print_structure_summary", + "router", + "start", + "structure_to_dict", + "visualize_flow_structure", +] diff --git a/lib/crewai/src/crewai/flow/assets/crewai_flow_visual_template.html b/lib/crewai/src/crewai/flow/assets/crewai_flow_visual_template.html deleted file mode 100644 index f175ef1a7..000000000 --- a/lib/crewai/src/crewai/flow/assets/crewai_flow_visual_template.html +++ /dev/null @@ -1,93 +0,0 @@ - - -
- -Nodes: '{{ dag_nodes_count }}'
+Edges: '{{ dag_edges_count }}'
+Topological Paths: '{{ execution_paths }}'
+", html, re.DOTALL) - return match.group(1) if match else "" - - def generate_legend_items_html(self, legend_items: list[dict[str, Any]]) -> str: - """Generate HTML markup for the legend items.""" - legend_items_html = "" - for item in legend_items: - if "border" in item: - legend_items_html += f""" -
- """ - elif item.get("dashed") is not None: - style = "dashed" if item["dashed"] else "solid" - legend_items_html += f""" -
- """ - else: - legend_items_html += f""" -
- """ - return legend_items_html - - def generate_final_html( - self, network_body: str, legend_items_html: str, title: str = "Flow Plot" - ) -> str: - """Combine all components into final HTML document with network visualization.""" - html_template = self.read_template() - logo_svg_base64 = self.encode_logo() - - return ( - html_template.replace("{{ title }}", title) - .replace("{{ network_content }}", network_body) - .replace("{{ logo_svg_base64 }}", logo_svg_base64) - .replace("", legend_items_html) - ) diff --git a/lib/crewai/src/crewai/flow/legend_generator.py b/lib/crewai/src/crewai/flow/legend_generator.py deleted file mode 100644 index 7a1e06582..000000000 --- a/lib/crewai/src/crewai/flow/legend_generator.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Legend generation for flow visualization diagrams.""" - -from typing import Any - -from crewai.flow.config import FlowColors - - -def get_legend_items(colors: FlowColors) -> list[dict[str, Any]]: - """Generate legend items based on flow colors. - - Parameters - ---------- - colors : FlowColors - Dictionary containing color definitions for flow elements. - - Returns - ------- - list[dict[str, Any]] - List of legend item dictionaries with labels and styling. - """ - return [ - {"label": "Start Method", "color": colors["start"]}, - {"label": "Method", "color": colors["method"]}, - { - "label": "Crew Method", - "color": colors["bg"], - "border": colors["start"], - "dashed": False, - }, - { - "label": "Router", - "color": colors["router"], - "border": colors["router_border"], - "dashed": True, - }, - {"label": "Trigger", "color": colors["edge"], "dashed": False}, - {"label": "AND Trigger", "color": colors["edge"], "dashed": True}, - { - "label": "Router Trigger", - "color": colors["router_edge"], - "dashed": True, - }, - ] - - -def generate_legend_items_html(legend_items: list[dict[str, Any]]) -> str: - """Generate HTML markup for legend items. - - Parameters - ---------- - legend_items : list[dict[str, Any]] - List of legend item dictionaries containing labels and styling. - - Returns - ------- - str - HTML string containing formatted legend items. - """ - legend_items_html = "" - for item in legend_items: - if "border" in item: - style = "dashed" if item["dashed"] else "solid" - legend_items_html += f""" -
- """ - elif item.get("dashed") is not None: - style = "dashed" if item["dashed"] else "solid" - legend_items_html += f""" -
- """ - else: - legend_items_html += f""" -
- """ - return legend_items_html diff --git a/lib/crewai/src/crewai/flow/path_utils.py b/lib/crewai/src/crewai/flow/path_utils.py deleted file mode 100644 index 02a893865..000000000 --- a/lib/crewai/src/crewai/flow/path_utils.py +++ /dev/null @@ -1,133 +0,0 @@ -""" -Path utilities for secure file operations in CrewAI flow module. - -This module provides utilities for secure path handling to prevent directory -traversal attacks and ensure paths remain within allowed boundaries. -""" - -from pathlib import Path - - -def safe_path_join(*parts: str, root: str | Path | None = None) -> str: - """ - Safely join path components and ensure the result is within allowed boundaries. - - Parameters - ---------- - *parts : str - Variable number of path components to join. - root : Union[str, Path, None], optional - Root directory to use as base. If None, uses current working directory. - - Returns - ------- - str - String representation of the resolved path. - - Raises - ------ - ValueError - If the resulting path would be outside the root directory - or if any path component is invalid. - """ - if not parts: - raise ValueError("No path components provided") - - try: - # Convert all parts to strings and clean them - clean_parts = [str(part).strip() for part in parts if part] - if not clean_parts: - raise ValueError("No valid path components provided") - - # Establish root directory - root_path = Path(root).resolve() if root else Path.cwd() - - # Join and resolve the full path - full_path = Path(root_path, *clean_parts).resolve() - - # Check if the resolved path is within root - if not str(full_path).startswith(str(root_path)): - raise ValueError( - f"Invalid path: Potential directory traversal. Path must be within {root_path}" - ) - - return str(full_path) - - except Exception as e: - if isinstance(e, ValueError): - raise - raise ValueError(f"Invalid path components: {e!s}") from e - - -def validate_path_exists(path: str | Path, file_type: str = "file") -> str: - """ - Validate that a path exists and is of the expected type. - - Parameters - ---------- - path : Union[str, Path] - Path to validate. - file_type : str, optional - Expected type ('file' or 'directory'), by default 'file'. - - Returns - ------- - str - Validated path as string. - - Raises - ------ - ValueError - If path doesn't exist or is not of expected type. - """ - try: - path_obj = Path(path).resolve() - - if not path_obj.exists(): - raise ValueError(f"Path does not exist: {path}") - - if file_type == "file" and not path_obj.is_file(): - raise ValueError(f"Path is not a file: {path}") - if file_type == "directory" and not path_obj.is_dir(): - raise ValueError(f"Path is not a directory: {path}") - - return str(path_obj) - - except Exception as e: - if isinstance(e, ValueError): - raise - raise ValueError(f"Invalid path: {e!s}") from e - - -def list_files(directory: str | Path, pattern: str = "*") -> list[str]: - """ - Safely list files in a directory matching a pattern. - - Parameters - ---------- - directory : Union[str, Path] - Directory to search in. - pattern : str, optional - Glob pattern to match files against, by default "*". - - Returns - ------- - List[str] - List of matching file paths. - - Raises - ------ - ValueError - If directory is invalid or inaccessible. - """ - try: - dir_path = Path(directory).resolve() - if not dir_path.is_dir(): - raise ValueError(f"Not a directory: {directory}") - - return [str(p) for p in dir_path.glob(pattern) if p.is_file()] - - except Exception as e: - if isinstance(e, ValueError): - raise - raise ValueError(f"Error listing files: {e!s}") from e diff --git a/lib/crewai/src/crewai/flow/utils.py b/lib/crewai/src/crewai/flow/utils.py index 753eb280a..bad9d9670 100644 --- a/lib/crewai/src/crewai/flow/utils.py +++ b/lib/crewai/src/crewai/flow/utils.py @@ -13,14 +13,17 @@ Example >>> ancestors = build_ancestor_dict(flow) """ +from __future__ import annotations + import ast from collections import defaultdict, deque import inspect import textwrap -from typing import Any +from typing import Any, TYPE_CHECKING from typing_extensions import TypeIs +from crewai.flow.constants import OR_CONDITION, AND_CONDITION from crewai.flow.flow_wrappers import ( FlowCondition, FlowConditions, @@ -30,6 +33,8 @@ 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 _printer = Printer() @@ -74,11 +79,22 @@ def get_possible_return_constants(function: Any) -> list[str] | None: _printer.print(f"Source code:\n{source}", color="yellow") return None - return_values = set() - dict_definitions = {} + return_values: set[str] = set() + dict_definitions: dict[str, list[str]] = {} + variable_values: dict[str, list[str]] = {} - class DictionaryAssignmentVisitor(ast.NodeVisitor): - def visit_Assign(self, node): + def extract_string_constants(node: ast.expr) -> list[str]: + """Recursively extract all string constants from an AST node.""" + strings: list[str] = [] + if isinstance(node, ast.Constant) and isinstance(node.value, str): + strings.append(node.value) + elif isinstance(node, ast.IfExp): + strings.extend(extract_string_constants(node.body)) + strings.extend(extract_string_constants(node.orelse)) + return strings + + class VariableAssignmentVisitor(ast.NodeVisitor): + def visit_Assign(self, node: ast.Assign) -> None: # Check if this assignment is assigning a dictionary literal to a variable if isinstance(node.value, ast.Dict) and len(node.targets) == 1: target = node.targets[0] @@ -92,29 +108,53 @@ def get_possible_return_constants(function: Any) -> list[str] | None: ] if dict_values: dict_definitions[var_name] = dict_values + + if len(node.targets) == 1: + target = node.targets[0] + var_name_alt: str | None = None + if isinstance(target, ast.Name): + var_name_alt = target.id + elif isinstance(target, ast.Attribute): + var_name_alt = f"{target.value.id if isinstance(target.value, ast.Name) else '_'}.{target.attr}" + + if var_name_alt: + strings = extract_string_constants(node.value) + if strings: + variable_values[var_name_alt] = strings + self.generic_visit(node) class ReturnVisitor(ast.NodeVisitor): - def visit_Return(self, node): - # Direct string return - if isinstance(node.value, ast.Constant) and isinstance( - node.value.value, str + def visit_Return(self, node: ast.Return) -> None: + if ( + node.value + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) ): return_values.add(node.value.value) - # Dictionary-based return, like return paths[result] - elif isinstance(node.value, ast.Subscript): - # Check if we're subscripting a known dictionary variable + elif node.value and isinstance(node.value, ast.Subscript): if isinstance(node.value.value, ast.Name): - var_name = node.value.value.id - if var_name in dict_definitions: - # Add all possible dictionary values - for v in dict_definitions[var_name]: + var_name_dict = node.value.value.id + if var_name_dict in dict_definitions: + 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}" + + if var_name_ret and var_name_ret in variable_values: + for v in variable_values[var_name_ret]: + return_values.add(v) + self.generic_visit(node) - # First pass: identify dictionary assignments - DictionaryAssignmentVisitor().visit(code_ast) - # Second pass: identify returns + def visit_If(self, node: ast.If) -> None: + self.generic_visit(node) + + VariableAssignmentVisitor().visit(code_ast) ReturnVisitor().visit(code_ast) return list(return_values) if return_values else None @@ -158,7 +198,15 @@ def calculate_node_levels(flow: Any) -> dict[str, int]: # Precompute listener dependencies or_listeners = defaultdict(list) and_listeners = defaultdict(set) - for listener_name, (condition_type, trigger_methods) in flow._listeners.items(): + for listener_name, condition_data in flow._listeners.items(): + if isinstance(condition_data, tuple): + condition_type, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive(condition_data, flow) + condition_type = condition_data.get("type", "OR") + else: + continue + if condition_type == "OR": for method in trigger_methods: or_listeners[method].append(listener_name) @@ -192,9 +240,13 @@ def calculate_node_levels(flow: Any) -> dict[str, int]: if listener_name not in visited: queue.append(listener_name) - # Handle router connections process_router_paths(flow, current, current_level, levels, queue) + max_level = max(levels.values()) if levels else 0 + for method_name in flow._methods: + if method_name not in levels: + levels[method_name] = max_level + 1 + return levels @@ -215,8 +267,14 @@ def count_outgoing_edges(flow: Any) -> dict[str, int]: counts = {} for method_name in flow._methods: counts[method_name] = 0 - for method_name in flow._listeners: - _, trigger_methods = flow._listeners[method_name] + for condition_data in flow._listeners.values(): + if isinstance(condition_data, tuple): + _, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive(condition_data, flow) + else: + continue + for trigger in trigger_methods: if trigger in flow._methods: counts[trigger] += 1 @@ -271,21 +329,34 @@ def dfs_ancestors( return visited.add(node) - # Handle regular listeners - for listener_name, (_, trigger_methods) in flow._listeners.items(): + for listener_name, condition_data in flow._listeners.items(): + if isinstance(condition_data, tuple): + _, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive(condition_data, flow) + else: + continue + if node in trigger_methods: ancestors[listener_name].add(node) ancestors[listener_name].update(ancestors[node]) dfs_ancestors(listener_name, ancestors, visited, flow) - # Handle router methods separately if node in flow._routers: router_method_name = node paths = flow._router_paths.get(router_method_name, []) for path in paths: - for listener_name, (_, trigger_methods) in flow._listeners.items(): + for listener_name, condition_data in flow._listeners.items(): + if isinstance(condition_data, tuple): + _, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive( + condition_data, flow + ) + else: + continue + if path in trigger_methods: - # Only propagate the ancestors of the router method, not the router method itself ancestors[listener_name].update(ancestors[node]) dfs_ancestors(listener_name, ancestors, visited, flow) @@ -335,19 +406,32 @@ def build_parent_children_dict(flow: Any) -> dict[str, list[str]]: """ parent_children: dict[str, list[str]] = {} - # Map listeners to their trigger methods - for listener_name, (_, trigger_methods) in flow._listeners.items(): + for listener_name, condition_data in flow._listeners.items(): + if isinstance(condition_data, tuple): + _, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive(condition_data, flow) + else: + continue + for trigger in trigger_methods: if trigger not in parent_children: parent_children[trigger] = [] if listener_name not in parent_children[trigger]: parent_children[trigger].append(listener_name) - # Map router methods to their paths and to listeners for router_method_name, paths in flow._router_paths.items(): for path in paths: - # Map router method to listeners of each path - for listener_name, (_, trigger_methods) in flow._listeners.items(): + for listener_name, condition_data in flow._listeners.items(): + if isinstance(condition_data, tuple): + _, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive( + condition_data, flow + ) + else: + continue + if path in trigger_methods: if router_method_name not in parent_children: parent_children[router_method_name] = [] @@ -382,17 +466,27 @@ def get_child_index( return children.index(child) -def process_router_paths(flow, current, current_level, levels, queue): - """ - Handle the router connections for the current node. - """ +def process_router_paths( + flow: Any, + current: str, + current_level: int, + levels: dict[str, int], + queue: deque[str], +) -> None: + """Handle the router connections for the current node.""" if current in flow._routers: paths = flow._router_paths.get(current, []) for path in paths: - for listener_name, ( - _condition_type, - trigger_methods, - ) in flow._listeners.items(): + for listener_name, condition_data in flow._listeners.items(): + if isinstance(condition_data, tuple): + _condition_type, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive( + condition_data, flow + ) + else: + continue + if path in trigger_methods: if ( listener_name not in levels @@ -413,7 +507,7 @@ def is_flow_method_name(obj: Any) -> TypeIs[FlowMethodName]: return isinstance(obj, str) -def is_flow_method_callable(obj: Any) -> TypeIs[FlowMethodCallable]: +def is_flow_method_callable(obj: Any) -> TypeIs[FlowMethodCallable[..., Any]]: """Check if the object is a callable flow method. Args: @@ -517,3 +611,107 @@ def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]: return False return True + + +def _extract_all_methods_recursive( + condition: str | FlowCondition | dict[str, Any] | list[Any], + flow: Flow[Any] | None = None, +) -> list[FlowMethodName]: + """Extract ALL method names from a condition tree recursively. + + This function recursively extracts every method name from the entire + condition tree, regardless of nesting. Used for visualization and debugging. + + Note: Only extracts actual method names, not router output strings. + If flow is provided, it will filter out strings that are not in flow._methods. + + Args: + condition: Can be a string, dict, or list + flow: Optional flow instance to filter out non-method strings + + Returns: + List of all method names found in the condition tree + """ + if is_flow_method_name(condition): + if flow is not None: + if condition in flow._methods: + return [condition] + return [] + return [condition] + if is_flow_condition_dict(condition): + normalized = _normalize_condition(condition) + methods = [] + for sub_cond in normalized.get("conditions", []): + methods.extend(_extract_all_methods_recursive(sub_cond, flow)) + return methods + if isinstance(condition, list): + methods = [] + for item in condition: + methods.extend(_extract_all_methods_recursive(item, flow)) + return methods + return [] + + +def _normalize_condition( + condition: FlowConditions | FlowCondition | FlowMethodName, +) -> FlowCondition: + """Normalize a condition to standard format with 'conditions' key. + + Args: + condition: Can be a string (method name), dict (condition), or list + + Returns: + Normalized dict with 'type' and 'conditions' keys + """ + if is_flow_method_name(condition): + return {"type": OR_CONDITION, "conditions": [condition]} + if is_flow_condition_dict(condition): + if "conditions" in condition: + return condition + if "methods" in condition: + return {"type": condition["type"], "conditions": condition["methods"]} + return condition + if is_flow_condition_list(condition): + return {"type": OR_CONDITION, "conditions": condition} + + raise ValueError(f"Cannot normalize condition: {condition}") + + +def _extract_all_methods( + condition: str | FlowCondition | dict[str, Any] | list[Any], +) -> list[FlowMethodName]: + """Extract all method names from a condition (including nested). + + For AND conditions, this extracts methods that must ALL complete. + For OR conditions nested inside AND, we don't extract their methods + since only one branch of the OR needs to trigger, not all methods. + + This function is used for runtime execution logic, where we need to know + which methods must complete for AND conditions. For visualization purposes, + use _extract_all_methods_recursive() instead. + + Args: + condition: Can be a string, dict, or list + + Returns: + List of all method names in the condition tree that must complete + """ + if is_flow_method_name(condition): + return [condition] + if is_flow_condition_dict(condition): + normalized = _normalize_condition(condition) + cond_type = normalized.get("type", OR_CONDITION) + + if cond_type == AND_CONDITION: + return [ + sub_cond + for sub_cond in normalized.get("conditions", []) + if is_flow_method_name(sub_cond) + ] + return [] + if isinstance(condition, list): + methods = [] + for item in condition: + methods.extend(_extract_all_methods(item)) + return methods + return [] diff --git a/lib/crewai/src/crewai/flow/visualization/__init__.py b/lib/crewai/src/crewai/flow/visualization/__init__.py new file mode 100644 index 000000000..98665f642 --- /dev/null +++ b/lib/crewai/src/crewai/flow/visualization/__init__.py @@ -0,0 +1,25 @@ +"""Flow structure visualization utilities.""" + +from crewai.flow.visualization.builder import ( + build_flow_structure, + calculate_execution_paths, + print_structure_summary, + structure_to_dict, +) +from crewai.flow.visualization.renderers import render_interactive +from crewai.flow.visualization.types import FlowStructure, NodeMetadata, StructureEdge + + +visualize_flow_structure = render_interactive + +__all__ = [ + "FlowStructure", + "NodeMetadata", + "StructureEdge", + "build_flow_structure", + "calculate_execution_paths", + "print_structure_summary", + "render_interactive", + "structure_to_dict", + "visualize_flow_structure", +] diff --git a/lib/crewai/src/crewai/flow/visualization/assets/interactive.js b/lib/crewai/src/crewai/flow/visualization/assets/interactive.js new file mode 100644 index 000000000..c6998becd --- /dev/null +++ b/lib/crewai/src/crewai/flow/visualization/assets/interactive.js @@ -0,0 +1,1681 @@ +function loadVisCDN() { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://unpkg.com/vis-network@9.1.2/dist/vis-network.min.js'; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); +} + +(async () => { + + try { + await loadVisCDN(); + const nodes = new vis.DataSet('{{ nodes_list_json }}'); + const edges = new vis.DataSet('{{ edges_list_json }}'); + + const container = document.getElementById('network'); + const data = { + nodes: nodes, + edges: edges + }; + + const options = { + nodes: { + shape: 'custom', + shadow: false, + chosen: false, + size: 30, + ctxRenderer: function ({ctx, id, x, y, state: {selected, hover}, style, label}) { + const node = nodes.get(id); + if (!node || !node.nodeStyle) return {}; + + const nodeStyle = node.nodeStyle; + const baseWidth = 200; + const baseHeight = 60; + + let scale = 1.0; + if (pressedNodeId === id) { + scale = 0.98; + } else if (hoveredNodeId === id) { + scale = 1.04; + } + + const isActiveDrawer = activeDrawerNodeId === id; + + const width = baseWidth * scale; + const height = baseHeight * scale; + + return { + drawNode() { + ctx.save(); + + const nodeOpacity = node.opacity !== undefined ? node.opacity : 1.0; + ctx.globalAlpha = nodeOpacity; + + if (node.shadow && node.shadow.enabled) { + ctx.shadowColor = node.shadow.color || '{{ CREWAI_ORANGE }}'; + ctx.shadowBlur = node.shadow.size || 20; + ctx.shadowOffsetX = node.shadow.x || 0; + ctx.shadowOffsetY = node.shadow.y || 0; + } else if (isActiveDrawer) { + ctx.shadowColor = '{{ CREWAI_ORANGE }}'; + ctx.shadowBlur = 20; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + } else { + ctx.shadowColor = 'rgba(0,0,0,0.1)'; + ctx.shadowBlur = 8; + ctx.shadowOffsetX = 2; + ctx.shadowOffsetY = 2; + } + + const radius = 20 * scale; + const rectX = x - width / 2; + const rectY = y - height / 2; + + ctx.beginPath(); + ctx.moveTo(rectX + radius, rectY); + ctx.lineTo(rectX + width - radius, rectY); + ctx.quadraticCurveTo(rectX + width, rectY, rectX + width, rectY + radius); + ctx.lineTo(rectX + width, rectY + height - radius); + ctx.quadraticCurveTo(rectX + width, rectY + height, rectX + width - radius, rectY + height); + ctx.lineTo(rectX + radius, rectY + height); + ctx.quadraticCurveTo(rectX, rectY + height, rectX, rectY + height - radius); + ctx.lineTo(rectX, rectY + radius); + ctx.quadraticCurveTo(rectX, rectY, rectX + radius, rectY); + ctx.closePath(); + + ctx.fillStyle = nodeStyle.bgColor; + ctx.fill(); + + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + + const borderWidth = isActiveDrawer ? nodeStyle.borderWidth * 2 : nodeStyle.borderWidth; + ctx.strokeStyle = isActiveDrawer ? '{{ CREWAI_ORANGE }}' : nodeStyle.borderColor; + ctx.lineWidth = borderWidth * scale; + ctx.stroke(); + + ctx.font = `500 ${13 * scale}px 'JetBrains Mono', 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const textMetrics = ctx.measureText(nodeStyle.name); + const textWidth = textMetrics.width; + const textHeight = 13 * scale; + const textPadding = 8 * scale; + const textBgRadius = 6 * scale; + + const textBgX = x - textWidth / 2 - textPadding; + const textBgY = y - textHeight / 2 - textPadding / 2; + const textBgWidth = textWidth + textPadding * 2; + const textBgHeight = textHeight + textPadding; + + ctx.beginPath(); + ctx.moveTo(textBgX + textBgRadius, textBgY); + ctx.lineTo(textBgX + textBgWidth - textBgRadius, textBgY); + ctx.quadraticCurveTo(textBgX + textBgWidth, textBgY, textBgX + textBgWidth, textBgY + textBgRadius); + ctx.lineTo(textBgX + textBgWidth, textBgY + textBgHeight - textBgRadius); + ctx.quadraticCurveTo(textBgX + textBgWidth, textBgY + textBgHeight, textBgX + textBgWidth - textBgRadius, textBgY + textBgHeight); + ctx.lineTo(textBgX + textBgRadius, textBgY + textBgHeight); + ctx.quadraticCurveTo(textBgX, textBgY + textBgHeight, textBgX, textBgY + textBgHeight - textBgRadius); + ctx.lineTo(textBgX, textBgY + textBgRadius); + ctx.quadraticCurveTo(textBgX, textBgY, textBgX + textBgRadius, textBgY); + ctx.closePath(); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; + ctx.fill(); + + ctx.fillStyle = nodeStyle.fontColor; + ctx.fillText(nodeStyle.name, x, y); + + ctx.restore(); + }, + nodeDimensions: {width, height} + }; + }, + scaling: { + min: 1, + max: 100 + } + }, + edges: { + width: 2, + hoverWidth: 0, + labelHighlightBold: false, + shadow: { + enabled: true, + color: 'rgba(0,0,0,0.08)', + size: 4, + x: 1, + y: 1 + }, + smooth: { + type: 'cubicBezier', + roundness: 0.5 + }, + font: { + size: 13, + align: 'middle', + color: 'transparent', + face: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + strokeWidth: 0, + background: 'transparent', + vadjust: 0, + bold: { + face: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + mod: 'bold', + vadjust: 0 + } + }, + arrows: { + to: { + enabled: true, + scaleFactor: 0.8, + type: 'triangle' + } + }, + arrowStrikethrough: true, + chosen: { + edge: false, + label: false + } + }, + physics: { + enabled: true, + hierarchicalRepulsion: { + nodeDistance: 180, + centralGravity: 0.0, + springLength: 150, + springConstant: 0.01, + damping: 0.09 + }, + solver: 'hierarchicalRepulsion', + stabilization: { + enabled: true, + iterations: 200, + updateInterval: 25 + } + }, + layout: { + hierarchical: { + enabled: true, + direction: 'UD', + sortMethod: 'directed', + levelSeparation: 180, + nodeSpacing: 220, + treeSpacing: 250 + } + }, + interaction: { + hover: true, + hoverConnectedEdges: false, + navigationButtons: false, + keyboard: true, + selectConnectedEdges: false, + multiselect: false + } + }; + + const network = new vis.Network(container, data, options); + + const ideSelector = document.getElementById('ide-selector'); + const savedIDE = localStorage.getItem('preferred_ide') || 'auto'; + ideSelector.value = savedIDE; + ideSelector.addEventListener('change', function () { + localStorage.setItem('preferred_ide', this.value); + }); + + const highlightCanvas = document.getElementById('highlight-canvas'); + const highlightCtx = highlightCanvas.getContext('2d'); + + function resizeHighlightCanvas() { + highlightCanvas.width = window.innerWidth; + highlightCanvas.height = window.innerHeight; + } + + resizeHighlightCanvas(); + window.addEventListener('resize', resizeHighlightCanvas); + + let highlightedNodes = []; + let highlightedEdges = []; + let nodeRestoreAnimationId = null; + let edgeRestoreAnimationId = null; + + function drawHighlightLayer() { + highlightCtx.clearRect(0, 0, highlightCanvas.width, highlightCanvas.height); + + if (highlightedNodes.length === 0) return; + + highlightedNodes.forEach(function (nodeId) { + const nodePosition = network.getPositions([nodeId])[nodeId]; + if (!nodePosition) return; + + const canvasPos = network.canvasToDOM(nodePosition); + const node = nodes.get(nodeId); + if (!node || !node.nodeStyle) return; + + const nodeStyle = node.nodeStyle; + const baseWidth = 200; + const baseHeight = 60; + const scale = 1.0; + const width = baseWidth * scale; + const height = baseHeight * scale; + + highlightCtx.save(); + + highlightCtx.shadowColor = '{{ CREWAI_ORANGE }}'; + highlightCtx.shadowBlur = 20; + highlightCtx.shadowOffsetX = 0; + highlightCtx.shadowOffsetY = 0; + + const radius = 20 * scale; + const rectX = canvasPos.x - width / 2; + const rectY = canvasPos.y - height / 2; + + highlightCtx.beginPath(); + highlightCtx.moveTo(rectX + radius, rectY); + highlightCtx.lineTo(rectX + width - radius, rectY); + highlightCtx.quadraticCurveTo(rectX + width, rectY, rectX + width, rectY + radius); + highlightCtx.lineTo(rectX + width, rectY + height - radius); + highlightCtx.quadraticCurveTo(rectX + width, rectY + height, rectX + width - radius, rectY + height); + highlightCtx.lineTo(rectX + radius, rectY + height); + highlightCtx.quadraticCurveTo(rectX, rectY + height, rectX, rectY + height - radius); + highlightCtx.lineTo(rectX, rectY + radius); + highlightCtx.quadraticCurveTo(rectX, rectY, rectX + radius, rectY); + highlightCtx.closePath(); + + highlightCtx.fillStyle = nodeStyle.bgColor; + highlightCtx.fill(); + + highlightCtx.shadowColor = 'transparent'; + highlightCtx.shadowBlur = 0; + + highlightCtx.strokeStyle = '{{ CREWAI_ORANGE }}'; + highlightCtx.lineWidth = nodeStyle.borderWidth * 2 * scale; + highlightCtx.stroke(); + + highlightCtx.fillStyle = nodeStyle.fontColor; + highlightCtx.font = `500 ${15 * scale}px Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`; + highlightCtx.textAlign = 'center'; + highlightCtx.textBaseline = 'middle'; + highlightCtx.fillText(nodeStyle.name, canvasPos.x, canvasPos.y); + + highlightCtx.restore(); + }); + } + + function highlightTriggeredBy(triggerNodeId) { + clearTriggeredByHighlight(); + + if (activeDrawerEdges && activeDrawerEdges.length > 0) { + activeDrawerEdges.forEach(function (edgeId) { + edges.update({ + id: edgeId, + width: 2, + opacity: 1.0 + }); + }); + activeDrawerEdges = []; + } + + if (!activeDrawerNodeId || !triggerNodeId) return; + + const allEdges = edges.get(); + let connectingEdges = []; + let actualTriggerNodeId = triggerNodeId; + + connectingEdges = allEdges.filter(edge => + edge.from === triggerNodeId && edge.to === activeDrawerNodeId + ); + + if (connectingEdges.length === 0) { + const incomingRouterEdges = allEdges.filter(edge => + edge.to === activeDrawerNodeId && edge.dashes + ); + + if (incomingRouterEdges.length > 0) { + incomingRouterEdges.forEach(function (edge) { + connectingEdges.push(edge); + actualTriggerNodeId = edge.from; + }); + } else { + const outgoingRouterEdges = allEdges.filter(edge => + edge.from === activeDrawerNodeId && edge.dashes + ); + + const nodeData = '{{ nodeData }}'; + for (const [nodeName, nodeInfo] of Object.entries(nodeData)) { + if (nodeInfo.trigger_methods && nodeInfo.trigger_methods.includes(triggerNodeId)) { + const edgeToTarget = outgoingRouterEdges.find(e => e.to === nodeName); + if (edgeToTarget) { + connectingEdges.push(edgeToTarget); + actualTriggerNodeId = nodeName; + break; + } + } + } + } + } + + if (connectingEdges.length === 0) return; + + highlightedNodes = [actualTriggerNodeId, activeDrawerNodeId]; + highlightedEdges = connectingEdges.map(e => e.id); + + const allNodesList = nodes.get(); + const nodeAnimDuration = 300; + const nodeAnimStart = performance.now(); + + function animateNodeOpacity() { + const elapsed = performance.now() - nodeAnimStart; + const progress = Math.min(elapsed / nodeAnimDuration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + + allNodesList.forEach(function (node) { + const currentOpacity = node.opacity !== undefined ? node.opacity : 1.0; + const targetOpacity = highlightedNodes.includes(node.id) ? 1.0 : 0.2; + const newOpacity = currentOpacity + (targetOpacity - currentOpacity) * eased; + + nodes.update({ + id: node.id, + opacity: newOpacity + }); + }); + + if (progress < 1) { + requestAnimationFrame(animateNodeOpacity); + } + } + + animateNodeOpacity(); + + const allEdgesList = edges.get(); + const edgeAnimDuration = 300; + const edgeAnimStart = performance.now(); + + function animateEdgeStyles() { + const elapsed = performance.now() - edgeAnimStart; + const progress = Math.min(elapsed / edgeAnimDuration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + + allEdgesList.forEach(function (edge) { + if (highlightedEdges.includes(edge.id)) { + const currentWidth = edge.width || 2; + const targetWidth = 8; + const newWidth = currentWidth + (targetWidth - currentWidth) * eased; + + const currentShadowSize = edge.shadow?.size || 4; + const targetShadowSize = 20; + const newShadowSize = currentShadowSize + (targetShadowSize - currentShadowSize) * eased; + + const updateData = { + id: edge.id, + hidden: false, + opacity: 1.0, + width: newWidth, + color: { + color: '{{ CREWAI_ORANGE }}', + highlight: '{{ CREWAI_ORANGE }}' + }, + shadow: { + enabled: true, + color: '{{ CREWAI_ORANGE }}', + size: newShadowSize, + x: 0, + y: 0 + } + }; + + if (edge.dashes) { + const scale = Math.sqrt(newWidth / 2); + updateData.dashes = [15 * scale, 10 * scale]; + } + + updateData.arrows = { + to: { + enabled: true, + scaleFactor: 0.8, + type: 'triangle' + } + }; + + updateData.color = { + color: '{{ CREWAI_ORANGE }}', + highlight: '{{ CREWAI_ORANGE }}', + hover: '{{ CREWAI_ORANGE }}', + inherit: 'to' + }; + + edges.update(updateData); + } else { + edges.update({ + id: edge.id, + hidden: false, + opacity: 1.0, + width: 1, + color: { + color: 'transparent', + highlight: 'transparent' + }, + shadow: { + enabled: false + }, + font: { + color: 'transparent', + background: 'transparent' + } + }); + } + }); + + if (progress < 1) { + requestAnimationFrame(animateEdgeStyles); + } + } + + animateEdgeStyles(); + + highlightCanvas.classList.add('visible'); + + setTimeout(function () { + drawHighlightLayer(); + }, 50); + } + + function clearTriggeredByHighlight() { + const allNodesList = nodes.get(); + const nodeRestoreAnimStart = performance.now(); + const nodeRestoreAnimDuration = 300; + + function animateNodeRestore() { + if (isAnimating) { + nodeRestoreAnimationId = null; + return; + } + + const elapsed = performance.now() - nodeRestoreAnimStart; + const progress = Math.min(elapsed / nodeRestoreAnimDuration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + + allNodesList.forEach(function (node) { + const currentOpacity = node.opacity !== undefined ? node.opacity : 1.0; + const targetOpacity = 1.0; + const newOpacity = currentOpacity + (targetOpacity - currentOpacity) * eased; + nodes.update({id: node.id, opacity: newOpacity}); + }); + + if (progress < 1) { + nodeRestoreAnimationId = requestAnimationFrame(animateNodeRestore); + } else { + nodeRestoreAnimationId = null; + } + } + + if (nodeRestoreAnimationId) { + cancelAnimationFrame(nodeRestoreAnimationId); + } + nodeRestoreAnimationId = requestAnimationFrame(animateNodeRestore); + + const allEdgesList = edges.get(); + const edgeRestoreAnimStart = performance.now(); + const edgeRestoreAnimDuration = 300; + + function animateEdgeRestore() { + if (isAnimating) { + edgeRestoreAnimationId = null; + return; + } + + const elapsed = performance.now() - edgeRestoreAnimStart; + const progress = Math.min(elapsed / edgeRestoreAnimDuration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + + allEdgesList.forEach(function (edge) { + if (activeDrawerEdges.includes(edge.id)) { + return; + } + + const defaultColor = edge.dashes || edge.label === 'AND' ? '{{ CREWAI_ORANGE }}' : '{{ GRAY }}'; + const currentOpacity = edge.opacity !== undefined ? edge.opacity : 1.0; + const currentWidth = edge.width !== undefined ? edge.width : 2; + const currentShadowSize = edge.shadow && edge.shadow.size !== undefined ? edge.shadow.size : 4; + + const targetOpacity = 1.0; + const targetWidth = 2; + const targetShadowSize = 4; + + const newOpacity = currentOpacity + (targetOpacity - currentOpacity) * eased; + const newWidth = currentWidth + (targetWidth - currentWidth) * eased; + const newShadowSize = currentShadowSize + (targetShadowSize - currentShadowSize) * eased; + + const updateData = { + id: edge.id, + hidden: false, + opacity: newOpacity, + width: newWidth, + color: { + color: defaultColor, + highlight: defaultColor + }, + shadow: { + enabled: true, + color: 'rgba(0,0,0,0.08)', + size: newShadowSize, + x: 1, + y: 1 + }, + font: { + color: 'transparent', + background: 'transparent' + } + }; + + if (edge.dashes) { + const scale = Math.sqrt(newWidth / 2); + updateData.dashes = [15 * scale, 10 * scale]; + } + + edges.update(updateData); + }); + + if (progress < 1) { + edgeRestoreAnimationId = requestAnimationFrame(animateEdgeRestore); + } else { + edgeRestoreAnimationId = null; + } + } + + if (edgeRestoreAnimationId) { + cancelAnimationFrame(edgeRestoreAnimationId); + } + edgeRestoreAnimationId = requestAnimationFrame(animateEdgeRestore); + + highlightedNodes = []; + highlightedEdges = []; + + highlightCanvas.style.transition = 'opacity 300ms ease-out'; + highlightCanvas.style.opacity = '0'; + setTimeout(function () { + highlightCanvas.classList.remove('visible'); + highlightCanvas.style.opacity = '1'; + highlightCanvas.style.transition = ''; + highlightCtx.clearRect(0, 0, highlightCanvas.width, highlightCanvas.height); + }, 300); + } + + network.on('afterDrawing', function () { + if (highlightCanvas.classList.contains('visible')) { + drawHighlightLayer(); + } + }); + + let hoveredNodeId = null; + let pressedNodeId = null; + let isClicking = false; + let activeDrawerNodeId = null; + let activeDrawerEdges = []; + + const edgeAnimations = {}; + + function animateEdgeWidth(edgeId, targetWidth, duration) { + if (edgeAnimations[edgeId]) { + cancelAnimationFrame(edgeAnimations[edgeId].frameId); + } + + const edge = edges.get(edgeId); + if (!edge) return; + + const startWidth = edge.width || 2; + const startTime = performance.now(); + + function animate() { + const currentTime = performance.now(); + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + const currentWidth = startWidth + (targetWidth - startWidth) * eased; + + edges.update({ + id: edgeId, + width: currentWidth + }); + + if (progress < 1) { + edgeAnimations[edgeId] = { + frameId: requestAnimationFrame(animate) + }; + } else { + delete edgeAnimations[edgeId]; + } + } + + animate(); + } + + network.on('hoverNode', function (params) { + const nodeId = params.node; + hoveredNodeId = nodeId; + document.body.style.cursor = 'pointer'; + network.redraw(); + }); + + network.on('blurNode', function (params) { + const nodeId = params.node; + if (hoveredNodeId === nodeId) { + hoveredNodeId = null; + } + document.body.style.cursor = 'default'; + network.redraw(); + }); + + let pressedEdges = []; + + network.on('selectNode', function (params) { + if (params.nodes.length > 0) { + const nodeId = params.nodes[0]; + pressedNodeId = nodeId; + + const connectedEdges = network.getConnectedEdges(nodeId); + pressedEdges = connectedEdges; + + network.redraw(); + } + }); + + network.on('deselectNode', function (params) { + if (pressedNodeId) { + const nodeId = pressedNodeId; + + setTimeout(function () { + if (isClicking) { + isClicking = false; + pressedNodeId = null; + pressedEdges = []; + return; + } + + pressedNodeId = null; + + pressedEdges.forEach(function (edgeId) { + if (!activeDrawerEdges.includes(edgeId)) { + animateEdgeWidth(edgeId, 2, 150); + } + }); + pressedEdges = []; + network.redraw(); + }, 10); + } + }); + let highlightedNodeId = null; + let highlightedSourceNodeId = null; + let highlightedEdgeId = null; + let highlightTimeout = null; + let originalNodeData = null; + let originalSourceNodeData = null; + let originalEdgeData = null; + let isAnimating = false; + + function clearHighlights() { + isAnimating = false; + + if (highlightTimeout) { + clearTimeout(highlightTimeout); + highlightTimeout = null; + } + + if (originalNodeData && originalNodeData.originalOpacities) { + originalNodeData.originalOpacities.forEach((opacity, nodeId) => { + nodes.update({ + id: nodeId, + opacity: opacity + }); + }); + } + + if (originalNodeData && originalNodeData.originalEdgesMap) { + originalNodeData.originalEdgesMap.forEach((edgeData, edgeId) => { + edges.update({ + id: edgeId, + opacity: edgeData.opacity, + width: edgeData.width, + color: edgeData.color + }); + }); + } + + if (highlightedNodeId) { + if (originalNodeData && originalNodeData.shadow) { + nodes.update({ + id: highlightedNodeId, + shadow: originalNodeData.shadow + }); + } else { + nodes.update({ + id: highlightedNodeId, + shadow: { + enabled: true, + color: 'rgba(0,0,0,0.1)', + size: 8, + x: 2, + y: 2 + } + }); + } + highlightedNodeId = null; + originalNodeData = null; + } + + if (highlightedEdgeId) { + if (originalEdgeData && originalEdgeData.shadow) { + edges.update({ + id: highlightedEdgeId, + shadow: originalEdgeData.shadow + }); + } else { + edges.update({ + id: highlightedEdgeId, + shadow: { + enabled: true, + color: 'rgba(0,0,0,0.08)', + size: 4, + x: 1, + y: 1 + } + }); + } + highlightedEdgeId = null; + originalEdgeData = null; + } + + if (highlightedSourceNodeId) { + if (originalSourceNodeData && originalSourceNodeData.shadow) { + nodes.update({ + id: highlightedSourceNodeId, + shadow: originalSourceNodeData.shadow + }); + } else { + nodes.update({ + id: highlightedSourceNodeId, + shadow: null + }); + } + highlightedSourceNodeId = null; + originalSourceNodeData = null; + } + } + + + function highlightPython(code) { + return Prism.highlight(code, Prism.languages.python, 'python'); + } + + function highlightJson(jsonString) { + let escaped = jsonString + .replace(/&/g, '&') + .replace(//g, '>'); + + let result = escaped + .replace(/(")([^&]+?)(")(\\s*)(:)/g, '$1$2$3$4$5') + .replace(/(:)(\\s*)(")([^&]*?)(")/g, '$1$2$3$4$5') + .replace(/(:)(\\s*)([-+]?\\d+\\.?\\d*)/g, '$1$2$3') + .replace(/:\\s*(true|false)\\b/g, ': $1') + .replace(/:\\s*null\\b/g, ': null') + .replace(/([{\\[\\]}])/g, '$1') + .replace(/,/g, ','); + + return result; + } + + network.on('click', function (params) { + if (params.nodes.length > 0) { + const nodeId = params.nodes[0]; + const node = nodes.get(nodeId); + const nodeData = '{{ nodeData }}'; + const metadata = nodeData[nodeId]; + + isClicking = true; + + clearTriggeredByHighlight(); + if (activeDrawerNodeId && activeDrawerNodeId !== nodeId) { + activeDrawerEdges.forEach(function (edgeId) { + animateEdgeWidth(edgeId, 2, 200); + }); + } + + activeDrawerNodeId = nodeId; + const connectedEdges = network.getConnectedEdges(nodeId); + activeDrawerEdges = connectedEdges; + + setTimeout(function () { + activeDrawerEdges.forEach(function (edgeId) { + animateEdgeWidth(edgeId, 5, 200); + }); + + network.redraw(); + }, 15); + + openDrawer(nodeId, metadata); + clearHighlights(); + } else if (params.edges.length === 0) { + clearHighlights(); + closeDrawer(); + } + }); + + function openDrawer(nodeName, metadata) { + const drawer = document.getElementById('drawer'); + const overlay = document.getElementById('drawer-overlay'); + const drawerTitle = document.getElementById('drawer-node-name'); + const drawerContent = document.getElementById('drawer-content'); + const openIdeButton = document.getElementById('drawer-open-ide'); + + drawerTitle.textContent = nodeName; + if (metadata.source_file && metadata.source_start_line) { + openIdeButton.style.display = 'flex'; + openIdeButton.onclick = function () { + const filePath = metadata.source_file; + const lineNum = metadata.source_start_line; + + function detectIDE() { + const savedIDE = localStorage.getItem('preferred_ide'); + if (savedIDE) return savedIDE; + + if (navigator.userAgent.includes('JetBrains')) return 'jetbrains'; + + return 'auto'; + } + + const detectedIDE = detectIDE(); + let ideUrl; + + if (detectedIDE === 'pycharm' || detectedIDE === 'auto') { + ideUrl = `pycharm://open?file=${filePath}&line=${lineNum}`; + } else if (detectedIDE === 'vscode') { + ideUrl = `vscode://file/${filePath}:${lineNum}`; + } else if (detectedIDE === 'jetbrains') { + ideUrl = `jetbrains://open?file=${encodeURIComponent(filePath)}&line=${lineNum}`; + } else { + ideUrl = `pycharm://open?file=${filePath}&line=${lineNum}`; + } + const link = document.createElement('a'); + link.href = ideUrl; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + const fallbackText = `${filePath}:${lineNum}`; + navigator.clipboard.writeText(fallbackText).catch(function (err) { + console.error('Failed to copy:', err); + }); + }; + } else { + openIdeButton.style.display = 'none'; + } + + let content = ''; + + let metadataContent = ''; + const nodeType = metadata.type || 'unknown'; + const typeBadgeColor = nodeType === 'start' || nodeType === 'router' ? '{{ CREWAI_ORANGE }}' : '{{ DARK_GRAY }}'; + metadataContent += ` +
+ `; + + if (metadata.condition_type) { + let conditionColor, conditionBg; + if (metadata.condition_type === 'AND') { + conditionColor = '{{ CREWAI_ORANGE }}'; + conditionBg = 'rgba(255,90,80,0.12)'; + } else if (metadata.condition_type === 'IF') { + conditionColor = '{{ CREWAI_ORANGE }}'; + conditionBg = 'rgba(255,90,80,0.18)'; + } else { + conditionColor = '{{ GRAY }}'; + conditionBg = 'rgba(102,102,102,0.12)'; + } + metadataContent += ` +
+ `; + } + + if (metadata.trigger_methods && metadata.trigger_methods.length > 0) { + metadataContent += ` +
+ `; + } + + if (metadata.router_paths && metadata.router_paths.length > 0) { + metadataContent += ` +
+ `; + } + + if (metadataContent) { + content += `
`; + } + + if (metadata.source_code) { + let lines = metadata.source_lines || metadata.source_code.split('\n'); + if (metadata.source_lines) { + lines = lines.map(function(line) { return line.replace(/\n$/, ''); }); + } + + let minIndent = Infinity; + lines.forEach(function (line) { + if (line.trim().length > 0) { + const match = line.match(/^\s*/); + const indent = match ? match[0].length : 0; + minIndent = Math.min(minIndent, indent); + } + }); + + const dedentedLines = lines.map(function (line) { + if (line.trim().length === 0) return ''; + return line.substring(minIndent); + }); + + const startLine = metadata.source_start_line || 1; + + const codeToHighlight = dedentedLines.join('\n').trim(); + + const highlightedCode = highlightPython(codeToHighlight); + + const highlightedLines = highlightedCode.split('\n'); + const numberedLines = highlightedLines.map(function (line, index) { + const lineNum = startLine + index; + return '
'; + }).join(''); + + const titleText = 'Source Code'; + + let classSection = ''; + if (metadata.class_signature) { + const highlightedClass = highlightPython(metadata.class_signature); + + let highlightedClassSignature = highlightedClass; + if (metadata.class_line_number) { + highlightedClassSignature = '' + metadata.class_line_number + '' + highlightedClass; + } + + classSection = ` +
${highlightedClassSignature}
+ + `; + } + + content += ` +
+ `; + } + + drawerContent.innerHTML = content; + + const copyButtons = drawerContent.querySelectorAll('.code-copy-button'); + copyButtons.forEach(function (button) { + button.addEventListener('click', function () { + const codeText = button.getAttribute('data-code'); + + navigator.clipboard.writeText(codeText).then(function () { + button.classList.add('copied'); + setTimeout(function () { + button.classList.remove('copied'); + }, 2000); + }).catch(function (err) { + console.error('Failed to copy:', err); + }); + }); + }); + + const accordionHeaders = drawerContent.querySelectorAll('.accordion-header'); + accordionHeaders.forEach(function (header) { + header.addEventListener('click', function () { + const accordionSection = header.closest('.accordion-section'); + + accordionSection.classList.toggle('expanded'); + }); + }); + + const triggerLinks = drawerContent.querySelectorAll('.drawer-code-link'); + triggerLinks.forEach(function (link) { + link.addEventListener('click', function () { + const targetNodeId = link.getAttribute('data-node-id'); + const currentNodeId = nodeName; + + if (targetNodeId) { + if (nodeRestoreAnimationId) { + cancelAnimationFrame(nodeRestoreAnimationId); + nodeRestoreAnimationId = null; + } + if (edgeRestoreAnimationId) { + cancelAnimationFrame(edgeRestoreAnimationId); + edgeRestoreAnimationId = null; + } + + if (isAnimating) { + clearHighlights(); + } + + const allCurrentEdges = edges.get(); + allCurrentEdges.forEach(edge => { + if (activeDrawerEdges.includes(edge.id)) { + return; + } + edges.update({ + id: edge.id, + opacity: 1.0, + width: 2 + }); + }); + + let edge = edges.get().find(function (e) { + return e.from === targetNodeId && e.to === currentNodeId; + }); + + let isForwardAnimation = false; + if (!edge) { + edge = edges.get().find(function (e) { + return e.from === currentNodeId && e.to === targetNodeId; + }); + isForwardAnimation = true; + } + + let actualTargetNodeId = targetNodeId; + let intermediateNodeId = null; + const targetNode = nodes.get(targetNodeId); + + if (!targetNode) { + const allNodesData = '{{ nodeData }}'; + + let routerNodeId = null; + for (const nodeId in allNodesData) { + const nodeMetadata = allNodesData[nodeId]; + if (nodeMetadata.router_paths && nodeMetadata.router_paths.includes(targetNodeId)) { + routerNodeId = nodeId; + break; + } + } + + if (routerNodeId) { + const allEdges = edges.get(); + edge = allEdges.find(function (e) { + return e.from === routerNodeId && e.to === currentNodeId; + }); + + if (edge) { + actualTargetNodeId = routerNodeId; + isForwardAnimation = false; + } else { + + const listenersOfPath = []; + for (const nodeId in allNodesData) { + const nodeMetadata = allNodesData[nodeId]; + if (nodeId !== currentNodeId && nodeMetadata.trigger_methods && nodeMetadata.trigger_methods.includes(targetNodeId)) { + listenersOfPath.push(nodeId); + } + } + + for (let i = 0; i < listenersOfPath.length; i++) { + const listenerNodeId = listenersOfPath[i]; + const edgeToCurrentNode = allEdges.find(function (e) { + return e.from === listenerNodeId && e.to === currentNodeId; + }); + + if (edgeToCurrentNode) { + actualTargetNodeId = routerNodeId; + intermediateNodeId = listenerNodeId; + isForwardAnimation = true; + edge = allEdges.find(function (e) { + return e.from === routerNodeId && e.to === listenerNodeId; + }); + break; + } + } + } + } + + if (!edge) { + for (const nodeId in allNodesData) { + const nodeMetadata = allNodesData[nodeId]; + if (nodeMetadata.trigger_methods && nodeMetadata.trigger_methods.includes(targetNodeId)) { + actualTargetNodeId = nodeId; + + const allEdges = edges.get(); + edge = allEdges.find(function (e) { + return e.from === currentNodeId && e.to === actualTargetNodeId; + }); + + break; + } + } + } + } + + let nodesToHide = []; + let edgesToHide = []; + let edgesToRestore = []; + + const nodeData = nodes.get(actualTargetNodeId); + if (nodeData) { + let animationSourceId, animationTargetId; + if (intermediateNodeId) { + animationSourceId = actualTargetNodeId; + animationTargetId = intermediateNodeId; + } else { + animationSourceId = isForwardAnimation ? currentNodeId : actualTargetNodeId; + animationTargetId = isForwardAnimation ? actualTargetNodeId : currentNodeId; + } + + const allNodes = nodes.get(); + const originalNodeOpacities = new Map(); + + const activeNodeIds = [animationSourceId, animationTargetId]; + if (intermediateNodeId) { + activeNodeIds.push(intermediateNodeId); + } + if (currentNodeId) { + activeNodeIds.push(currentNodeId); + } + + allNodes.forEach(node => { + originalNodeOpacities.set(node.id, node.opacity !== undefined ? node.opacity : 1); + if (activeNodeIds.includes(node.id)) { + nodes.update({ + id: node.id, + opacity: 1.0 + }); + } else { + nodes.update({ + id: node.id, + opacity: 0.2 + }); + } + }); + + const allEdges = edges.get(); + const originalEdgesMap = new Map(); + allEdges.forEach(edge => { + originalEdgesMap.set(edge.id, { + opacity: edge.opacity !== undefined ? edge.opacity : 1.0, + width: edge.width || 2, + color: edge.color + }); + edges.update({ + id: edge.id, + opacity: 0.2 + }); + }); + + const sourceNodeData = nodes.get(animationSourceId); + const targetNodeData = nodes.get(animationTargetId); + const sourceOriginalShadow = sourceNodeData ? sourceNodeData.shadow : null; + + originalNodeData = { + shadow: targetNodeData ? targetNodeData.shadow : null, + opacity: targetNodeData ? targetNodeData.opacity : 1, + originalOpacities: originalNodeOpacities, + originalEdgesMap: originalEdgesMap + }; + originalSourceNodeData = { + shadow: sourceOriginalShadow + }; + highlightedNodeId = animationTargetId; + highlightedSourceNodeId = animationSourceId; + isAnimating = true; + + const phase1Duration = 150; + const phase1Start = Date.now(); + + function animateSourcePulse() { + if (!isAnimating) { + return; + } + + const elapsed = Date.now() - phase1Start; + const progress = Math.min(elapsed / phase1Duration, 1); + + const eased = progress < 0.5 + ? 2 * progress * progress + : 1 - Math.pow(-2 * progress + 2, 2) / 2; + + const pulseSize = eased * 15; + + nodes.update({ + id: animationSourceId, + shadow: { + enabled: true, + color: '{{ CREWAI_ORANGE }}', + size: pulseSize, + x: 0, + y: 0 + } + }); + + if (progress < 1 && isAnimating) { + requestAnimationFrame(animateSourcePulse); + } else { + startPhase2(); + } + } + + function startPhase2() { + const phase2Duration = 400; + const phase2Start = Date.now(); + + if (edge) { + originalEdgeData = { + shadow: edge.shadow, + level: edge.level + }; + highlightedEdgeId = edge.id; + } + + let secondHopEdge = null; + if (intermediateNodeId && currentNodeId) { + const allEdges = edges.get(); + secondHopEdge = allEdges.find(function (e) { + return e.from === intermediateNodeId && e.to === currentNodeId; + }); + } + + function animateTravel() { + if (!isAnimating) return; + + const elapsed = Date.now() - phase2Start; + const progress = Math.min(elapsed / phase2Duration, 1); + + const eased = 1 - Math.pow(1 - progress, 3); + + if (edge) { + const edgeGlowSize = eased * 15; + const edgeWidth = 2 + (eased * 6); + edges.update({ + id: edge.id, + width: edgeWidth, + opacity: 1.0, + color: { + color: '{{ CREWAI_ORANGE }}', + highlight: '{{ CREWAI_ORANGE }}' + }, + shadow: { + enabled: true, + color: '{{ CREWAI_ORANGE }}', + size: edgeGlowSize, + x: 0, + y: 0 + } + }); + } + + if (secondHopEdge && progress > 0.5) { + const secondHopProgress = (progress - 0.5) / 0.5; + const secondHopEased = 1 - Math.pow(1 - secondHopProgress, 3); + const secondEdgeGlowSize = secondHopEased * 15; + const secondEdgeWidth = 2 + (secondHopEased * 6); + + edges.update({ + id: secondHopEdge.id, + width: secondEdgeWidth, + opacity: 1.0, + color: { + color: '{{ CREWAI_ORANGE }}', + highlight: '{{ CREWAI_ORANGE }}' + }, + shadow: { + enabled: true, + color: '{{ CREWAI_ORANGE }}', + size: secondEdgeGlowSize, + x: 0, + y: 0 + } + }); + } + + if (progress > 0.3) { + const nodeProgress = (progress - 0.3) / 0.7; + const nodeEased = 1 - Math.pow(1 - nodeProgress, 3); + const glowSize = nodeEased * 25; + + nodes.update({ + id: animationTargetId, + shadow: { + enabled: true, + color: '{{ CREWAI_ORANGE }}', + size: glowSize, + x: 0, + y: 0 + } + }); + } + + if (progress < 1 && isAnimating) { + requestAnimationFrame(animateTravel); + } else { + nodes.update({ + id: animationSourceId, + shadow: null + }); + nodes.update({ + id: animationTargetId, + shadow: null + }); + } + } + + animateTravel(); + } + + animateSourcePulse(); + } else if (edge) { + clearHighlights(); + + originalEdgeData = { + shadow: edge.shadow + }; + highlightedEdgeId = edge.id; + isAnimating = true; + + const animationDuration = 300; + const startTime = Date.now(); + + function animateEdgeGlow() { + if (!isAnimating) return; + + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / animationDuration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + const edgeGlowSize = eased * 15; + + edges.update({ + id: edge.id, + shadow: { + enabled: true, + color: '{{ CREWAI_ORANGE }}', + size: edgeGlowSize, + x: 0, + y: 0 + } + }); + + if (progress < 1 && isAnimating) { + requestAnimationFrame(animateEdgeGlow); + } + } + + animateEdgeGlow(); + } + + } + }); + }); + + const triggeredByLinks = drawerContent.querySelectorAll('.drawer-code-link[data-node-id]'); + triggeredByLinks.forEach(function (link) { + link.addEventListener('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + const triggerNodeId = this.getAttribute('data-node-id'); + highlightTriggeredBy(triggerNodeId); + }); + }); + + drawer.style.visibility = 'visible'; + + const wasAlreadyOpen = drawer.classList.contains('open'); + requestAnimationFrame(function () { + drawer.classList.add('open'); + overlay.classList.add('visible'); + document.querySelector('.nav-controls').classList.add('drawer-open'); + + if (!wasAlreadyOpen) { + setTimeout(function () { + const currentScale = network.getScale(); + const currentPosition = network.getViewPosition(); + + const drawerWidth = 400; + const offsetX = (drawerWidth * 0.3) / currentScale; + network.moveTo({ + position: { + x: currentPosition.x + offsetX, + y: currentPosition.y + }, + scale: currentScale, + animation: { + duration: 300, + easingFunction: 'easeInOutQuad' + } + }); + }, 50); // Small delay to let drawer animation start + } + }); + } + + function closeDrawer() { + const drawer = document.getElementById('drawer'); + const overlay = document.getElementById('drawer-overlay'); + drawer.classList.remove('open'); + overlay.classList.remove('visible'); + document.querySelector('.nav-controls').classList.remove('drawer-open'); + + clearTriggeredByHighlight(); + if (activeDrawerNodeId) { + activeDrawerEdges.forEach(function (edgeId) { + animateEdgeWidth(edgeId, 2, 200); + }); + activeDrawerNodeId = null; + activeDrawerEdges = []; + + network.redraw(); + } + setTimeout(function () { + network.fit({ + animation: { + duration: 300, + easingFunction: 'easeInOutQuad' + } + }); + }, 50); + + setTimeout(function () { + if (!drawer.classList.contains('open')) { + drawer.style.visibility = 'hidden'; + } + }, 300); + } + + document.getElementById('drawer-overlay').addEventListener('click', function () { + closeDrawer(); + clearHighlights(); + }); + document.getElementById('drawer-close').addEventListener('click', closeDrawer); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + closeDrawer(); + } + }); + + network.once('stabilizationIterationsDone', function () { + network.fit(); + }); + + + document.getElementById('zoom-in').addEventListener('click', function () { + const scale = network.getScale(); + network.moveTo({ + scale: scale * 1.2, + animation: { + duration: 200, + easingFunction: 'easeInOutQuad' + } + }); + }); + + document.getElementById('zoom-out').addEventListener('click', function () { + const scale = network.getScale(); + network.moveTo({ + scale: scale * 0.8, + animation: { + duration: 200, + easingFunction: 'easeInOutQuad' + } + }); + }); + + document.getElementById('fit').addEventListener('click', function () { + network.fit({ + animation: { + duration: 300, + easingFunction: 'easeInOutQuad' + } + }); + }); + + document.getElementById('export-png').addEventListener('click', function () { + const script = document.createElement('script'); + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'; + script.onload = function () { + html2canvas(document.getElementById('network-container')).then(function (canvas) { + const link = document.createElement('a'); + link.download = 'flow_dag.png'; + link.href = canvas.toDataURL(); + link.click(); + }); + }; + document.head.appendChild(script); + }); + + document.getElementById('export-pdf').addEventListener('click', function () { + const script1 = document.createElement('script'); + script1.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'; + script1.onload = function () { + const script2 = document.createElement('script'); + script2.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js'; + script2.onload = function () { + html2canvas(document.getElementById('network-container')).then(function (canvas) { + const imgData = canvas.toDataURL('image/png'); + const {jsPDF} = window.jspdf; + const pdf = new jsPDF({ + orientation: canvas.width > canvas.height ? 'landscape' : 'portrait', + unit: 'px', + format: [canvas.width, canvas.height] + }); + pdf.addImage(imgData, 'PNG', 0, 0, canvas.width, canvas.height); + pdf.save('flow_dag.pdf'); + }); + }; + document.head.appendChild(script2); + }; + document.head.appendChild(script1); + }); + + document.getElementById('export-json').addEventListener('click', function () { + const dagData = '{{ dagData }}'; + const dataStr = JSON.stringify(dagData, null, 2); + const blob = new Blob([dataStr], {type: 'application/json'}); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.download = 'flow_dag.json'; + link.href = url; + link.click(); + URL.revokeObjectURL(url); + }); + + const themeToggle = document.getElementById('theme-toggle'); + const htmlElement = document.documentElement; + + function getCSSVariable(name) { + return getComputedStyle(htmlElement).getPropertyValue(name).trim(); + } + + function updateEdgeLabelColors() { + edges.forEach(function (edge) { + edges.update({ + id: edge.id, + font: { + color: 'transparent', + background: 'transparent' + } + }); + }); + } + + const savedTheme = localStorage.getItem('theme') || 'light'; + if (savedTheme === 'dark') { + htmlElement.setAttribute('data-theme', 'dark'); + themeToggle.textContent = '☀️'; + themeToggle.title = 'Toggle Light Mode'; + setTimeout(updateEdgeLabelColors, 0); + } + + themeToggle.addEventListener('click', function () { + const currentTheme = htmlElement.getAttribute('data-theme'); + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + + if (newTheme === 'dark') { + htmlElement.setAttribute('data-theme', 'dark'); + themeToggle.textContent = '☀️'; + themeToggle.title = 'Toggle Light Mode'; + } else { + htmlElement.removeAttribute('data-theme'); + themeToggle.textContent = '🌙'; + themeToggle.title = 'Toggle Dark Mode'; + } + + localStorage.setItem('theme', newTheme); + setTimeout(updateEdgeLabelColors, 50); + }); + } catch (e) { + console.error(e); + } +})() 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 new file mode 100644 index 000000000..dcec5e2c4 --- /dev/null +++ b/lib/crewai/src/crewai/flow/visualization/assets/interactive_flow.html.j2 @@ -0,0 +1,115 @@ + + +
+
+ + + + + + + + +
+