diff --git a/docs/en/mcp/overview.mdx b/docs/en/mcp/overview.mdx index 63bdab5d6..d8eb2743c 100644 --- a/docs/en/mcp/overview.mdx +++ b/docs/en/mcp/overview.mdx @@ -11,9 +11,13 @@ The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) CrewAI offers **two approaches** for MCP integration: -### Simple DSL Integration** (Recommended) +### 🚀 **Simple DSL Integration** (Recommended) -Use the `mcps` field directly on agents for seamless MCP tool integration: +Use the `mcps` field directly on agents for seamless MCP tool integration. The DSL supports both **string references** (for quick setup) and **structured configurations** (for full control). + +#### String-Based References (Quick Setup) + +Perfect for remote HTTPS servers and CrewAI AMP marketplace: ```python from crewai import Agent @@ -32,6 +36,46 @@ agent = Agent( # MCP tools are now automatically available to your agent! ``` +#### Structured Configurations (Full Control) + +For complete control over connection settings, tool filtering, and all transport types: + +```python +from crewai import Agent +from crewai.mcp import MCPServerStdio, MCPServerHTTP, MCPServerSSE +from crewai.mcp.filters import create_static_tool_filter + +agent = Agent( + role="Advanced Research Analyst", + goal="Research with full control over MCP connections", + backstory="Expert researcher with advanced tool access", + mcps=[ + # Stdio transport for local servers + MCPServerStdio( + command="npx", + args=["-y", "@modelcontextprotocol/server-filesystem"], + env={"API_KEY": "your_key"}, + tool_filter=create_static_tool_filter( + allowed_tool_names=["read_file", "list_directory"] + ), + cache_tools_list=True, + ), + # HTTP/Streamable HTTP transport for remote servers + MCPServerHTTP( + url="https://api.example.com/mcp", + headers={"Authorization": "Bearer your_token"}, + streamable=True, + cache_tools_list=True, + ), + # SSE transport for real-time streaming + MCPServerSSE( + url="https://stream.example.com/mcp/sse", + headers={"Authorization": "Bearer your_token"}, + ), + ] +) +``` + ### 🔧 **Advanced: MCPServerAdapter** (For Complex Scenarios) For advanced use cases requiring manual connection management, the `crewai-tools` library provides the `MCPServerAdapter` class. @@ -68,12 +112,14 @@ uv pip install 'crewai-tools[mcp]' ## Quick Start: Simple DSL Integration -The easiest way to integrate MCP servers is using the `mcps` field on your agents: +The easiest way to integrate MCP servers is using the `mcps` field on your agents. You can use either string references or structured configurations. + +### Quick Start with String References ```python from crewai import Agent, Task, Crew -# Create agent with MCP tools +# Create agent with MCP tools using string references research_agent = Agent( role="Research Analyst", goal="Find and analyze information using advanced search tools", @@ -96,13 +142,53 @@ crew = Crew(agents=[research_agent], tasks=[research_task]) result = crew.kickoff() ``` +### Quick Start with Structured Configurations + +```python +from crewai import Agent, Task, Crew +from crewai.mcp import MCPServerStdio, MCPServerHTTP, MCPServerSSE + +# Create agent with structured MCP configurations +research_agent = Agent( + role="Research Analyst", + goal="Find and analyze information using advanced search tools", + backstory="Expert researcher with access to multiple data sources", + mcps=[ + # Local stdio server + MCPServerStdio( + command="python", + args=["local_server.py"], + env={"API_KEY": "your_key"}, + ), + # Remote HTTP server + MCPServerHTTP( + url="https://api.research.com/mcp", + headers={"Authorization": "Bearer your_token"}, + ), + ] +) + +# Create task +research_task = Task( + description="Research the latest developments in AI agent frameworks", + expected_output="Comprehensive research report with citations", + agent=research_agent +) + +# Create and run crew +crew = Crew(agents=[research_agent], tasks=[research_task]) +result = crew.kickoff() +``` + That's it! The MCP tools are automatically discovered and available to your agent. ## MCP Reference Formats -The `mcps` field supports various reference formats for maximum flexibility: +The `mcps` field supports both **string references** (for quick setup) and **structured configurations** (for full control). You can mix both formats in the same list. -### External MCP Servers +### String-Based References + +#### External MCP Servers ```python mcps=[ @@ -117,7 +203,7 @@ mcps=[ ] ``` -### CrewAI AMP Marketplace +#### CrewAI AMP Marketplace ```python mcps=[ @@ -133,17 +219,166 @@ mcps=[ ] ``` -### Mixed References +### Structured Configurations + +#### Stdio Transport (Local Servers) + +Perfect for local MCP servers that run as processes: ```python +from crewai.mcp import MCPServerStdio +from crewai.mcp.filters import create_static_tool_filter + mcps=[ - "https://external-api.com/mcp", # External server - "https://weather.service.com/mcp#forecast", # Specific external tool - "crewai-amp:financial-insights", # AMP service - "crewai-amp:data-analysis#sentiment_tool" # Specific AMP tool + MCPServerStdio( + command="npx", + args=["-y", "@modelcontextprotocol/server-filesystem"], + env={"API_KEY": "your_key"}, + tool_filter=create_static_tool_filter( + allowed_tool_names=["read_file", "write_file"] + ), + cache_tools_list=True, + ), + # Python-based server + MCPServerStdio( + command="python", + args=["path/to/server.py"], + env={"UV_PYTHON": "3.12", "API_KEY": "your_key"}, + ), ] ``` +#### HTTP/Streamable HTTP Transport (Remote Servers) + +For remote MCP servers over HTTP/HTTPS: + +```python +from crewai.mcp import MCPServerHTTP + +mcps=[ + # Streamable HTTP (default) + MCPServerHTTP( + url="https://api.example.com/mcp", + headers={"Authorization": "Bearer your_token"}, + streamable=True, + cache_tools_list=True, + ), + # Standard HTTP + MCPServerHTTP( + url="https://api.example.com/mcp", + headers={"Authorization": "Bearer your_token"}, + streamable=False, + ), +] +``` + +#### SSE Transport (Real-Time Streaming) + +For remote servers using Server-Sent Events: + +```python +from crewai.mcp import MCPServerSSE + +mcps=[ + MCPServerSSE( + url="https://stream.example.com/mcp/sse", + headers={"Authorization": "Bearer your_token"}, + cache_tools_list=True, + ), +] +``` + +### Mixed References + +You can combine string references and structured configurations: + +```python +from crewai.mcp import MCPServerStdio, MCPServerHTTP + +mcps=[ + # String references + "https://external-api.com/mcp", # External server + "crewai-amp:financial-insights", # AMP service + + # Structured configurations + MCPServerStdio( + command="npx", + args=["-y", "@modelcontextprotocol/server-filesystem"], + ), + MCPServerHTTP( + url="https://api.example.com/mcp", + headers={"Authorization": "Bearer token"}, + ), +] +``` + +### Tool Filtering + +Structured configurations support advanced tool filtering: + +```python +from crewai.mcp import MCPServerStdio +from crewai.mcp.filters import create_static_tool_filter, create_dynamic_tool_filter, ToolFilterContext + +# Static filtering (allow/block lists) +static_filter = create_static_tool_filter( + allowed_tool_names=["read_file", "write_file"], + blocked_tool_names=["delete_file"], +) + +# Dynamic filtering (context-aware) +def dynamic_filter(context: ToolFilterContext, tool: dict) -> bool: + # Block dangerous tools for certain agent roles + if context.agent.role == "Code Reviewer": + if "delete" in tool.get("name", "").lower(): + return False + return True + +mcps=[ + MCPServerStdio( + command="npx", + args=["-y", "@modelcontextprotocol/server-filesystem"], + tool_filter=static_filter, # or dynamic_filter + ), +] +``` + +## Configuration Parameters + +Each transport type supports specific configuration options: + +### MCPServerStdio Parameters + +- **`command`** (required): Command to execute (e.g., `"python"`, `"node"`, `"npx"`, `"uvx"`) +- **`args`** (optional): List of command arguments (e.g., `["server.py"]` or `["-y", "@mcp/server"]`) +- **`env`** (optional): Dictionary of environment variables to pass to the process +- **`tool_filter`** (optional): Tool filter function for filtering available tools +- **`cache_tools_list`** (optional): Whether to cache the tool list for faster subsequent access (default: `False`) + +### MCPServerHTTP Parameters + +- **`url`** (required): Server URL (e.g., `"https://api.example.com/mcp"`) +- **`headers`** (optional): Dictionary of HTTP headers for authentication or other purposes +- **`streamable`** (optional): Whether to use streamable HTTP transport (default: `True`) +- **`tool_filter`** (optional): Tool filter function for filtering available tools +- **`cache_tools_list`** (optional): Whether to cache the tool list for faster subsequent access (default: `False`) + +### MCPServerSSE Parameters + +- **`url`** (required): Server URL (e.g., `"https://api.example.com/mcp/sse"`) +- **`headers`** (optional): Dictionary of HTTP headers for authentication or other purposes +- **`tool_filter`** (optional): Tool filter function for filtering available tools +- **`cache_tools_list`** (optional): Whether to cache the tool list for faster subsequent access (default: `False`) + +### Common Parameters + +All transport types support: +- **`tool_filter`**: Filter function to control which tools are available. Can be: + - `None` (default): All tools are available + - Static filter: Created with `create_static_tool_filter()` for allow/block lists + - Dynamic filter: Created with `create_dynamic_tool_filter()` for context-aware filtering +- **`cache_tools_list`**: When `True`, caches the tool list after first discovery to improve performance on subsequent connections + ## Key Features - 🔄 **Automatic Tool Discovery**: Tools are automatically discovered and integrated @@ -152,26 +387,47 @@ mcps=[ - 🛡️ **Error Resilience**: Graceful handling of unavailable servers - ⏱️ **Timeout Protection**: Built-in timeouts prevent hanging connections - 📊 **Transparent Integration**: Works seamlessly with existing CrewAI features +- 🔧 **Full Transport Support**: Stdio, HTTP/Streamable HTTP, and SSE transports +- 🎯 **Advanced Filtering**: Static and dynamic tool filtering capabilities +- 🔐 **Flexible Authentication**: Support for headers, environment variables, and query parameters ## Error Handling -The MCP DSL integration is designed to be resilient: +The MCP DSL integration is designed to be resilient and handles failures gracefully: ```python +from crewai import Agent +from crewai.mcp import MCPServerStdio, MCPServerHTTP + agent = Agent( role="Resilient Agent", goal="Continue working despite server issues", backstory="Agent that handles failures gracefully", mcps=[ + # String references "https://reliable-server.com/mcp", # Will work "https://unreachable-server.com/mcp", # Will be skipped gracefully - "https://slow-server.com/mcp", # Will timeout gracefully - "crewai-amp:working-service" # Will work + "crewai-amp:working-service", # Will work + + # Structured configs + MCPServerStdio( + command="python", + args=["reliable_server.py"], # Will work + ), + MCPServerHTTP( + url="https://slow-server.com/mcp", # Will timeout gracefully + ), ] ) # Agent will use tools from working servers and log warnings for failing ones ``` +All connection errors are handled gracefully: +- **Connection failures**: Logged as warnings, agent continues with available tools +- **Timeout errors**: Connections timeout after 30 seconds (configurable) +- **Authentication errors**: Logged clearly for debugging +- **Invalid configurations**: Validation errors are raised at agent creation time + ## Advanced: MCPServerAdapter For complex scenarios requiring manual connection management, use the `MCPServerAdapter` class from `crewai-tools`. Using a Python context manager (`with` statement) is the recommended approach as it automatically handles starting and stopping the connection to the MCP server. diff --git a/lib/crewai/src/crewai/agent/core.py b/lib/crewai/src/crewai/agent/core.py index 02fad524f..132fd9fcd 100644 --- a/lib/crewai/src/crewai/agent/core.py +++ b/lib/crewai/src/crewai/agent/core.py @@ -40,6 +40,16 @@ from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource from crewai.knowledge.utils.knowledge_utils import extract_knowledge_context from crewai.lite_agent import LiteAgent from crewai.llms.base_llm import BaseLLM +from crewai.mcp import ( + MCPClient, + MCPServerConfig, + MCPServerHTTP, + MCPServerSSE, + MCPServerStdio, +) +from crewai.mcp.transports.http import HTTPTransport +from crewai.mcp.transports.sse import SSETransport +from crewai.mcp.transports.stdio import StdioTransport from crewai.memory.contextual.contextual_memory import ContextualMemory from crewai.rag.embeddings.types import EmbedderConfig from crewai.security.fingerprint import Fingerprint @@ -108,6 +118,7 @@ class Agent(BaseAgent): """ _times_executed: int = PrivateAttr(default=0) + _mcp_clients: list[Any] = PrivateAttr(default_factory=list) max_execution_time: int | None = Field( default=None, description="Maximum execution time for an agent to execute a task", @@ -526,6 +537,9 @@ class Agent(BaseAgent): self, event=AgentExecutionCompletedEvent(agent=self, task=task, output=result), ) + + self._cleanup_mcp_clients() + return result def _execute_with_timeout(self, task_prompt: str, task: Task, timeout: int) -> Any: @@ -649,30 +663,70 @@ class Agent(BaseAgent): self._logger.log("error", f"Error getting platform tools: {e!s}") return [] - def get_mcp_tools(self, mcps: list[str]) -> list[BaseTool]: - """Convert MCP server references to CrewAI tools.""" + def get_mcp_tools(self, mcps: list[str | MCPServerConfig]) -> list[BaseTool]: + """Convert MCP server references/configs to CrewAI tools. + + Supports both string references (backwards compatible) and structured + configuration objects (MCPServerStdio, MCPServerHTTP, MCPServerSSE). + + Args: + mcps: List of MCP server references (strings) or configurations. + + Returns: + List of BaseTool instances from MCP servers. + """ all_tools = [] + clients = [] - for mcp_ref in mcps: - try: - if mcp_ref.startswith("crewai-amp:"): - tools = self._get_amp_mcp_tools(mcp_ref) - elif mcp_ref.startswith("https://"): - tools = self._get_external_mcp_tools(mcp_ref) - else: - continue + for mcp_config in mcps: + if isinstance(mcp_config, str): + tools = self._get_mcp_tools_from_string(mcp_config) + else: + tools, client = self._get_native_mcp_tools(mcp_config) + if client: + clients.append(client) - all_tools.extend(tools) - self._logger.log( - "info", f"Successfully loaded {len(tools)} tools from {mcp_ref}" - ) - - except Exception as e: - self._logger.log("warning", f"Skipping MCP {mcp_ref} due to error: {e}") - continue + all_tools.extend(tools) + # Store clients for cleanup + self._mcp_clients.extend(clients) return all_tools + def _cleanup_mcp_clients(self) -> None: + """Cleanup MCP client connections after task execution.""" + if not self._mcp_clients: + return + + async def _disconnect_all() -> None: + for client in self._mcp_clients: + if client and hasattr(client, "connected") and client.connected: + await client.disconnect() + + try: + asyncio.run(_disconnect_all()) + except Exception as e: + self._logger.log("error", f"Error during MCP client cleanup: {e}") + finally: + self._mcp_clients.clear() + + def _get_mcp_tools_from_string(self, mcp_ref: str) -> list[BaseTool]: + """Get tools from legacy string-based MCP references. + + This method maintains backwards compatibility with string-based + MCP references (https://... and crewai-amp:...). + + Args: + mcp_ref: String reference to MCP server. + + Returns: + List of BaseTool instances. + """ + if mcp_ref.startswith("crewai-amp:"): + return self._get_amp_mcp_tools(mcp_ref) + if mcp_ref.startswith("https://"): + return self._get_external_mcp_tools(mcp_ref) + return [] + def _get_external_mcp_tools(self, mcp_ref: str) -> list[BaseTool]: """Get tools from external HTTPS MCP server with graceful error handling.""" from crewai.tools.mcp_tool_wrapper import MCPToolWrapper @@ -731,6 +785,164 @@ class Agent(BaseAgent): ) return [] + def _get_native_mcp_tools( + self, mcp_config: MCPServerConfig + ) -> tuple[list[BaseTool], Any | None]: + """Get tools from MCP server using structured configuration. + + This method creates an MCP client based on the configuration type, + connects to the server, discovers tools, applies filtering, and + returns wrapped tools along with the client instance for cleanup. + + Args: + mcp_config: MCP server configuration (MCPServerStdio, MCPServerHTTP, or MCPServerSSE). + + Returns: + Tuple of (list of BaseTool instances, MCPClient instance for cleanup). + """ + from crewai.tools.base_tool import BaseTool + from crewai.tools.mcp_native_tool import MCPNativeTool + + if isinstance(mcp_config, MCPServerStdio): + transport = StdioTransport( + command=mcp_config.command, + args=mcp_config.args, + env=mcp_config.env, + ) + server_name = f"{mcp_config.command}_{'_'.join(mcp_config.args)}" + elif isinstance(mcp_config, MCPServerHTTP): + transport = HTTPTransport( + url=mcp_config.url, + headers=mcp_config.headers, + streamable=mcp_config.streamable, + ) + server_name = self._extract_server_name(mcp_config.url) + elif isinstance(mcp_config, MCPServerSSE): + transport = SSETransport( + url=mcp_config.url, + headers=mcp_config.headers, + ) + server_name = self._extract_server_name(mcp_config.url) + else: + raise ValueError(f"Unsupported MCP server config type: {type(mcp_config)}") + + client = MCPClient( + transport=transport, + cache_tools_list=mcp_config.cache_tools_list, + ) + + async def _setup_client_and_list_tools() -> list[dict[str, Any]]: + """Async helper to connect and list tools in same event loop.""" + + try: + if not client.connected: + await client.connect() + + tools_list = await client.list_tools() + + try: + await client.disconnect() + # Small delay to allow background tasks to finish cleanup + # This helps prevent "cancel scope in different task" errors + # when asyncio.run() closes the event loop + await asyncio.sleep(0.1) + except Exception as e: + self._logger.log("error", f"Error during disconnect: {e}") + + return tools_list + except Exception as e: + if client.connected: + await client.disconnect() + await asyncio.sleep(0.1) + raise RuntimeError( + f"Error during setup client and list tools: {e}" + ) from e + + try: + try: + asyncio.get_running_loop() + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit( + asyncio.run, _setup_client_and_list_tools() + ) + tools_list = future.result() + except RuntimeError: + try: + tools_list = asyncio.run(_setup_client_and_list_tools()) + except RuntimeError as e: + error_msg = str(e).lower() + if "cancel scope" in error_msg or "task" in error_msg: + raise ConnectionError( + "MCP connection failed due to event loop cleanup issues. " + "This may be due to authentication errors or server unavailability." + ) from e + except asyncio.CancelledError as e: + raise ConnectionError( + "MCP connection was cancelled. This may indicate an authentication " + "error or server unavailability." + ) from e + + if mcp_config.tool_filter: + filtered_tools = [] + for tool in tools_list: + if callable(mcp_config.tool_filter): + try: + from crewai.mcp.filters import ToolFilterContext + + context = ToolFilterContext( + agent=self, + server_name=server_name, + run_context=None, + ) + if mcp_config.tool_filter(context, tool): + filtered_tools.append(tool) + except (TypeError, AttributeError): + if mcp_config.tool_filter(tool): + filtered_tools.append(tool) + else: + # Not callable - include tool + filtered_tools.append(tool) + tools_list = filtered_tools + + tools = [] + for tool_def in tools_list: + tool_name = tool_def.get("name", "") + if not tool_name: + continue + + # Convert inputSchema to Pydantic model if present + args_schema = None + if tool_def.get("inputSchema"): + args_schema = self._json_schema_to_pydantic( + tool_name, tool_def["inputSchema"] + ) + + tool_schema = { + "description": tool_def.get("description", ""), + "args_schema": args_schema, + } + + try: + native_tool = MCPNativeTool( + mcp_client=client, + tool_name=tool_name, + tool_schema=tool_schema, + server_name=server_name, + ) + tools.append(native_tool) + except Exception as e: + self._logger.log("error", f"Failed to create native MCP tool: {e}") + continue + + return cast(list[BaseTool], tools), client + except Exception as e: + if client.connected: + asyncio.run(client.disconnect()) + + raise RuntimeError(f"Failed to get native MCP tools: {e}") from e + def _get_amp_mcp_tools(self, amp_ref: str) -> list[BaseTool]: """Get tools from CrewAI AMP MCP marketplace.""" # Parse: "crewai-amp:mcp-name" or "crewai-amp:mcp-name#tool_name" diff --git a/lib/crewai/src/crewai/agents/agent_builder/base_agent.py b/lib/crewai/src/crewai/agents/agent_builder/base_agent.py index c6dfd9e38..8dc175bff 100644 --- a/lib/crewai/src/crewai/agents/agent_builder/base_agent.py +++ b/lib/crewai/src/crewai/agents/agent_builder/base_agent.py @@ -25,6 +25,7 @@ from crewai.agents.tools_handler import ToolsHandler from crewai.knowledge.knowledge import Knowledge from crewai.knowledge.knowledge_config import KnowledgeConfig from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource +from crewai.mcp.config import MCPServerConfig from crewai.rag.embeddings.types import EmbedderConfig from crewai.security.security_config import SecurityConfig from crewai.tools.base_tool import BaseTool, Tool @@ -194,7 +195,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta): default=None, description="List of applications or application/action combinations that the agent can access through CrewAI Platform. Can contain app names (e.g., 'gmail') or specific actions (e.g., 'gmail/send_email')", ) - mcps: list[str] | None = Field( + mcps: list[str | MCPServerConfig] | None = Field( default=None, description="List of MCP server references. Supports 'https://server.com/path' for external servers and 'crewai-amp:mcp-name' for AMP marketplace. Use '#tool_name' suffix for specific tools.", ) @@ -253,20 +254,36 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta): @field_validator("mcps") @classmethod - def validate_mcps(cls, mcps: list[str] | None) -> list[str] | None: + def validate_mcps( + cls, mcps: list[str | MCPServerConfig] | None + ) -> list[str | MCPServerConfig] | None: + """Validate MCP server references and configurations. + + Supports both string references (for backwards compatibility) and + structured configuration objects (MCPServerStdio, MCPServerHTTP, MCPServerSSE). + """ if not mcps: return mcps validated_mcps = [] for mcp in mcps: - if mcp.startswith(("https://", "crewai-amp:")): + if isinstance(mcp, str): + if mcp.startswith(("https://", "crewai-amp:")): + validated_mcps.append(mcp) + else: + raise ValueError( + f"Invalid MCP reference: {mcp}. " + "String references must start with 'https://' or 'crewai-amp:'" + ) + + elif isinstance(mcp, (MCPServerConfig)): validated_mcps.append(mcp) else: raise ValueError( - f"Invalid MCP reference: {mcp}. Must start with 'https://' or 'crewai-amp:'" + f"Invalid MCP configuration: {type(mcp)}. " + "Must be a string reference or MCPServerConfig instance." ) - - return list(set(validated_mcps)) + return validated_mcps @model_validator(mode="after") def validate_and_set_attributes(self) -> Self: @@ -343,7 +360,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta): """Get platform tools for the specified list of applications and/or application/action combinations.""" @abstractmethod - def get_mcp_tools(self, mcps: list[str]) -> list[BaseTool]: + def get_mcp_tools(self, mcps: list[str | MCPServerConfig]) -> list[BaseTool]: """Get MCP tools for the specified list of MCP server references.""" def copy(self) -> Self: # type: ignore # Signature of "copy" incompatible with supertype "BaseModel" diff --git a/lib/crewai/src/crewai/events/__init__.py b/lib/crewai/src/crewai/events/__init__.py index 66c441bed..4147965e1 100644 --- a/lib/crewai/src/crewai/events/__init__.py +++ b/lib/crewai/src/crewai/events/__init__.py @@ -16,7 +16,6 @@ from crewai.events.base_event_listener import BaseEventListener from crewai.events.depends import Depends from crewai.events.event_bus import crewai_event_bus from crewai.events.handler_graph import CircularDependencyError - from crewai.events.types.crew_events import ( CrewKickoffCompletedEvent, CrewKickoffFailedEvent, @@ -61,6 +60,14 @@ from crewai.events.types.logging_events import ( AgentLogsExecutionEvent, AgentLogsStartedEvent, ) +from crewai.events.types.mcp_events import ( + MCPConnectionCompletedEvent, + MCPConnectionFailedEvent, + MCPConnectionStartedEvent, + MCPToolExecutionCompletedEvent, + MCPToolExecutionFailedEvent, + MCPToolExecutionStartedEvent, +) from crewai.events.types.memory_events import ( MemoryQueryCompletedEvent, MemoryQueryFailedEvent, @@ -153,6 +160,12 @@ __all__ = [ "LiteAgentExecutionCompletedEvent", "LiteAgentExecutionErrorEvent", "LiteAgentExecutionStartedEvent", + "MCPConnectionCompletedEvent", + "MCPConnectionFailedEvent", + "MCPConnectionStartedEvent", + "MCPToolExecutionCompletedEvent", + "MCPToolExecutionFailedEvent", + "MCPToolExecutionStartedEvent", "MemoryQueryCompletedEvent", "MemoryQueryFailedEvent", "MemoryQueryStartedEvent", diff --git a/lib/crewai/src/crewai/events/event_listener.py b/lib/crewai/src/crewai/events/event_listener.py index 6bb604b64..e07ee193c 100644 --- a/lib/crewai/src/crewai/events/event_listener.py +++ b/lib/crewai/src/crewai/events/event_listener.py @@ -65,6 +65,14 @@ from crewai.events.types.logging_events import ( AgentLogsExecutionEvent, AgentLogsStartedEvent, ) +from crewai.events.types.mcp_events import ( + MCPConnectionCompletedEvent, + MCPConnectionFailedEvent, + MCPConnectionStartedEvent, + MCPToolExecutionCompletedEvent, + MCPToolExecutionFailedEvent, + MCPToolExecutionStartedEvent, +) from crewai.events.types.reasoning_events import ( AgentReasoningCompletedEvent, AgentReasoningFailedEvent, @@ -615,5 +623,67 @@ class EventListener(BaseEventListener): event.total_turns, ) + # ----------- MCP EVENTS ----------- + + @crewai_event_bus.on(MCPConnectionStartedEvent) + def on_mcp_connection_started(source, event: MCPConnectionStartedEvent): + self.formatter.handle_mcp_connection_started( + event.server_name, + event.server_url, + event.transport_type, + event.is_reconnect, + event.connect_timeout, + ) + + @crewai_event_bus.on(MCPConnectionCompletedEvent) + def on_mcp_connection_completed(source, event: MCPConnectionCompletedEvent): + self.formatter.handle_mcp_connection_completed( + event.server_name, + event.server_url, + event.transport_type, + event.connection_duration_ms, + event.is_reconnect, + ) + + @crewai_event_bus.on(MCPConnectionFailedEvent) + def on_mcp_connection_failed(source, event: MCPConnectionFailedEvent): + self.formatter.handle_mcp_connection_failed( + event.server_name, + event.server_url, + event.transport_type, + event.error, + event.error_type, + ) + + @crewai_event_bus.on(MCPToolExecutionStartedEvent) + def on_mcp_tool_execution_started(source, event: MCPToolExecutionStartedEvent): + self.formatter.handle_mcp_tool_execution_started( + event.server_name, + event.tool_name, + event.tool_args, + ) + + @crewai_event_bus.on(MCPToolExecutionCompletedEvent) + def on_mcp_tool_execution_completed( + source, event: MCPToolExecutionCompletedEvent + ): + self.formatter.handle_mcp_tool_execution_completed( + event.server_name, + event.tool_name, + event.tool_args, + event.result, + event.execution_duration_ms, + ) + + @crewai_event_bus.on(MCPToolExecutionFailedEvent) + def on_mcp_tool_execution_failed(source, event: MCPToolExecutionFailedEvent): + self.formatter.handle_mcp_tool_execution_failed( + event.server_name, + event.tool_name, + event.tool_args, + event.error, + event.error_type, + ) + event_listener = EventListener() diff --git a/lib/crewai/src/crewai/events/event_types.py b/lib/crewai/src/crewai/events/event_types.py index f7a4d1f72..ea00aa9ae 100644 --- a/lib/crewai/src/crewai/events/event_types.py +++ b/lib/crewai/src/crewai/events/event_types.py @@ -40,6 +40,14 @@ from crewai.events.types.llm_guardrail_events import ( LLMGuardrailCompletedEvent, LLMGuardrailStartedEvent, ) +from crewai.events.types.mcp_events import ( + MCPConnectionCompletedEvent, + MCPConnectionFailedEvent, + MCPConnectionStartedEvent, + MCPToolExecutionCompletedEvent, + MCPToolExecutionFailedEvent, + MCPToolExecutionStartedEvent, +) from crewai.events.types.memory_events import ( MemoryQueryCompletedEvent, MemoryQueryFailedEvent, @@ -115,4 +123,10 @@ EventTypes = ( | MemoryQueryFailedEvent | MemoryRetrievalStartedEvent | MemoryRetrievalCompletedEvent + | MCPConnectionStartedEvent + | MCPConnectionCompletedEvent + | MCPConnectionFailedEvent + | MCPToolExecutionStartedEvent + | MCPToolExecutionCompletedEvent + | MCPToolExecutionFailedEvent ) diff --git a/lib/crewai/src/crewai/events/types/mcp_events.py b/lib/crewai/src/crewai/events/types/mcp_events.py new file mode 100644 index 000000000..d360aa62a --- /dev/null +++ b/lib/crewai/src/crewai/events/types/mcp_events.py @@ -0,0 +1,85 @@ +from datetime import datetime +from typing import Any + +from crewai.events.base_events import BaseEvent + + +class MCPEvent(BaseEvent): + """Base event for MCP operations.""" + + server_name: str + server_url: str | None = None + transport_type: str | None = None # "stdio", "http", "sse" + agent_id: str | None = None + agent_role: str | None = None + from_agent: Any | None = None + from_task: Any | None = None + + def __init__(self, **data): + super().__init__(**data) + self._set_agent_params(data) + self._set_task_params(data) + + +class MCPConnectionStartedEvent(MCPEvent): + """Event emitted when starting to connect to an MCP server.""" + + type: str = "mcp_connection_started" + connect_timeout: int | None = None + is_reconnect: bool = ( + False # True if this is a reconnection, False for first connection + ) + + +class MCPConnectionCompletedEvent(MCPEvent): + """Event emitted when successfully connected to an MCP server.""" + + type: str = "mcp_connection_completed" + started_at: datetime | None = None + completed_at: datetime | None = None + connection_duration_ms: float | None = None + is_reconnect: bool = ( + False # True if this was a reconnection, False for first connection + ) + + +class MCPConnectionFailedEvent(MCPEvent): + """Event emitted when connection to an MCP server fails.""" + + type: str = "mcp_connection_failed" + error: str + error_type: str | None = None # "timeout", "authentication", "network", etc. + started_at: datetime | None = None + failed_at: datetime | None = None + + +class MCPToolExecutionStartedEvent(MCPEvent): + """Event emitted when starting to execute an MCP tool.""" + + type: str = "mcp_tool_execution_started" + tool_name: str + tool_args: dict[str, Any] | None = None + + +class MCPToolExecutionCompletedEvent(MCPEvent): + """Event emitted when MCP tool execution completes.""" + + type: str = "mcp_tool_execution_completed" + tool_name: str + tool_args: dict[str, Any] | None = None + result: Any | None = None + started_at: datetime | None = None + completed_at: datetime | None = None + execution_duration_ms: float | None = None + + +class MCPToolExecutionFailedEvent(MCPEvent): + """Event emitted when MCP tool execution fails.""" + + type: str = "mcp_tool_execution_failed" + tool_name: str + tool_args: dict[str, Any] | None = None + error: str + error_type: str | None = None # "timeout", "validation", "server_error", etc. + started_at: datetime | None = None + failed_at: datetime | None = None diff --git a/lib/crewai/src/crewai/events/utils/console_formatter.py b/lib/crewai/src/crewai/events/utils/console_formatter.py index 4ee2aa52b..32aa8d208 100644 --- a/lib/crewai/src/crewai/events/utils/console_formatter.py +++ b/lib/crewai/src/crewai/events/utils/console_formatter.py @@ -2248,3 +2248,203 @@ class ConsoleFormatter: self.current_a2a_conversation_branch = None self.current_a2a_turn_count = 0 + + # ----------- MCP EVENTS ----------- + + def handle_mcp_connection_started( + self, + server_name: str, + server_url: str | None = None, + transport_type: str | None = None, + is_reconnect: bool = False, + connect_timeout: int | None = None, + ) -> None: + """Handle MCP connection started event.""" + if not self.verbose: + return + + content = Text() + reconnect_text = " (Reconnecting)" if is_reconnect else "" + content.append(f"MCP Connection Started{reconnect_text}\n\n", style="cyan bold") + content.append("Server: ", style="white") + content.append(f"{server_name}\n", style="cyan") + + if server_url: + content.append("URL: ", style="white") + content.append(f"{server_url}\n", style="cyan dim") + + if transport_type: + content.append("Transport: ", style="white") + content.append(f"{transport_type}\n", style="cyan") + + if connect_timeout: + content.append("Timeout: ", style="white") + content.append(f"{connect_timeout}s\n", style="cyan") + + panel = self.create_panel(content, "🔌 MCP Connection", "cyan") + self.print(panel) + self.print() + + def handle_mcp_connection_completed( + self, + server_name: str, + server_url: str | None = None, + transport_type: str | None = None, + connection_duration_ms: float | None = None, + is_reconnect: bool = False, + ) -> None: + """Handle MCP connection completed event.""" + if not self.verbose: + return + + content = Text() + reconnect_text = " (Reconnected)" if is_reconnect else "" + content.append( + f"MCP Connection Completed{reconnect_text}\n\n", style="green bold" + ) + content.append("Server: ", style="white") + content.append(f"{server_name}\n", style="green") + + if server_url: + content.append("URL: ", style="white") + content.append(f"{server_url}\n", style="green dim") + + if transport_type: + content.append("Transport: ", style="white") + content.append(f"{transport_type}\n", style="green") + + if connection_duration_ms is not None: + content.append("Duration: ", style="white") + content.append(f"{connection_duration_ms:.2f}ms\n", style="green") + + panel = self.create_panel(content, "✅ MCP Connected", "green") + self.print(panel) + self.print() + + def handle_mcp_connection_failed( + self, + server_name: str, + server_url: str | None = None, + transport_type: str | None = None, + error: str = "", + error_type: str | None = None, + ) -> None: + """Handle MCP connection failed event.""" + if not self.verbose: + return + + content = Text() + content.append("MCP Connection Failed\n\n", style="red bold") + content.append("Server: ", style="white") + content.append(f"{server_name}\n", style="red") + + if server_url: + content.append("URL: ", style="white") + content.append(f"{server_url}\n", style="red dim") + + if transport_type: + content.append("Transport: ", style="white") + content.append(f"{transport_type}\n", style="red") + + if error_type: + content.append("Error Type: ", style="white") + content.append(f"{error_type}\n", style="red") + + if error: + content.append("\nError: ", style="white bold") + error_preview = error[:500] + "..." if len(error) > 500 else error + content.append(f"{error_preview}\n", style="red") + + panel = self.create_panel(content, "❌ MCP Connection Failed", "red") + self.print(panel) + self.print() + + def handle_mcp_tool_execution_started( + self, + server_name: str, + tool_name: str, + tool_args: dict[str, Any] | None = None, + ) -> None: + """Handle MCP tool execution started event.""" + if not self.verbose: + return + + content = self.create_status_content( + "MCP Tool Execution Started", + tool_name, + "yellow", + tool_args=tool_args or {}, + Server=server_name, + ) + + panel = self.create_panel(content, "🔧 MCP Tool", "yellow") + self.print(panel) + self.print() + + def handle_mcp_tool_execution_completed( + self, + server_name: str, + tool_name: str, + tool_args: dict[str, Any] | None = None, + result: Any | None = None, + execution_duration_ms: float | None = None, + ) -> None: + """Handle MCP tool execution completed event.""" + if not self.verbose: + return + + content = self.create_status_content( + "MCP Tool Execution Completed", + tool_name, + "green", + tool_args=tool_args or {}, + Server=server_name, + ) + + if execution_duration_ms is not None: + content.append("Duration: ", style="white") + content.append(f"{execution_duration_ms:.2f}ms\n", style="green") + + if result is not None: + result_str = str(result) + if len(result_str) > 500: + result_str = result_str[:497] + "..." + content.append("\nResult: ", style="white bold") + content.append(f"{result_str}\n", style="green") + + panel = self.create_panel(content, "✅ MCP Tool Completed", "green") + self.print(panel) + self.print() + + def handle_mcp_tool_execution_failed( + self, + server_name: str, + tool_name: str, + tool_args: dict[str, Any] | None = None, + error: str = "", + error_type: str | None = None, + ) -> None: + """Handle MCP tool execution failed event.""" + if not self.verbose: + return + + content = self.create_status_content( + "MCP Tool Execution Failed", + tool_name, + "red", + tool_args=tool_args or {}, + Server=server_name, + ) + + if error_type: + content.append("Error Type: ", style="white") + content.append(f"{error_type}\n", style="red") + + if error: + content.append("\nError: ", style="white bold") + error_preview = error[:500] + "..." if len(error) > 500 else error + content.append(f"{error_preview}\n", style="red") + + panel = self.create_panel(content, "❌ MCP Tool Failed", "red") + self.print(panel) + self.print() 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 }}'
-