diff --git a/python/packages/devui/.gitignore b/python/packages/devui/.gitignore new file mode 100644 index 0000000000..e545b92de0 --- /dev/null +++ b/python/packages/devui/.gitignore @@ -0,0 +1,19 @@ +# Test artifacts +tests/captured_messages/ + +# Python cache +__pycache__/ +*.py[cod] +*$py.class + +# Local development files +.env +*.log + +# IDE files +.vscode/ +.idea/ + +# OS files +.DS_Store +Thumbs.db diff --git a/python/packages/devui/LICENSE b/python/packages/devui/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/devui/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/devui/README.md b/python/packages/devui/README.md new file mode 100644 index 0000000000..7fbe4ac6a7 --- /dev/null +++ b/python/packages/devui/README.md @@ -0,0 +1,140 @@ +# DevUI - Agent Framework Debug Interface + +A lightweight, standalone sample app interface for running entities (agents/workflows) in the Microsoft Agent Framework supporting both **directory-based discovery** and **in-memory entity registration**. + +> [!IMPORTANT] +> DevUI is a **sample app** to help you get started with the Agent Framework. It is **not** intended for production use. For production, or for features beyond what is provided in this sample app, it is recommended that you build your own custom interface and API server using the Agent Framework SDK. + +![DevUI Screenshot](./docs/devuiscreen.png) + +## Quick Start + +```bash +# Install +pip install agent-framework-devui + +# Launch web UI + API server +devui ./agents --port 8080 +# → Web UI: http://localhost:8080 +# → API: http://localhost:8080/v1/* +``` + +You can also launch it programmatically + +```python +from agent_framework import ChatAgent +from agent_framework.openai import OpenAIChatClient +from agent_framework.devui import serve + +def get_weather(location: str) -> str: + """Get weather for a location.""" + return f"Weather in {location}: 72°F and sunny" + +# Create your agent +agent = ChatAgent( + name="WeatherAgent", + chat_client=OpenAIChatClient(), + tools=[get_weather] +) + +# Launch debug UI - that's it! +serve(entities=[agent], auto_open=True) +# → Opens browser to http://localhost:8080 +``` + +## Directory Structure + +For your agents to be discovered by the DevUI, they must be organized in a directory structure like below. Each agent/workflow must have an `__init__.py` that exports the required variable (`agent` or `workflow`). + +**Note**: `.env` files are optional but will be automatically loaded if present in the agent/workflow directory or parent entities directory. Use them to store API keys, configuration variables, and other environment-specific settings. + +``` +agents/ +├── weather_agent/ +│ ├── __init__.py # Must export: agent = ChatAgent(...) +│ ├── agent.py +│ └── .env # Optional: API keys, config vars +├── my_workflow/ +│ ├── __init__.py # Must export: workflow = WorkflowBuilder()... +│ ├── workflow.py +│ └── .env # Optional: environment variables +└── .env # Optional: shared environment variables +``` + +## OpenAI-Compatible API + +For convenience, you can interact with the agents/workflows using the standard OpenAI API format. Just specify the `entity_id` in the `extra_body` field. This can be an `agent_id` or `workflow_id`. + +```bash +# Standard OpenAI format +curl -X POST http://localhost:8080/v1/responses \ + -H "Content-Type: application/json" \ + -d @- << 'EOF' +{ + "model": "agent-framework", + "input": "Hello world", + "extra_body": {"entity_id": "weather_agent"} +} +EOF +``` + +Messages and events from agents/workflows are mapped to OpenAI response types in `agent_framework_devui/_mapper.py`. See the mapping table below: + +| Agent Framework Content | OpenAI Event | Type | +| --------------------------------- | ----------------------------------------- | -------- | +| `TextContent` | `ResponseTextDeltaEvent` | Official | +| `TextReasoningContent` | `ResponseReasoningTextDeltaEvent` | Official | +| `FunctionCallContent` | `ResponseFunctionCallArgumentsDeltaEvent` | Official | +| `FunctionResultContent` | `ResponseFunctionResultComplete` | Custom | +| `ErrorContent` | `ResponseErrorEvent` | Official | +| `UsageContent` | `ResponseUsageEventComplete` | Custom | +| `DataContent` | `ResponseTraceEventComplete` | Custom | +| `UriContent` | `ResponseTraceEventComplete` | Custom | +| `HostedFileContent` | `ResponseTraceEventComplete` | Custom | +| `HostedVectorStoreContent` | `ResponseTraceEventComplete` | Custom | +| `FunctionApprovalRequestContent` | Custom event | Custom | +| `FunctionApprovalResponseContent` | Custom event | Custom | +| `WorkflowEvent` | `ResponseWorkflowEventComplete` | Custom | + +## CLI Options + +```bash +devui [directory] [options] + +Options: + --port, -p Port (default: 8080) + --host Host (default: 127.0.0.1) + --headless API only, no UI + --config YAML config file + --tracing none|framework|workflow|all + --reload Enable auto-reload +``` + +## Key Endpoints + +- `GET /v1/entities` - List discovered agents/workflows +- `GET /v1/entities/{entity_id}/info` - Get detailed entity information +- `POST /v1/responses` - Execute agent/workflow (streaming or sync) +- `GET /health` - Health check +- `POST /v1/threads` - Create thread for agent (optional) +- `GET /v1/threads?agent_id={id}` - List threads for agent +- `GET /v1/threads/{thread_id}` - Get thread info +- `DELETE /v1/threads/{thread_id}` - Delete thread +- `GET /v1/threads/{thread_id}/messages` - Get thread messages + +## Implementation + +- **Discovery**: `agent_framework_devui/_discovery.py` +- **Execution**: `agent_framework_devui/_executor.py` +- **Message Mapping**: `agent_framework_devui/_mapper.py` +- **Session Management**: `agent_framework_devui/_session.py` +- **API Server**: `agent_framework_devui/_server.py` +- **CLI**: `agent_framework_devui/_cli.py` + +## Examples + +See `samples/` for working agent and workflow implementations. + +## License + +MIT diff --git a/python/packages/devui/agent_framework_devui/__init__.py b/python/packages/devui/agent_framework_devui/__init__.py new file mode 100644 index 0000000000..2185f73882 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/__init__.py @@ -0,0 +1,131 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent Framework DevUI - Debug interface with OpenAI compatible API server.""" + +import importlib.metadata +import logging +import webbrowser +from typing import Any + +from ._server import DevServer +from .models import AgentFrameworkRequest, OpenAIError, OpenAIResponse, ResponseStreamEvent +from .models._discovery_models import DiscoveryResponse, EntityInfo + +logger = logging.getLogger(__name__) + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + + +def serve( + entities: list[Any] | None = None, + entities_dir: str | None = None, + port: int = 8080, + host: str = "127.0.0.1", + auto_open: bool = False, + cors_origins: list[str] | None = None, + ui_enabled: bool = True, +) -> None: + """Launch Agent Framework DevUI with simple API. + + Args: + entities: List of entities for in-memory registration (IDs auto-generated) + entities_dir: Directory to scan for entities + port: Port to run server on + host: Host to bind server to + auto_open: Whether to automatically open browser + cors_origins: List of allowed CORS origins + ui_enabled: Whether to enable the UI + """ + import re + + import uvicorn + + # Validate host parameter early for security + if not re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0|[a-zA-Z0-9.-]+)$", host): + raise ValueError(f"Invalid host: {host}. Must be localhost, IP address, or valid hostname") + + # Validate port parameter + if not isinstance(port, int) or not (1 <= port <= 65535): + raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535") + + # Create server with direct parameters + server = DevServer( + entities_dir=entities_dir, port=port, host=host, cors_origins=cors_origins, ui_enabled=ui_enabled + ) + + # Register in-memory entities if provided + if entities: + logger.info(f"Registering {len(entities)} in-memory entities") + # Store entities for later registration during server startup + server._pending_entities = entities + + app = server.get_app() + + if auto_open: + + def open_browser() -> None: + import http.client + import re + import time + + # Validate host and port for security + if not re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0|[a-zA-Z0-9.-]+)$", host): + logger.warning(f"Invalid host for auto-open: {host}") + return + + if not isinstance(port, int) or not (1 <= port <= 65535): + logger.warning(f"Invalid port for auto-open: {port}") + return + + # Wait for server to be ready by checking health endpoint + browser_url = f"http://{host}:{port}" + + for _ in range(30): # 15 second timeout (30 * 0.5s) + try: + # Use http.client for safe connection handling (standard library) + conn = http.client.HTTPConnection(host, port, timeout=1) + try: + conn.request("GET", "/health") + response = conn.getresponse() + if response.status == 200: + webbrowser.open(browser_url) + return + finally: + conn.close() + except (http.client.HTTPException, OSError, TimeoutError): + pass + time.sleep(0.5) + + # Fallback: open browser anyway after timeout + webbrowser.open(browser_url) + + import threading + + threading.Thread(target=open_browser, daemon=True).start() + + logger.info(f"Starting Agent Framework DevUI on {host}:{port}") + uvicorn.run(app, host=host, port=port, log_level="info") + + +def main() -> None: + """CLI entry point for devui command.""" + from ._cli import main as cli_main + + cli_main() + + +# Export main public API +__all__ = [ + "AgentFrameworkRequest", + "DevServer", + "DiscoveryResponse", + "EntityInfo", + "OpenAIError", + "OpenAIResponse", + "ResponseStreamEvent", + "main", + "serve", +] diff --git a/python/packages/devui/agent_framework_devui/_cli.py b/python/packages/devui/agent_framework_devui/_cli.py new file mode 100644 index 0000000000..0e36731b1c --- /dev/null +++ b/python/packages/devui/agent_framework_devui/_cli.py @@ -0,0 +1,135 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Command line interface for Agent Framework DevUI.""" + +import argparse +import logging +import os +import sys + +logger = logging.getLogger(__name__) + + +def setup_logging(level: str = "INFO") -> None: + """Configure logging for the server.""" + log_format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" + logging.basicConfig(level=getattr(logging, level.upper()), format=log_format, datefmt="%Y-%m-%d %H:%M:%S") + + +def create_cli_parser() -> argparse.ArgumentParser: + """Create the command line argument parser.""" + parser = argparse.ArgumentParser( + prog="devui", + description="Launch Agent Framework DevUI - Debug interface with OpenAI compatible API", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + devui # Scan current directory + devui ./agents # Scan specific directory + devui --port 8000 # Custom port + devui --headless # API only, no UI + """, + ) + + parser.add_argument( + "directory", nargs="?", default=".", help="Directory to scan for entities (default: current directory)" + ) + + parser.add_argument("--port", "-p", type=int, default=8080, help="Port to run server on (default: 8080)") + + parser.add_argument("--host", default="127.0.0.1", help="Host to bind server to (default: 127.0.0.1)") + + parser.add_argument("--no-open", action="store_true", help="Don't automatically open browser") + + parser.add_argument("--headless", action="store_true", help="Run without UI (API only)") + + parser.add_argument( + "--log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + default="INFO", + help="Logging level (default: INFO)", + ) + + parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development") + + parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}") + + return parser + + +def get_version() -> str: + """Get the package version.""" + try: + from . import __version__ + + return __version__ + except ImportError: + return "unknown" + + +def validate_directory(directory: str) -> str: + """Validate and normalize the entities directory.""" + if not directory: + directory = "." + + abs_dir = os.path.abspath(directory) + + if not os.path.exists(abs_dir): + print(f"❌ Error: Directory '{directory}' does not exist", file=sys.stderr) # noqa: T201 + sys.exit(1) + + if not os.path.isdir(abs_dir): + print(f"❌ Error: '{directory}' is not a directory", file=sys.stderr) # noqa: T201 + sys.exit(1) + + return abs_dir + + +def print_startup_info(entities_dir: str, host: str, port: int, ui_enabled: bool, reload: bool) -> None: + """Print startup information.""" + print("🤖 Agent Framework DevUI") # noqa: T201 + print("=" * 50) # noqa: T201 + print(f"📁 Entities directory: {entities_dir}") # noqa: T201 + print(f"🌐 Server URL: http://{host}:{port}") # noqa: T201 + print(f"🎨 UI enabled: {'Yes' if ui_enabled else 'No'}") # noqa: T201 + print(f"🔄 Auto-reload: {'Yes' if reload else 'No'}") # noqa: T201 + print("=" * 50) # noqa: T201 + print("🔍 Scanning for entities...") # noqa: T201 + + +def main() -> None: + """Main CLI entry point.""" + parser = create_cli_parser() + args = parser.parse_args() + + # Setup logging + setup_logging(args.log_level) + + # Validate directory + entities_dir = validate_directory(args.directory) + + # Extract parameters directly from args + ui_enabled = not args.headless + + # Print startup info + print_startup_info(entities_dir, args.host, args.port, ui_enabled, args.reload) + + # Import and start server + try: + from . import serve + + serve( + entities_dir=entities_dir, port=args.port, host=args.host, auto_open=not args.no_open, ui_enabled=ui_enabled + ) + + except KeyboardInterrupt: + print("\n👋 Shutting down Agent Framework DevUI...") # noqa: T201 + sys.exit(0) + except Exception as e: + logger.exception("Failed to start server") + print(f"❌ Error: {e}", file=sys.stderr) # noqa: T201 + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/python/packages/devui/agent_framework_devui/_discovery.py b/python/packages/devui/agent_framework_devui/_discovery.py new file mode 100644 index 0000000000..d75d77a6c4 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/_discovery.py @@ -0,0 +1,550 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent Framework entity discovery implementation.""" + +import importlib +import importlib.util +import logging +import sys +import uuid +from pathlib import Path +from typing import Any + +from dotenv import load_dotenv + +from .models._discovery_models import EntityInfo + +logger = logging.getLogger(__name__) + + +class EntityDiscovery: + """Discovery for Agent Framework entities - agents and workflows.""" + + def __init__(self, entities_dir: str | None = None): + """Initialize entity discovery. + + Args: + entities_dir: Directory to scan for entities (optional) + """ + self.entities_dir = entities_dir + self._entities: dict[str, EntityInfo] = {} + self._loaded_objects: dict[str, Any] = {} + + async def discover_entities(self) -> list[EntityInfo]: + """Scan for Agent Framework entities. + + Returns: + List of discovered entities + """ + if not self.entities_dir: + logger.info("No Agent Framework entities directory configured") + return [] + + entities_dir = Path(self.entities_dir).resolve() + await self._scan_entities_directory(entities_dir) + + logger.info(f"Discovered {len(self._entities)} Agent Framework entities") + return self.list_entities() + + def get_entity_info(self, entity_id: str) -> EntityInfo | None: + """Get entity metadata. + + Args: + entity_id: Entity identifier + + Returns: + Entity information or None if not found + """ + return self._entities.get(entity_id) + + def get_entity_object(self, entity_id: str) -> Any | None: + """Get the actual loaded entity object. + + Args: + entity_id: Entity identifier + + Returns: + Entity object or None if not found + """ + return self._loaded_objects.get(entity_id) + + def list_entities(self) -> list[EntityInfo]: + """List all discovered entities. + + Returns: + List of all entity information + """ + return list(self._entities.values()) + + def register_entity(self, entity_id: str, entity_info: EntityInfo, entity_object: Any) -> None: + """Register an entity with both metadata and object. + + Args: + entity_id: Unique entity identifier + entity_info: Entity metadata + entity_object: Actual entity object for execution + """ + self._entities[entity_id] = entity_info + self._loaded_objects[entity_id] = entity_object + logger.debug(f"Registered entity: {entity_id} ({entity_info.type})") + + async def create_entity_info_from_object(self, entity_object: Any, entity_type: str | None = None) -> EntityInfo: + """Create EntityInfo from Agent Framework entity object. + + Args: + entity_object: Agent Framework entity object + entity_type: Optional entity type override + + Returns: + EntityInfo with Agent Framework specific metadata + """ + # Determine entity type if not provided + if entity_type is None: + entity_type = "agent" + # Check if it's a workflow + if hasattr(entity_object, "get_executors_list") or hasattr(entity_object, "executors"): + entity_type = "workflow" + + # Extract metadata with improved fallback naming + name = getattr(entity_object, "name", None) + if not name: + # In-memory entities: use ID with entity type prefix since no directory name available + entity_id_raw = getattr(entity_object, "id", None) + if entity_id_raw: + # Truncate UUID to first 8 characters for readability + short_id = str(entity_id_raw)[:8] if len(str(entity_id_raw)) > 8 else str(entity_id_raw) + name = f"{entity_type.title()} {short_id}" + else: + # Fallback to class name with entity type + class_name = entity_object.__class__.__name__ + name = f"{entity_type.title()} {class_name}" + description = getattr(entity_object, "description", "") + + # Generate entity ID using Agent Framework specific naming + entity_id = self._generate_entity_id(entity_object, entity_type) + + # Extract tools/executors using Agent Framework specific logic + tools_list = await self._extract_tools_from_object(entity_object, entity_type) + + # Create EntityInfo with Agent Framework specifics + return EntityInfo( + id=entity_id, + name=name, + description=description, + type=entity_type, + framework="agent_framework", + tools=[str(tool) for tool in (tools_list or [])], + executors=tools_list if entity_type == "workflow" else [], + input_schema={"type": "string"}, # Default schema + start_executor_id=tools_list[0] if tools_list and entity_type == "workflow" else None, + metadata={ + "source": "agent_framework_object", + "class_name": entity_object.__class__.__name__ + if hasattr(entity_object, "__class__") + else str(type(entity_object)), + "has_run_stream": hasattr(entity_object, "run_stream"), + }, + ) + + async def _scan_entities_directory(self, entities_dir: Path) -> None: + """Scan the entities directory for Agent Framework entities. + + Args: + entities_dir: Directory to scan for entities + """ + if not entities_dir.exists(): + logger.warning(f"Entities directory not found: {entities_dir}") + return + + logger.info(f"Scanning {entities_dir} for Agent Framework entities...") + + # Add entities directory to Python path if not already there + entities_dir_str = str(entities_dir) + if entities_dir_str not in sys.path: + sys.path.insert(0, entities_dir_str) + + # Scan for directories and Python files + for item in entities_dir.iterdir(): + if item.name.startswith(".") or item.name == "__pycache__": + continue + + if item.is_dir(): + # Directory-based entity + await self._discover_entities_in_directory(item) + elif item.is_file() and item.suffix == ".py" and not item.name.startswith("_"): + # Single file entity + await self._discover_entities_in_file(item) + + async def _discover_entities_in_directory(self, dir_path: Path) -> None: + """Discover entities in a directory using module import. + + Args: + dir_path: Directory containing entity + """ + entity_id = dir_path.name + logger.debug(f"Scanning directory: {entity_id}") + + try: + # Load environment variables for this entity first + self._load_env_for_entity(dir_path) + + # Try different import patterns + import_patterns = [ + entity_id, # Direct module import + f"{entity_id}.agent", # agent.py submodule + f"{entity_id}.workflow", # workflow.py submodule + ] + + for pattern in import_patterns: + module = self._load_module_from_pattern(pattern) + if module: + entities_found = await self._find_entities_in_module(module, entity_id, str(dir_path)) + if entities_found: + logger.debug(f"Found {len(entities_found)} entities in {pattern}") + break + + except Exception as e: + logger.warning(f"Error scanning directory {entity_id}: {e}") + + async def _discover_entities_in_file(self, file_path: Path) -> None: + """Discover entities in a single Python file. + + Args: + file_path: Python file to scan + """ + try: + # Load environment variables for this entity's directory first + self._load_env_for_entity(file_path.parent) + + # Create module name from file path + base_name = file_path.stem + + # Load the module directly from file + module = self._load_module_from_file(file_path, base_name) + if module: + entities_found = await self._find_entities_in_module(module, base_name, str(file_path)) + if entities_found: + logger.debug(f"Found {len(entities_found)} entities in {file_path.name}") + + except Exception as e: + logger.warning(f"Error scanning file {file_path}: {e}") + + def _load_env_for_entity(self, entity_path: Path) -> bool: + """Load .env file for an entity. + + Args: + entity_path: Path to entity directory + + Returns: + True if .env was loaded successfully + """ + # Check for .env in the entity folder first + env_file = entity_path / ".env" + if self._load_env_file(env_file): + return True + + # Check one level up (the entities directory) for safety + if self.entities_dir: + entities_dir = Path(self.entities_dir).resolve() + entities_env = entities_dir / ".env" + if self._load_env_file(entities_env): + return True + + return False + + def _load_env_file(self, env_path: Path) -> bool: + """Load environment variables from .env file. + + Args: + env_path: Path to .env file + + Returns: + True if file was loaded successfully + """ + if env_path.exists(): + load_dotenv(env_path, override=True) + logger.debug(f"Loaded .env from {env_path}") + return True + return False + + def _load_module_from_pattern(self, pattern: str) -> Any | None: + """Load module using import pattern. + + Args: + pattern: Import pattern to try + + Returns: + Loaded module or None if failed + """ + try: + # Check if module exists first + spec = importlib.util.find_spec(pattern) + if spec is None: + return None + + module = importlib.import_module(pattern) + logger.debug(f"Successfully imported {pattern}") + return module + + except ModuleNotFoundError: + logger.debug(f"Import pattern {pattern} not found") + return None + except Exception as e: + logger.warning(f"Error importing {pattern}: {e}") + return None + + def _load_module_from_file(self, file_path: Path, module_name: str) -> Any | None: + """Load module directly from file path. + + Args: + file_path: Path to Python file + module_name: Name to assign to module + + Returns: + Loaded module or None if failed + """ + try: + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + return None + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module # Add to sys.modules for proper imports + spec.loader.exec_module(module) + + logger.debug(f"Successfully loaded module from {file_path}") + return module + + except Exception as e: + logger.warning(f"Error loading module from {file_path}: {e}") + return None + + async def _find_entities_in_module(self, module: Any, base_id: str, module_path: str) -> list[str]: + """Find agent and workflow entities in a loaded module. + + Args: + module: Loaded Python module + base_id: Base identifier for entities + module_path: Path to module for metadata + + Returns: + List of entity IDs that were found and registered + """ + entities_found = [] + + # Look for explicit variable names first + candidates = [ + ("agent", getattr(module, "agent", None)), + ("workflow", getattr(module, "workflow", None)), + ] + + for obj_type, obj in candidates: + if obj is None: + continue + + if self._is_valid_entity(obj, obj_type): + entity_id = f"{obj_type}_{base_id}" + await self._register_entity_from_object(entity_id, obj, obj_type, module_path) + entities_found.append(entity_id) + + return entities_found + + def _is_valid_entity(self, obj: Any, expected_type: str) -> bool: + """Check if object is a valid agent or workflow using duck typing. + + Args: + obj: Object to validate + expected_type: Expected type ("agent" or "workflow") + + Returns: + True if object is valid for the expected type + """ + if expected_type == "agent": + return self._is_valid_agent(obj) + if expected_type == "workflow": + return self._is_valid_workflow(obj) + return False + + def _is_valid_agent(self, obj: Any) -> bool: + """Check if object is a valid Agent Framework agent. + + Args: + obj: Object to validate + + Returns: + True if object appears to be a valid agent + """ + try: + # Try to import AgentProtocol for proper type checking + try: + from agent_framework import AgentProtocol + + if isinstance(obj, AgentProtocol): + return True + except ImportError: + pass + + # Fallback to duck typing for agent protocol + if hasattr(obj, "run_stream") and hasattr(obj, "id") and hasattr(obj, "name"): + return True + + except (TypeError, AttributeError): + pass + + return False + + def _is_valid_workflow(self, obj: Any) -> bool: + """Check if object is a valid Agent Framework workflow. + + Args: + obj: Object to validate + + Returns: + True if object appears to be a valid workflow + """ + # Check for workflow - must have run_stream method and executors + return hasattr(obj, "run_stream") and (hasattr(obj, "executors") or hasattr(obj, "get_executors_list")) + + async def _register_entity_from_object(self, entity_id: str, obj: Any, obj_type: str, module_path: str) -> None: + """Register an entity from a live object. + + Args: + entity_id: Unique entity identifier + obj: Entity object + obj_type: Type of entity ("agent" or "workflow") + module_path: Path to module for metadata + """ + try: + # Extract metadata from the live object with improved fallback naming + name = getattr(obj, "name", None) + if not name: + # For directory-based entities, prefer directory name over UUID + # entity_id format: "workflow_fanout_workflow" or "agent_weather_agent" + if entity_id and "_" in entity_id: + # Directory-based: use formatted directory name (remove type prefix) + directory_name = entity_id.split("_", 1)[1] if "_" in entity_id else entity_id + name = directory_name.replace("_", " ").title() + else: + # In-memory: use ID with entity type prefix + entity_id_raw = getattr(obj, "id", None) + if entity_id_raw: + # Truncate UUID to first 8 characters for readability + short_id = str(entity_id_raw)[:8] if len(str(entity_id_raw)) > 8 else str(entity_id_raw) + name = f"{obj_type.title()} {short_id}" + else: + # Final fallback to class name + name = f"{obj_type.title()} {obj.__class__.__name__}" + description = getattr(obj, "description", None) + tools = await self._extract_tools_from_object(obj, obj_type) + + # Create EntityInfo + tools_union: list[str | dict[str, Any]] | None = None + if tools: + tools_union = [tool for tool in tools] + + entity_info = EntityInfo( + id=entity_id, + type=obj_type, + name=name, + framework="agent_framework", + description=description, + tools=tools_union, + metadata={ + "module_path": module_path, + "entity_type": obj_type, + "source": "module_import", + "has_run_stream": hasattr(obj, "run_stream"), + "class_name": obj.__class__.__name__ if hasattr(obj, "__class__") else str(type(obj)), + }, + ) + + # Register the entity + self.register_entity(entity_id, entity_info, obj) + + except Exception as e: + logger.error(f"Error registering entity {entity_id}: {e}") + + async def _extract_tools_from_object(self, obj: Any, obj_type: str) -> list[str]: + """Extract tool/executor names from a live object. + + Args: + obj: Entity object + obj_type: Type of entity + + Returns: + List of tool/executor names + """ + tools = [] + + try: + if obj_type == "agent": + # For agents, check chat_options.tools first + chat_options = getattr(obj, "chat_options", None) + if chat_options and hasattr(chat_options, "tools"): + for tool in chat_options.tools: + if hasattr(tool, "__name__"): + tools.append(tool.__name__) + elif hasattr(tool, "name"): + tools.append(tool.name) + else: + tools.append(str(tool)) + else: + # Fallback to direct tools attribute + agent_tools = getattr(obj, "tools", None) + if agent_tools: + for tool in agent_tools: + if hasattr(tool, "__name__"): + tools.append(tool.__name__) + elif hasattr(tool, "name"): + tools.append(tool.name) + else: + tools.append(str(tool)) + + elif obj_type == "workflow": + # For workflows, extract executor names + if hasattr(obj, "get_executors_list"): + executor_objects = obj.get_executors_list() + tools = [getattr(ex, "id", str(ex)) for ex in executor_objects] + elif hasattr(obj, "executors"): + executors = obj.executors + if isinstance(executors, list): + tools = [getattr(ex, "id", str(ex)) for ex in executors] + elif isinstance(executors, dict): + tools = list(executors.keys()) + + except Exception as e: + logger.debug(f"Error extracting tools from {obj_type} {type(obj)}: {e}") + + return tools + + def _generate_entity_id(self, entity: Any, entity_type: str) -> str: + """Generate entity ID with priority: name -> id -> class_name -> uuid. + + Args: + entity: Entity object + entity_type: Type of entity (agent, workflow, etc.) + + Returns: + Generated entity ID + """ + import re + + # Priority 1: entity.name + if hasattr(entity, "name") and entity.name: + name = str(entity.name).lower().replace(" ", "-").replace("_", "-") + return f"{entity_type}_{name}" + + # Priority 2: entity.id + if hasattr(entity, "id") and entity.id: + entity_id = str(entity.id).lower().replace(" ", "-").replace("_", "-") + return f"{entity_type}_{entity_id}" + + # Priority 3: class name + if hasattr(entity, "__class__"): + class_name = entity.__class__.__name__ + # Convert CamelCase to kebab-case + class_name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", class_name).lower() + return f"{entity_type}_{class_name}" + + # Priority 4: fallback to uuid + return f"{entity_type}_{uuid.uuid4().hex[:8]}" diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py new file mode 100644 index 0000000000..9df2b32d68 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -0,0 +1,745 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent Framework executor implementation.""" + +import json +import logging +import os +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +from agent_framework import AgentThread + +from ._discovery import EntityDiscovery +from ._mapper import MessageMapper +from ._tracing import capture_traces +from .models import AgentFrameworkRequest, OpenAIResponse +from .models._discovery_models import EntityInfo + +logger = logging.getLogger(__name__) + + +class EntityNotFoundError(Exception): + """Raised when an entity is not found.""" + + pass + + +class AgentFrameworkExecutor: + """Executor for Agent Framework entities - agents and workflows.""" + + def __init__(self, entity_discovery: EntityDiscovery, message_mapper: MessageMapper): + """Initialize Agent Framework executor. + + Args: + entity_discovery: Entity discovery instance + message_mapper: Message mapper instance + """ + self.entity_discovery = entity_discovery + self.message_mapper = message_mapper + self._setup_tracing_provider() + self._setup_agent_framework_tracing() + + # Minimal thread storage - no metadata needed + self.thread_storage: dict[str, AgentThread] = {} + self.agent_threads: dict[str, list[str]] = {} # agent_id -> thread_ids + + def _setup_tracing_provider(self) -> None: + """Set up our own TracerProvider so we can add processors.""" + try: + from opentelemetry import trace + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + + # Only set up if no provider exists yet + if not hasattr(trace, "_TRACER_PROVIDER") or trace._TRACER_PROVIDER is None: + resource = Resource.create({ + "service.name": "agent-framework-server", + "service.version": "1.0.0", + }) + provider = TracerProvider(resource=resource) + trace.set_tracer_provider(provider) + logger.info("Set up TracerProvider for server tracing") + else: + logger.debug("TracerProvider already exists") + + except ImportError: + logger.debug("OpenTelemetry not available") + except Exception as e: + logger.warning(f"Failed to setup TracerProvider: {e}") + + def _setup_agent_framework_tracing(self) -> None: + """Set up Agent Framework's built-in tracing.""" + # Configure Agent Framework tracing only if OTLP endpoint is configured + otlp_endpoint = os.environ.get("AGENT_FRAMEWORK_OTLP_ENDPOINT") + if otlp_endpoint: + try: + from agent_framework.telemetry import setup_telemetry + + setup_telemetry(enable_otel=True, enable_sensitive_data=True, otlp_endpoint=otlp_endpoint) + logger.info(f"Enabled Agent Framework telemetry with endpoint: {otlp_endpoint}") + except Exception as e: + logger.warning(f"Failed to enable Agent Framework tracing: {e}") + else: + logger.debug("No OTLP endpoint configured, skipping telemetry setup") + + # Thread Management Methods + def create_thread(self, agent_id: str) -> str: + """Create new thread for agent.""" + thread_id = f"thread_{uuid.uuid4().hex[:8]}" + thread = AgentThread() + + self.thread_storage[thread_id] = thread + + if agent_id not in self.agent_threads: + self.agent_threads[agent_id] = [] + self.agent_threads[agent_id].append(thread_id) + + return thread_id + + def get_thread(self, thread_id: str) -> AgentThread | None: + """Get AgentThread by ID.""" + return self.thread_storage.get(thread_id) + + def list_threads_for_agent(self, agent_id: str) -> list[str]: + """List thread IDs for agent.""" + return self.agent_threads.get(agent_id, []) + + def get_agent_for_thread(self, thread_id: str) -> str | None: + """Find which agent owns this thread.""" + for agent_id, thread_ids in self.agent_threads.items(): + if thread_id in thread_ids: + return agent_id + return None + + def delete_thread(self, thread_id: str) -> bool: + """Delete thread.""" + if thread_id not in self.thread_storage: + return False + + # Remove from agent mapping + for _agent_id, thread_ids in self.agent_threads.items(): + if thread_id in thread_ids: + thread_ids.remove(thread_id) + break + + del self.thread_storage[thread_id] + return True + + async def get_thread_messages(self, thread_id: str) -> list[dict[str, Any]]: + """Get messages from a thread's message store, filtering for UI display.""" + thread = self.get_thread(thread_id) + if not thread or not thread.message_store: + return [] + + try: + # Get AgentFramework ChatMessage objects from thread + af_messages = await thread.message_store.list_messages() + + ui_messages = [] + for i, af_msg in enumerate(af_messages): + # Extract role value (handle enum) + role = af_msg.role.value if hasattr(af_msg.role, "value") else str(af_msg.role) + + # Skip tool/function messages - only show user and assistant text + if role not in ["user", "assistant"]: + continue + + # Extract user-facing text content only + text_content = self._extract_display_text(af_msg.contents) + + # Skip messages with no displayable text + if not text_content: + continue + + ui_message = { + "id": af_msg.message_id or f"restored-{i}", + "role": role, + "contents": [{"type": "text", "text": text_content}], + "timestamp": __import__("datetime").datetime.now().isoformat(), + "author_name": af_msg.author_name, + "message_id": af_msg.message_id, + } + + ui_messages.append(ui_message) + + logger.info(f"Restored {len(ui_messages)} display messages for thread {thread_id}") + return ui_messages + + except Exception as e: + logger.error(f"Error getting thread messages: {e}") + import traceback + + logger.error(traceback.format_exc()) + return [] + + def _extract_display_text(self, contents: list[Any]) -> str: + """Extract user-facing text from message contents, filtering out internal mechanics.""" + text_parts = [] + + for content in contents: + content_type = getattr(content, "type", None) + + # Only include text content for display + if content_type == "text": + text = getattr(content, "text", "") + + # Handle double-encoded JSON from user messages + if text.startswith('{"role":'): + try: + import json + + parsed = json.loads(text) + if parsed.get("contents"): + for sub_content in parsed["contents"]: + if sub_content.get("type") == "text": + text_parts.append(sub_content.get("text", "")) + except Exception: + text_parts.append(text) # Fallback to raw text + else: + text_parts.append(text) + + # Skip function_call, function_result, and other internal content types + + return " ".join(text_parts).strip() + + async def serialize_thread(self, thread_id: str) -> dict[str, Any] | None: + """Serialize thread state for persistence.""" + thread = self.get_thread(thread_id) + if not thread: + return None + + try: + # Use AgentThread's built-in serialization + serialized_state = await thread.serialize() + + # Add our metadata + agent_id = self.get_agent_for_thread(thread_id) + serialized_state["metadata"] = {"agent_id": agent_id, "thread_id": thread_id} + + return serialized_state + + except Exception as e: + logger.error(f"Error serializing thread {thread_id}: {e}") + return None + + async def deserialize_thread(self, thread_id: str, agent_id: str, serialized_state: dict[str, Any]) -> bool: + """Deserialize thread state from persistence.""" + try: + # Create new thread + thread = AgentThread() + + # Use AgentThread's built-in deserialization + from agent_framework._threads import deserialize_thread_state + + await deserialize_thread_state(thread, serialized_state) + + # Store the restored thread + self.thread_storage[thread_id] = thread + + if agent_id not in self.agent_threads: + self.agent_threads[agent_id] = [] + self.agent_threads[agent_id].append(thread_id) + + return True + + except Exception as e: + logger.error(f"Error deserializing thread {thread_id}: {e}") + return False + + async def discover_entities(self) -> list[EntityInfo]: + """Discover all available entities. + + Returns: + List of discovered entities + """ + return await self.entity_discovery.discover_entities() + + def get_entity_info(self, entity_id: str) -> EntityInfo: + """Get entity information. + + Args: + entity_id: Entity identifier + + Returns: + Entity information + + Raises: + EntityNotFoundError: If entity is not found + """ + entity_info = self.entity_discovery.get_entity_info(entity_id) + if entity_info is None: + raise EntityNotFoundError(f"Entity '{entity_id}' not found") + return entity_info + + async def execute_streaming(self, request: AgentFrameworkRequest) -> AsyncGenerator[Any, None]: + """Execute request and stream results in OpenAI format. + + Args: + request: Request to execute + + Yields: + OpenAI response stream events + """ + try: + entity_id = request.get_entity_id() + if not entity_id: + logger.error("No entity_id specified in request") + return + + # Validate entity exists + if not self.entity_discovery.get_entity_info(entity_id): + logger.error(f"Entity '{entity_id}' not found") + return + + # Execute entity and convert events + async for raw_event in self.execute_entity(entity_id, request): + openai_events = await self.message_mapper.convert_event(raw_event, request) + for event in openai_events: + yield event + + except Exception as e: + logger.exception(f"Error in streaming execution: {e}") + # Could yield error event here + + async def execute_sync(self, request: AgentFrameworkRequest) -> OpenAIResponse: + """Execute request synchronously and return complete response. + + Args: + request: Request to execute + + Returns: + Final aggregated OpenAI response + """ + # Collect all streaming events + events = [event async for event in self.execute_streaming(request)] + + # Aggregate into final response + return await self.message_mapper.aggregate_to_response(events, request) + + async def execute_entity(self, entity_id: str, request: AgentFrameworkRequest) -> AsyncGenerator[Any, None]: + """Execute the entity and yield raw Agent Framework events plus trace events. + + Args: + entity_id: ID of entity to execute + request: Request to execute + + Yields: + Raw Agent Framework events and trace events + """ + try: + # Get entity info and object + entity_info = self.get_entity_info(entity_id) + entity_obj = self.entity_discovery.get_entity_object(entity_id) + + if not entity_obj: + raise EntityNotFoundError(f"Entity object for '{entity_id}' not found") + + logger.info(f"Executing {entity_info.type}: {entity_id}") + + # Extract session_id from request for trace context + session_id = getattr(request.extra_body, "session_id", None) if request.extra_body else None + + # Use simplified trace capture + with capture_traces(session_id=session_id, entity_id=entity_id) as trace_collector: + if entity_info.type == "agent": + async for event in self._execute_agent(entity_obj, request, trace_collector): + yield event + elif entity_info.type == "workflow": + async for event in self._execute_workflow(entity_obj, request, trace_collector): + yield event + else: + raise ValueError(f"Unsupported entity type: {entity_info.type}") + + # Yield any remaining trace events after execution completes + for trace_event in trace_collector.get_pending_events(): + yield trace_event + + except Exception as e: + logger.exception(f"Error executing entity {entity_id}: {e}") + # Yield error event + yield {"type": "error", "message": str(e), "entity_id": entity_id} + + async def _execute_agent( + self, agent: Any, request: AgentFrameworkRequest, trace_collector: Any + ) -> AsyncGenerator[Any, None]: + """Execute Agent Framework agent with trace collection and optional thread support. + + Args: + agent: Agent object to execute + request: Request to execute + trace_collector: Trace collector to get events from + + Yields: + Agent update events and trace events + """ + try: + # Convert input to proper ChatMessage or string + user_message = self._convert_input_to_chat_message(request.input) + + # Get thread if provided in extra_body + thread = None + if request.extra_body and hasattr(request.extra_body, "thread_id") and request.extra_body.thread_id: + thread_id = request.extra_body.thread_id + thread = self.get_thread(thread_id) + if thread: + logger.debug(f"Using existing thread: {thread_id}") + else: + logger.warning(f"Thread {thread_id} not found, proceeding without thread") + + # Debug logging - handle both string and ChatMessage + if isinstance(user_message, str): + logger.debug(f"Executing agent with text input: {user_message[:100]}...") + else: + logger.debug(f"Executing agent with multimodal ChatMessage: {type(user_message)}") + + # Use Agent Framework's native streaming with optional thread + if thread: + async for update in agent.run_stream(user_message, thread=thread): + # Yield any pending trace events first + for trace_event in trace_collector.get_pending_events(): + yield trace_event + + # Then yield the execution update + yield update + else: + async for update in agent.run_stream(user_message): + # Yield any pending trace events first + for trace_event in trace_collector.get_pending_events(): + yield trace_event + + # Then yield the execution update + yield update + + except Exception as e: + logger.error(f"Error in agent execution: {e}") + yield {"type": "error", "message": f"Agent execution error: {e!s}"} + + async def _execute_workflow( + self, workflow: Any, request: AgentFrameworkRequest, trace_collector: Any + ) -> AsyncGenerator[Any, None]: + """Execute Agent Framework workflow with trace collection. + + Args: + workflow: Workflow object to execute + request: Request to execute + trace_collector: Trace collector to get events from + + Yields: + Workflow events and trace events + """ + try: + # Get input data - prefer structured data from extra_body + input_data: str | list[Any] | dict[str, Any] + if request.extra_body and hasattr(request.extra_body, "input_data") and request.extra_body.input_data: + input_data = request.extra_body.input_data + logger.debug(f"Using structured input_data from extra_body: {type(input_data)}") + else: + input_data = request.input + logger.debug(f"Using input field as fallback: {type(input_data)}") + + # Parse input based on workflow's expected input type + parsed_input = await self._parse_workflow_input(workflow, input_data) + + logger.debug(f"Executing workflow with parsed input type: {type(parsed_input)}") + + # Use Agent Framework workflow's native streaming + async for event in workflow.run_stream(parsed_input): + # Yield any pending trace events first + for trace_event in trace_collector.get_pending_events(): + yield trace_event + + # Then yield the workflow event + yield event + + except Exception as e: + logger.error(f"Error in workflow execution: {e}") + yield {"type": "error", "message": f"Workflow execution error: {e!s}"} + + def _convert_input_to_chat_message(self, input_data: Any) -> Any: + """Convert OpenAI Responses API input to Agent Framework ChatMessage or string. + + Args: + input_data: OpenAI ResponseInputParam (List[ResponseInputItemParam]) + + Returns: + ChatMessage for multimodal content, or string for simple text + """ + # Import Agent Framework types + try: + from agent_framework import ChatMessage, DataContent, Role, TextContent + except ImportError: + # Fallback to string extraction if Agent Framework not available + return self._extract_user_message_fallback(input_data) + + # Handle simple string input (backward compatibility) + if isinstance(input_data, str): + return input_data + + # Handle OpenAI ResponseInputParam (List[ResponseInputItemParam]) + if isinstance(input_data, list): + return self._convert_openai_input_to_chat_message(input_data, ChatMessage, TextContent, DataContent, Role) + + # Fallback for other formats + return self._extract_user_message_fallback(input_data) + + def _convert_openai_input_to_chat_message( + self, input_items: list[Any], ChatMessage: Any, TextContent: Any, DataContent: Any, Role: Any + ) -> Any: + """Convert OpenAI ResponseInputParam to Agent Framework ChatMessage. + + Args: + input_items: List of OpenAI ResponseInputItemParam objects (dicts or objects) + ChatMessage: ChatMessage class for creating chat messages + TextContent: TextContent class for text content + DataContent: DataContent class for data/media content + Role: Role enum for message roles + + Returns: + ChatMessage with converted content + """ + contents = [] + + # Process each input item + for item in input_items: + # Handle dict format (from JSON) + if isinstance(item, dict): + item_type = item.get("type") + if item_type == "message": + # Extract content from OpenAI message + message_content = item.get("content", []) + + # Handle both string content and list content + if isinstance(message_content, str): + contents.append(TextContent(text=message_content)) + elif isinstance(message_content, list): + for content_item in message_content: + # Handle dict content items + if isinstance(content_item, dict): + content_type = content_item.get("type") + + if content_type == "input_text": + text = content_item.get("text", "") + contents.append(TextContent(text=text)) + + elif content_type == "input_image": + image_url = content_item.get("image_url", "") + if image_url: + # Extract media type from data URI if possible + # Parse media type from data URL, fallback to image/png + if image_url.startswith("data:"): + try: + # Extract media type from data:image/jpeg;base64,... format + media_type = image_url.split(";")[0].split(":")[1] + except (IndexError, AttributeError): + logger.warning( + f"Failed to parse media type from data URL: {image_url[:30]}..." + ) + media_type = "image/png" + else: + media_type = "image/png" + contents.append(DataContent(uri=image_url, media_type=media_type)) + + elif content_type == "input_file": + # Handle file input + file_data = content_item.get("file_data") + file_url = content_item.get("file_url") + filename = content_item.get("filename", "") + + # Determine media type from filename + media_type = "application/octet-stream" # default + if filename: + if filename.lower().endswith(".pdf"): + media_type = "application/pdf" + elif filename.lower().endswith((".png", ".jpg", ".jpeg", ".gif")): + media_type = f"image/{filename.split('.')[-1].lower()}" + + # Use file_data or file_url + if file_data: + # Assume file_data is base64, create data URI + data_uri = f"data:{media_type};base64,{file_data}" + contents.append(DataContent(uri=data_uri, media_type=media_type)) + elif file_url: + contents.append(DataContent(uri=file_url, media_type=media_type)) + + # Handle other OpenAI input item types as needed + # (tool calls, function results, etc.) + + # If no contents found, create a simple text message + if not contents: + contents.append(TextContent(text="")) + + # Create ChatMessage with user role + return ChatMessage(role=Role.USER, contents=contents) + + def _extract_user_message_fallback(self, input_data: Any) -> str: + """Fallback method to extract user message as string. + + Args: + input_data: Input data in various formats + + Returns: + Extracted user message string + """ + if isinstance(input_data, str): + return input_data + if isinstance(input_data, dict): + # Try common field names + for field in ["message", "text", "input", "content", "query"]: + if field in input_data: + return str(input_data[field]) + # Fallback to JSON string + return json.dumps(input_data) + return str(input_data) + + async def _parse_workflow_input(self, workflow: Any, raw_input: Any) -> Any: + """Parse input based on workflow's expected input type. + + Args: + workflow: Workflow object + raw_input: Raw input data + + Returns: + Parsed input appropriate for the workflow + """ + try: + # Handle structured input + if isinstance(raw_input, dict): + return self._parse_structured_workflow_input(workflow, raw_input) + return self._parse_raw_workflow_input(workflow, str(raw_input)) + + except Exception as e: + logger.warning(f"Error parsing workflow input: {e}") + return raw_input + + def _parse_structured_workflow_input(self, workflow: Any, input_data: dict[str, Any]) -> Any: + """Parse structured input data for workflow execution. + + Args: + workflow: Workflow object + input_data: Structured input data + + Returns: + Parsed input for workflow + """ + try: + # Get the start executor and its input type + start_executor = workflow.get_start_executor() + if not start_executor or not hasattr(start_executor, "_handlers"): + logger.debug("Cannot determine input type for workflow - using raw dict") + return input_data + + message_types = list(start_executor._handlers.keys()) + if not message_types: + logger.debug("No message types found for start executor - using raw dict") + return input_data + + # Get the first (primary) input type + input_type = message_types[0] + + # If input type is dict, return as-is + if input_type is dict: + return input_data + + # Handle primitive types + if input_type in (str, int, float, bool): + try: + if isinstance(input_data, input_type): + return input_data + if "input" in input_data: + return input_type(input_data["input"]) + if len(input_data) == 1: + value = next(iter(input_data.values())) + return input_type(value) + return input_data + except (ValueError, TypeError) as e: + logger.warning(f"Failed to convert input to {input_type}: {e}") + return input_data + + # If it's a Pydantic model, validate and create instance + if hasattr(input_type, "model_validate"): + try: + return input_type.model_validate(input_data) + except Exception as e: + logger.warning(f"Failed to validate input as {input_type}: {e}") + return input_data + + # If it's a dataclass or other type with annotations + elif hasattr(input_type, "__annotations__"): + try: + return input_type(**input_data) + except Exception as e: + logger.warning(f"Failed to create {input_type} from input data: {e}") + return input_data + + except Exception as e: + logger.warning(f"Error parsing structured workflow input: {e}") + + return input_data + + def _parse_raw_workflow_input(self, workflow: Any, raw_input: str) -> Any: + """Parse raw input string based on workflow's expected input type. + + Args: + workflow: Workflow object + raw_input: Raw input string + + Returns: + Parsed input for workflow + """ + try: + # Get the start executor and its input type + start_executor = workflow.get_start_executor() + if not start_executor or not hasattr(start_executor, "_handlers"): + logger.debug("Cannot determine input type for workflow - using raw string") + return raw_input + + message_types = list(start_executor._handlers.keys()) + if not message_types: + logger.debug("No message types found for start executor - using raw string") + return raw_input + + # Get the first (primary) input type + input_type = message_types[0] + + # If input type is str, return as-is + if input_type is str: + return raw_input + + # If it's a Pydantic model, try to parse JSON + if hasattr(input_type, "model_validate_json"): + try: + # First try to parse as JSON + if raw_input.strip().startswith("{"): + return input_type.model_validate_json(raw_input) + + # Try common field names + common_fields = ["message", "text", "input", "data", "content"] + for field in common_fields: + try: + return input_type(**{field: raw_input}) + except Exception as e: + logger.debug(f"Failed to parse input using field '{field}': {e}") + continue + + # Last resort: try default constructor + return input_type() + + except Exception as e: + logger.debug(f"Failed to parse input as {input_type}: {e}") + + # If it's a dataclass, try JSON parsing + elif hasattr(input_type, "__annotations__"): + try: + if raw_input.strip().startswith("{"): + parsed = json.loads(raw_input) + return input_type(**parsed) + except Exception as e: + logger.debug(f"Failed to parse input as {input_type}: {e}") + + except Exception as e: + logger.debug(f"Error determining workflow input type: {e}") + + # Fallback: return raw string + return raw_input diff --git a/python/packages/devui/agent_framework_devui/_mapper.py b/python/packages/devui/agent_framework_devui/_mapper.py new file mode 100644 index 0000000000..30a9058677 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/_mapper.py @@ -0,0 +1,527 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent Framework message mapper implementation.""" + +import json +import logging +import uuid +from collections.abc import Sequence +from datetime import datetime +from typing import Any, Union + +from .models import ( + AgentFrameworkRequest, + InputTokensDetails, + OpenAIResponse, + OutputTokensDetails, + ResponseErrorEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionResultComplete, + ResponseOutputMessage, + ResponseOutputText, + ResponseReasoningTextDeltaEvent, + ResponseStreamEvent, + ResponseTextDeltaEvent, + ResponseTraceEventComplete, + ResponseUsage, + ResponseUsageEventComplete, + ResponseWorkflowEventComplete, +) + +logger = logging.getLogger(__name__) + +# Type alias for all possible event types +EventType = Union[ + ResponseStreamEvent, + ResponseWorkflowEventComplete, + ResponseFunctionResultComplete, + ResponseTraceEventComplete, + ResponseUsageEventComplete, +] + + +class MessageMapper: + """Maps Agent Framework messages/responses to OpenAI format.""" + + def __init__(self) -> None: + """Initialize Agent Framework message mapper.""" + self.sequence_counter = 0 + self._conversion_contexts: dict[int, dict[str, Any]] = {} + + # Register content type mappers for all 12 Agent Framework content types + self.content_mappers = { + "TextContent": self._map_text_content, + "TextReasoningContent": self._map_reasoning_content, + "FunctionCallContent": self._map_function_call_content, + "FunctionResultContent": self._map_function_result_content, + "ErrorContent": self._map_error_content, + "UsageContent": self._map_usage_content, + "DataContent": self._map_data_content, + "UriContent": self._map_uri_content, + "HostedFileContent": self._map_hosted_file_content, + "HostedVectorStoreContent": self._map_hosted_vector_store_content, + "FunctionApprovalRequestContent": self._map_approval_request_content, + "FunctionApprovalResponseContent": self._map_approval_response_content, + } + + async def convert_event(self, raw_event: Any, request: AgentFrameworkRequest) -> Sequence[Any]: + """Convert a single Agent Framework event to OpenAI events. + + Args: + raw_event: Agent Framework event (AgentRunResponseUpdate, WorkflowEvent, etc.) + request: Original request for context + + Returns: + List of OpenAI response stream events + """ + context = self._get_or_create_context(request) + + # Handle error events + if isinstance(raw_event, dict) and raw_event.get("type") == "error": + return [await self._create_error_event(raw_event.get("message", "Unknown error"), context)] + + # Handle ResponseTraceEvent objects from our trace collector + from .models import ResponseTraceEvent + + if isinstance(raw_event, ResponseTraceEvent): + return [ + ResponseTraceEventComplete( + type="response.trace.complete", + data=raw_event.data, + item_id=context["item_id"], + sequence_number=self._next_sequence(context), + ) + ] + + # Import Agent Framework types for proper isinstance checks + try: + from agent_framework import AgentRunResponseUpdate, WorkflowEvent + + # Handle agent updates (AgentRunResponseUpdate) + if isinstance(raw_event, AgentRunResponseUpdate): + return await self._convert_agent_update(raw_event, context) + + # Handle workflow events (any class that inherits from WorkflowEvent) + if isinstance(raw_event, WorkflowEvent): + return await self._convert_workflow_event(raw_event, context) + + except ImportError as e: + logger.warning(f"Could not import Agent Framework types: {e}") + # Fallback to attribute-based detection + if hasattr(raw_event, "contents"): + return await self._convert_agent_update(raw_event, context) + if hasattr(raw_event, "__class__") and "Event" in raw_event.__class__.__name__: + return await self._convert_workflow_event(raw_event, context) + + # Unknown event type + return [await self._create_unknown_event(raw_event, context)] + + async def aggregate_to_response(self, events: Sequence[Any], request: AgentFrameworkRequest) -> OpenAIResponse: + """Aggregate streaming events into final OpenAI response. + + Args: + events: List of OpenAI stream events + request: Original request for context + + Returns: + Final aggregated OpenAI response + """ + try: + # Extract text content from events + content_parts = [] + + for event in events: + # Extract delta text from ResponseTextDeltaEvent + if hasattr(event, "delta") and hasattr(event, "type") and event.type == "response.output_text.delta": + content_parts.append(event.delta) + + # Combine content + full_content = "".join(content_parts) + + # Create proper OpenAI Response + response_output_text = ResponseOutputText(type="output_text", text=full_content, annotations=[]) + + response_output_message = ResponseOutputMessage( + type="message", + role="assistant", + content=[response_output_text], + id=f"msg_{uuid.uuid4().hex[:8]}", + status="completed", + ) + + # Create usage object + input_token_count = len(str(request.input)) // 4 if request.input else 0 + output_token_count = len(full_content) // 4 + + usage = ResponseUsage( + input_tokens=input_token_count, + output_tokens=output_token_count, + total_tokens=input_token_count + output_token_count, + input_tokens_details=InputTokensDetails(cached_tokens=0), + output_tokens_details=OutputTokensDetails(reasoning_tokens=0), + ) + + return OpenAIResponse( + id=f"resp_{uuid.uuid4().hex[:12]}", + object="response", + created_at=datetime.now().timestamp(), + model=request.model, + output=[response_output_message], + usage=usage, + parallel_tool_calls=False, + tool_choice="none", + tools=[], + ) + + except Exception as e: + logger.exception(f"Error aggregating response: {e}") + return await self._create_error_response(str(e), request) + + def _get_or_create_context(self, request: AgentFrameworkRequest) -> dict[str, Any]: + """Get or create conversion context for this request. + + Args: + request: Request to get context for + + Returns: + Conversion context dictionary + """ + request_key = id(request) + if request_key not in self._conversion_contexts: + self._conversion_contexts[request_key] = { + "sequence_counter": 0, + "item_id": f"msg_{uuid.uuid4().hex[:8]}", + "content_index": 0, + "output_index": 0, + } + return self._conversion_contexts[request_key] + + def _next_sequence(self, context: dict[str, Any]) -> int: + """Get next sequence number for events. + + Args: + context: Conversion context + + Returns: + Next sequence number + """ + context["sequence_counter"] += 1 + return int(context["sequence_counter"]) + + async def _convert_agent_update(self, update: Any, context: dict[str, Any]) -> Sequence[Any]: + """Convert AgentRunResponseUpdate to OpenAI events using comprehensive content mapping. + + Args: + update: Agent run response update + context: Conversion context + + Returns: + List of OpenAI response stream events + """ + events: list[Any] = [] + + try: + # Handle different update types + if not hasattr(update, "contents") or not update.contents: + return events + + for content in update.contents: + content_type = content.__class__.__name__ + + if content_type in self.content_mappers: + mapped_events = await self.content_mappers[content_type](content, context) + if isinstance(mapped_events, list): + events.extend(mapped_events) + else: + events.append(mapped_events) + else: + # Graceful fallback for unknown content types + events.append(await self._create_unknown_content_event(content, context)) + + context["content_index"] += 1 + + except Exception as e: + logger.warning(f"Error converting agent update: {e}") + events.append(await self._create_error_event(str(e), context)) + + return events + + async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> Sequence[Any]: + """Convert workflow event to structured OpenAI events. + + Args: + event: Workflow event + context: Conversion context + + Returns: + List of OpenAI response stream events + """ + try: + # Create structured workflow event + workflow_event = ResponseWorkflowEventComplete( + type="response.workflow_event.complete", + data={ + "event_type": event.__class__.__name__, + "data": getattr(event, "data", None), + "executor_id": getattr(event, "executor_id", None), + "timestamp": datetime.now().isoformat(), + }, + executor_id=getattr(event, "executor_id", None), + item_id=context["item_id"], + output_index=context["output_index"], + sequence_number=self._next_sequence(context), + ) + + return [workflow_event] + + except Exception as e: + logger.warning(f"Error converting workflow event: {e}") + return [await self._create_error_event(str(e), context)] + + # Content type mappers - implementing our comprehensive mapping plan + + async def _map_text_content(self, content: Any, context: dict[str, Any]) -> ResponseTextDeltaEvent: + """Map TextContent to ResponseTextDeltaEvent.""" + return self._create_text_delta_event(content.text, context) + + async def _map_reasoning_content(self, content: Any, context: dict[str, Any]) -> ResponseReasoningTextDeltaEvent: + """Map TextReasoningContent to ResponseReasoningTextDeltaEvent.""" + return ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + delta=content.text, + item_id=context["item_id"], + output_index=context["output_index"], + content_index=context["content_index"], + sequence_number=self._next_sequence(context), + ) + + async def _map_function_call_content( + self, content: Any, context: dict[str, Any] + ) -> list[ResponseFunctionCallArgumentsDeltaEvent]: + """Map FunctionCallContent to ResponseFunctionCallArgumentsDeltaEvent(s).""" + events = [] + + # For streaming, need to chunk the arguments JSON + args_str = json.dumps(content.arguments) if hasattr(content, "arguments") and content.arguments else "{}" + + # Chunk the JSON string for streaming + for chunk in self._chunk_json_string(args_str): + events.append( + ResponseFunctionCallArgumentsDeltaEvent( + type="response.function_call_arguments.delta", + delta=chunk, + item_id=context["item_id"], + output_index=context["output_index"], + sequence_number=self._next_sequence(context), + ) + ) + + return events + + async def _map_function_result_content( + self, content: Any, context: dict[str, Any] + ) -> ResponseFunctionResultComplete: + """Map FunctionResultContent to structured event.""" + return ResponseFunctionResultComplete( + type="response.function_result.complete", + data={ + "call_id": getattr(content, "call_id", f"call_{uuid.uuid4().hex[:8]}"), + "result": getattr(content, "result", None), + "status": "completed" if not getattr(content, "exception", None) else "failed", + "exception": str(getattr(content, "exception", None)) if getattr(content, "exception", None) else None, + "timestamp": datetime.now().isoformat(), + }, + call_id=getattr(content, "call_id", f"call_{uuid.uuid4().hex[:8]}"), + item_id=context["item_id"], + output_index=context["output_index"], + sequence_number=self._next_sequence(context), + ) + + async def _map_error_content(self, content: Any, context: dict[str, Any]) -> ResponseErrorEvent: + """Map ErrorContent to ResponseErrorEvent.""" + return ResponseErrorEvent( + type="error", + message=getattr(content, "message", "Unknown error"), + code=getattr(content, "error_code", None), + param=None, + sequence_number=self._next_sequence(context), + ) + + async def _map_usage_content(self, content: Any, context: dict[str, Any]) -> ResponseUsageEventComplete: + """Map UsageContent to structured usage event.""" + # Store usage data in context for aggregation + if "usage_data" not in context: + context["usage_data"] = [] + context["usage_data"].append(content) + + return ResponseUsageEventComplete( + type="response.usage.complete", + data={ + "usage_data": getattr(content, "usage_data", {}), + "total_tokens": getattr(content, "total_tokens", 0), + "completion_tokens": getattr(content, "completion_tokens", 0), + "prompt_tokens": getattr(content, "prompt_tokens", 0), + "timestamp": datetime.now().isoformat(), + }, + item_id=context["item_id"], + output_index=context["output_index"], + sequence_number=self._next_sequence(context), + ) + + async def _map_data_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete: + """Map DataContent to structured trace event.""" + return ResponseTraceEventComplete( + type="response.trace.complete", + data={ + "content_type": "data", + "data": getattr(content, "data", None), + "mime_type": getattr(content, "mime_type", "application/octet-stream"), + "size_bytes": len(str(getattr(content, "data", ""))) if getattr(content, "data", None) else 0, + "timestamp": datetime.now().isoformat(), + }, + item_id=context["item_id"], + output_index=context["output_index"], + sequence_number=self._next_sequence(context), + ) + + async def _map_uri_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete: + """Map UriContent to structured trace event.""" + return ResponseTraceEventComplete( + type="response.trace.complete", + data={ + "content_type": "uri", + "uri": getattr(content, "uri", ""), + "mime_type": getattr(content, "mime_type", "text/plain"), + "timestamp": datetime.now().isoformat(), + }, + item_id=context["item_id"], + output_index=context["output_index"], + sequence_number=self._next_sequence(context), + ) + + async def _map_hosted_file_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete: + """Map HostedFileContent to structured trace event.""" + return ResponseTraceEventComplete( + type="response.trace.complete", + data={ + "content_type": "hosted_file", + "file_id": getattr(content, "file_id", "unknown"), + "timestamp": datetime.now().isoformat(), + }, + item_id=context["item_id"], + output_index=context["output_index"], + sequence_number=self._next_sequence(context), + ) + + async def _map_hosted_vector_store_content( + self, content: Any, context: dict[str, Any] + ) -> ResponseTraceEventComplete: + """Map HostedVectorStoreContent to structured trace event.""" + return ResponseTraceEventComplete( + type="response.trace.complete", + data={ + "content_type": "hosted_vector_store", + "vector_store_id": getattr(content, "vector_store_id", "unknown"), + "timestamp": datetime.now().isoformat(), + }, + item_id=context["item_id"], + output_index=context["output_index"], + sequence_number=self._next_sequence(context), + ) + + async def _map_approval_request_content(self, content: Any, context: dict[str, Any]) -> dict[str, Any]: + """Map FunctionApprovalRequestContent to custom event.""" + return { + "type": "response.function_approval.requested", + "request_id": getattr(content, "id", "unknown"), + "function_call": { + "id": getattr(content.function_call, "call_id", "") if hasattr(content, "function_call") else "", + "name": getattr(content.function_call, "name", "") if hasattr(content, "function_call") else "", + "arguments": getattr(content.function_call, "arguments", {}) + if hasattr(content, "function_call") + else {}, + }, + "item_id": context["item_id"], + "output_index": context["output_index"], + "sequence_number": self._next_sequence(context), + } + + async def _map_approval_response_content(self, content: Any, context: dict[str, Any]) -> dict[str, Any]: + """Map FunctionApprovalResponseContent to custom event.""" + return { + "type": "response.function_approval.responded", + "request_id": getattr(content, "request_id", "unknown"), + "approved": getattr(content, "approved", False), + "item_id": context["item_id"], + "output_index": context["output_index"], + "sequence_number": self._next_sequence(context), + } + + # Helper methods + + def _create_text_delta_event(self, text: str, context: dict[str, Any]) -> ResponseTextDeltaEvent: + """Create a ResponseTextDeltaEvent.""" + return ResponseTextDeltaEvent( + type="response.output_text.delta", + item_id=context["item_id"], + output_index=context["output_index"], + content_index=context["content_index"], + delta=text, + sequence_number=self._next_sequence(context), + logprobs=[], + ) + + async def _create_error_event(self, message: str, context: dict[str, Any]) -> ResponseErrorEvent: + """Create a ResponseErrorEvent.""" + return ResponseErrorEvent( + type="error", message=message, code=None, param=None, sequence_number=self._next_sequence(context) + ) + + async def _create_unknown_event(self, event_data: Any, context: dict[str, Any]) -> ResponseStreamEvent: + """Create event for unknown event types.""" + text = f"Unknown event: {event_data!s}\\n" + return self._create_text_delta_event(text, context) + + async def _create_unknown_content_event(self, content: Any, context: dict[str, Any]) -> ResponseStreamEvent: + """Create event for unknown content types.""" + content_type = content.__class__.__name__ + text = f"⚠️ Unknown content type: {content_type}\\n" + return self._create_text_delta_event(text, context) + + def _chunk_json_string(self, json_str: str, chunk_size: int = 50) -> list[str]: + """Chunk JSON string for streaming.""" + return [json_str[i : i + chunk_size] for i in range(0, len(json_str), chunk_size)] + + async def _create_error_response(self, error_message: str, request: AgentFrameworkRequest) -> OpenAIResponse: + """Create error response.""" + error_text = f"Error: {error_message}" + + response_output_text = ResponseOutputText(type="output_text", text=error_text, annotations=[]) + + response_output_message = ResponseOutputMessage( + type="message", + role="assistant", + content=[response_output_text], + id=f"msg_{uuid.uuid4().hex[:8]}", + status="completed", + ) + + usage = ResponseUsage( + input_tokens=0, + output_tokens=0, + total_tokens=0, + input_tokens_details=InputTokensDetails(cached_tokens=0), + output_tokens_details=OutputTokensDetails(reasoning_tokens=0), + ) + + return OpenAIResponse( + id=f"resp_{uuid.uuid4().hex[:12]}", + object="response", + created_at=datetime.now().timestamp(), + model=request.model, + output=[response_output_message], + usage=usage, + parallel_tool_calls=False, + tool_choice="none", + tools=[], + ) diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py new file mode 100644 index 0000000000..206877dcc4 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -0,0 +1,397 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""FastAPI server implementation.""" + +import logging +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles + +from ._discovery import EntityDiscovery +from ._executor import AgentFrameworkExecutor +from ._mapper import MessageMapper +from .models import AgentFrameworkRequest, OpenAIError +from .models._discovery_models import DiscoveryResponse, EntityInfo + +# Removed ExecutionEngine import - using direct executor approach + +logger = logging.getLogger(__name__) + + +class DevServer: + """Development Server - OpenAI compatible API server for debugging agents.""" + + def __init__( + self, + entities_dir: str | None = None, + port: int = 8080, + host: str = "127.0.0.1", + cors_origins: list[str] | None = None, + ui_enabled: bool = True, + ) -> None: + """Initialize the development server. + + Args: + entities_dir: Directory to scan for entities + port: Port to run server on + host: Host to bind server to + cors_origins: List of allowed CORS origins + ui_enabled: Whether to enable the UI + """ + self.entities_dir = entities_dir + self.port = port + self.host = host + self.cors_origins = cors_origins or ["*"] + self.ui_enabled = ui_enabled + self.executor: AgentFrameworkExecutor | None = None + self._app: FastAPI | None = None + self._pending_entities: list[Any] | None = None + + async def _ensure_executor(self) -> AgentFrameworkExecutor: + """Ensure executor is initialized.""" + if self.executor is None: + logger.info("Initializing Agent Framework executor...") + + # Create components directly + entity_discovery = EntityDiscovery(self.entities_dir) + message_mapper = MessageMapper() + self.executor = AgentFrameworkExecutor(entity_discovery, message_mapper) + + # Discover entities from directory + discovered_entities = await self.executor.discover_entities() + logger.info(f"Discovered {len(discovered_entities)} entities from directory") + + # Register any pending in-memory entities + if self._pending_entities: + discovery = self.executor.entity_discovery + for entity in self._pending_entities: + try: + entity_info = await discovery.create_entity_info_from_object(entity) + discovery.register_entity(entity_info.id, entity_info, entity) + logger.info(f"Registered in-memory entity: {entity_info.id}") + except Exception as e: + logger.error(f"Failed to register in-memory entity: {e}") + self._pending_entities = None # Clear after registration + + # Get the final entity count after all registration + all_entities = self.executor.entity_discovery.list_entities() + logger.info(f"Total entities available: {len(all_entities)}") + + return self.executor + + def create_app(self) -> FastAPI: + """Create the FastAPI application.""" + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + # Startup + logger.info("Starting Agent Framework Server") + await self._ensure_executor() + yield + # Shutdown + logger.info("Shutting down Agent Framework Server") + + app = FastAPI( + title="Agent Framework Server", + description="OpenAI-compatible API server for Agent Framework and other AI frameworks", + version="1.0.0", + lifespan=lifespan, + ) + + # Add CORS middleware + app.add_middleware( + CORSMiddleware, + allow_origins=self.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + self._register_routes(app) + self._mount_ui(app) + + return app + + def _register_routes(self, app: FastAPI) -> None: + """Register API routes.""" + + @app.get("/health") + async def health_check() -> dict[str, Any]: + """Health check endpoint.""" + executor = await self._ensure_executor() + entities = await executor.discover_entities() + + return {"status": "healthy", "entities_count": len(entities), "framework": "agent_framework"} + + @app.get("/v1/entities", response_model=DiscoveryResponse) + async def discover_entities() -> DiscoveryResponse: + """List all registered entities.""" + try: + executor = await self._ensure_executor() + # Use list_entities() instead of discover_entities() to get already-registered entities + entities = executor.entity_discovery.list_entities() + return DiscoveryResponse(entities=entities) + except Exception as e: + logger.error(f"Error listing entities: {e}") + raise HTTPException(status_code=500, detail=f"Entity listing failed: {e!s}") from e + + @app.get("/v1/entities/{entity_id}/info", response_model=EntityInfo) + async def get_entity_info(entity_id: str) -> EntityInfo: + """Get detailed information about a specific entity.""" + try: + executor = await self._ensure_executor() + entity_info = executor.get_entity_info(entity_id) + + if not entity_info: + raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found") + + # For workflows, populate additional detailed information + if entity_info.type == "workflow": + entity_obj = executor.entity_discovery.get_entity_object(entity_id) + if entity_obj: + # Get workflow structure + workflow_dump = None + if hasattr(entity_obj, "model_dump"): + workflow_dump = entity_obj.model_dump() + elif hasattr(entity_obj, "__dict__"): + workflow_dump = {k: v for k, v in entity_obj.__dict__.items() if not k.startswith("_")} + + # Get input schema information + input_schema = {} + input_type_name = "Unknown" + start_executor_id = "" + + try: + start_executor = entity_obj.get_start_executor() + if start_executor and hasattr(start_executor, "_handlers"): + message_types = list(start_executor._handlers.keys()) + if message_types: + input_type = message_types[0] + input_type_name = getattr(input_type, "__name__", str(input_type)) + + # Basic schema generation for common types + if input_type is str: + input_schema = {"type": "string"} + elif input_type is dict: + input_schema = {"type": "object"} + elif hasattr(input_type, "model_json_schema"): + input_schema = input_type.model_json_schema() + + start_executor_id = getattr(start_executor, "executor_id", "") + except Exception as e: + logger.debug(f"Could not extract input info for workflow {entity_id}: {e}") + + # Get executor list + executor_list = [] + if hasattr(entity_obj, "executors") and entity_obj.executors: + executor_list = [getattr(ex, "executor_id", str(ex)) for ex in entity_obj.executors] + + # Create copy of entity info and populate workflow-specific fields + enhanced_info = entity_info.model_copy() + enhanced_info.workflow_dump = workflow_dump + enhanced_info.input_schema = input_schema + enhanced_info.input_type_name = input_type_name + enhanced_info.start_executor_id = start_executor_id + + # Update executors field if we found better data + if executor_list: + enhanced_info.executors = executor_list + return enhanced_info + + # For non-workflow entities, return as-is + return entity_info + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting entity info for {entity_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get entity info: {e!s}") from e + + @app.post("/v1/responses") + async def create_response(request: AgentFrameworkRequest, raw_request: Request) -> Any: + """OpenAI Responses API endpoint.""" + try: + # Debug: log the incoming request + raw_body = await raw_request.body() + logger.info(f"Raw request body: {raw_body.decode()}") + logger.info(f"Parsed request: model={request.model}, extra_body={request.extra_body}") + + # Get entity_id using the new method + entity_id = request.get_entity_id() + logger.info(f"Extracted entity_id: {entity_id}") + + if not entity_id: + error = OpenAIError.create(f"Missing entity_id. Request extra_body: {request.extra_body}") + return JSONResponse(status_code=400, content=error.model_dump()) + + # Get executor and validate entity exists + executor = await self._ensure_executor() + try: + entity_info = executor.get_entity_info(entity_id) + logger.info(f"Found entity: {entity_info.name} ({entity_info.type})") + except Exception: + error = OpenAIError.create(f"Entity not found: {entity_id}") + return JSONResponse(status_code=404, content=error.model_dump()) + + # Execute request + if request.stream: + return StreamingResponse( + self._stream_execution(executor, request), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*", + }, + ) + return await executor.execute_sync(request) + + except Exception as e: + logger.error(f"Error executing request: {e}") + error = OpenAIError.create(f"Execution failed: {e!s}") + return JSONResponse(status_code=500, content=error.model_dump()) + + @app.post("/v1/threads") + async def create_thread(request_data: dict[str, Any]) -> dict[str, Any]: + """Create a new thread for an agent.""" + try: + agent_id = request_data.get("agent_id") + if not agent_id: + raise HTTPException(status_code=400, detail="agent_id is required") + + executor = await self._ensure_executor() + thread_id = executor.create_thread(agent_id) + + return { + "id": thread_id, + "object": "thread", + "created_at": int(__import__("time").time()), + "metadata": {"agent_id": agent_id}, + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating thread: {e}") + raise HTTPException(status_code=500, detail=f"Failed to create thread: {e!s}") from e + + @app.get("/v1/threads") + async def list_threads(agent_id: str) -> dict[str, Any]: + """List threads for an agent.""" + try: + executor = await self._ensure_executor() + thread_ids = executor.list_threads_for_agent(agent_id) + + # Convert thread IDs to thread objects + threads = [] + for thread_id in thread_ids: + threads.append({"id": thread_id, "object": "thread", "agent_id": agent_id}) + + return {"object": "list", "data": threads} + except Exception as e: + logger.error(f"Error listing threads: {e}") + raise HTTPException(status_code=500, detail=f"Failed to list threads: {e!s}") from e + + @app.get("/v1/threads/{thread_id}") + async def get_thread(thread_id: str) -> dict[str, Any]: + """Get thread information.""" + try: + executor = await self._ensure_executor() + + # Check if thread exists + thread = executor.get_thread(thread_id) + if not thread: + raise HTTPException(status_code=404, detail="Thread not found") + + # Get the agent that owns this thread + agent_id = executor.get_agent_for_thread(thread_id) + + return {"id": thread_id, "object": "thread", "agent_id": agent_id} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting thread {thread_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get thread: {e!s}") from e + + @app.delete("/v1/threads/{thread_id}") + async def delete_thread(thread_id: str) -> dict[str, Any]: + """Delete a thread.""" + try: + executor = await self._ensure_executor() + success = executor.delete_thread(thread_id) + + if not success: + raise HTTPException(status_code=404, detail="Thread not found") + + return {"id": thread_id, "object": "thread.deleted", "deleted": True} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting thread {thread_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete thread: {e!s}") from e + + @app.get("/v1/threads/{thread_id}/messages") + async def get_thread_messages(thread_id: str) -> dict[str, Any]: + """Get messages from a thread.""" + try: + executor = await self._ensure_executor() + + # Check if thread exists + thread = executor.get_thread(thread_id) + if not thread: + raise HTTPException(status_code=404, detail="Thread not found") + + # Get messages from thread + messages = await executor.get_thread_messages(thread_id) + + return {"object": "list", "data": messages, "thread_id": thread_id} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting messages for thread {thread_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get thread messages: {e!s}") from e + + async def _stream_execution( + self, executor: AgentFrameworkExecutor, request: AgentFrameworkRequest + ) -> AsyncGenerator[str, None]: + """Stream execution directly through executor.""" + try: + # Direct call to executor - simple and clean + async for event in executor.execute_streaming(request): + yield f"data: {event.model_dump_json()}\n\n" + + # Send final done event + yield "data: [DONE]\n\n" + + except Exception as e: + logger.error(f"Error in streaming execution: {e}") + error_event = {"id": "error", "object": "error", "error": {"message": str(e), "type": "execution_error"}} + yield f"data: {error_event}\n\n" + + def _mount_ui(self, app: FastAPI) -> None: + """Mount the UI as static files.""" + from pathlib import Path + + ui_dir = Path(__file__).parent / "ui" + if ui_dir.exists() and ui_dir.is_dir() and self.ui_enabled: + app.mount("/", StaticFiles(directory=str(ui_dir), html=True), name="ui") + + def register_entities(self, entities: list[Any]) -> None: + """Register entities to be discovered when server starts. + + Args: + entities: List of entity objects to register + """ + if self._pending_entities is None: + self._pending_entities = [] + self._pending_entities.extend(entities) + + def get_app(self) -> FastAPI: + """Get the FastAPI application instance.""" + if self._app is None: + self._app = self.create_app() + return self._app diff --git a/python/packages/devui/agent_framework_devui/_session.py b/python/packages/devui/agent_framework_devui/_session.py new file mode 100644 index 0000000000..587207a924 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/_session.py @@ -0,0 +1,191 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Session management for agent execution tracking.""" + +import logging +import uuid +from datetime import datetime +from typing import Any + +logger = logging.getLogger(__name__) + +# Type aliases for better readability +SessionData = dict[str, Any] +RequestRecord = dict[str, Any] +SessionSummary = dict[str, Any] + + +class SessionManager: + """Manages execution sessions for tracking requests and context.""" + + def __init__(self) -> None: + """Initialize the session manager.""" + self.sessions: dict[str, SessionData] = {} + + def create_session(self, session_id: str | None = None) -> str: + """Create a new execution session. + + Args: + session_id: Optional session ID, if not provided a new one is generated + + Returns: + Session ID + """ + if not session_id: + session_id = str(uuid.uuid4()) + + self.sessions[session_id] = { + "id": session_id, + "created_at": datetime.now(), + "requests": [], + "context": {}, + "active": True, + } + + logger.debug(f"Created session: {session_id}") + return session_id + + def get_session(self, session_id: str) -> SessionData | None: + """Get session information. + + Args: + session_id: Session ID + + Returns: + Session data or None if not found + """ + return self.sessions.get(session_id) + + def close_session(self, session_id: str) -> None: + """Close and cleanup a session. + + Args: + session_id: Session ID to close + """ + if session_id in self.sessions: + self.sessions[session_id]["active"] = False + logger.debug(f"Closed session: {session_id}") + + def add_request_record( + self, session_id: str, entity_id: str, executor_name: str, request_input: Any, model: str + ) -> str: + """Add a request record to a session. + + Args: + session_id: Session ID + entity_id: ID of the entity being executed + executor_name: Name of the executor + request_input: Input for the request + model: Model name + + Returns: + Request ID + """ + session = self.get_session(session_id) + if not session: + return "" + + request_record: RequestRecord = { + "id": str(uuid.uuid4()), + "timestamp": datetime.now(), + "entity_id": entity_id, + "executor": executor_name, + "input": request_input, + "model": model, + "stream": True, + } + session["requests"].append(request_record) + return str(request_record["id"]) + + def update_request_record(self, session_id: str, request_id: str, updates: dict[str, Any]) -> None: + """Update a request record in a session. + + Args: + session_id: Session ID + request_id: Request ID to update + updates: Dictionary of updates to apply + """ + session = self.get_session(session_id) + if not session: + return + + for request in session["requests"]: + if request["id"] == request_id: + request.update(updates) + break + + def get_session_history(self, session_id: str) -> SessionSummary | None: + """Get session execution history. + + Args: + session_id: Session ID + + Returns: + Session history or None if not found + """ + session = self.get_session(session_id) + if not session: + return None + + return { + "session_id": session_id, + "created_at": session["created_at"].isoformat(), + "active": session["active"], + "request_count": len(session["requests"]), + "requests": [ + { + "id": req["id"], + "timestamp": req["timestamp"].isoformat(), + "entity_id": req["entity_id"], + "executor": req["executor"], + "model": req["model"], + "input_length": len(str(req["input"])) if req["input"] else 0, + "execution_time": req.get("execution_time"), + "status": req.get("status", "unknown"), + } + for req in session["requests"] + ], + } + + def get_active_sessions(self) -> list[SessionSummary]: + """Get list of active sessions. + + Returns: + List of active session summaries + """ + active_sessions = [] + + for session_id, session in self.sessions.items(): + if session["active"]: + active_sessions.append({ + "session_id": session_id, + "created_at": session["created_at"].isoformat(), + "request_count": len(session["requests"]), + "last_activity": ( + session["requests"][-1]["timestamp"].isoformat() + if session["requests"] + else session["created_at"].isoformat() + ), + }) + + return active_sessions + + def cleanup_old_sessions(self, max_age_hours: int = 24) -> None: + """Cleanup old sessions to prevent memory leaks. + + Args: + max_age_hours: Maximum age of sessions to keep in hours + """ + cutoff_time = datetime.now().timestamp() - (max_age_hours * 3600) + + sessions_to_remove = [] + for session_id, session in self.sessions.items(): + if session["created_at"].timestamp() < cutoff_time: + sessions_to_remove.append(session_id) + + for session_id in sessions_to_remove: + del self.sessions[session_id] + logger.debug(f"Cleaned up old session: {session_id}") + + if sessions_to_remove: + logger.info(f"Cleaned up {len(sessions_to_remove)} old sessions") diff --git a/python/packages/devui/agent_framework_devui/_tracing.py b/python/packages/devui/agent_framework_devui/_tracing.py new file mode 100644 index 0000000000..83abd2fcb7 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/_tracing.py @@ -0,0 +1,168 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Simplified tracing integration for Agent Framework Server.""" + +import logging +from collections.abc import Generator, Sequence +from contextlib import contextmanager +from datetime import datetime +from typing import Any + +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + +from .models import ResponseTraceEvent + +logger = logging.getLogger(__name__) + + +class SimpleTraceCollector(SpanExporter): + """Simple trace collector that captures spans for direct yielding.""" + + def __init__(self, session_id: str | None = None, entity_id: str | None = None) -> None: + """Initialize trace collector. + + Args: + session_id: Session identifier for context + entity_id: Entity identifier for context + """ + self.session_id = session_id + self.entity_id = entity_id + self.collected_events: list[ResponseTraceEvent] = [] + + def export(self, spans: Sequence[Any]) -> SpanExportResult: + """Collect spans as trace events. + + Args: + spans: Sequence of OpenTelemetry spans + + Returns: + SpanExportResult indicating success + """ + logger.debug(f"SimpleTraceCollector received {len(spans)} spans") + + try: + for span in spans: + trace_event = self._convert_span_to_trace_event(span) + if trace_event: + self.collected_events.append(trace_event) + logger.debug(f"Collected trace event: {span.name}") + + return SpanExportResult.SUCCESS + + except Exception as e: + logger.error(f"Error collecting trace spans: {e}") + return SpanExportResult.FAILURE + + def force_flush(self, timeout_millis: int = 30000) -> bool: + """Force flush spans (no-op for simple collection).""" + return True + + def get_pending_events(self) -> list[ResponseTraceEvent]: + """Get and clear pending trace events. + + Returns: + List of collected trace events, clearing the internal list + """ + events = self.collected_events.copy() + self.collected_events.clear() + return events + + def _convert_span_to_trace_event(self, span: Any) -> ResponseTraceEvent | None: + """Convert OpenTelemetry span to ResponseTraceEvent. + + Args: + span: OpenTelemetry span + + Returns: + ResponseTraceEvent or None if conversion fails + """ + try: + start_time = span.start_time / 1_000_000_000 # Convert from nanoseconds + end_time = span.end_time / 1_000_000_000 if span.end_time else None + duration_ms = ((end_time - start_time) * 1000) if end_time else None + + # Build trace data + trace_data = { + "type": "trace_span", + "span_id": str(span.context.span_id), + "trace_id": str(span.context.trace_id), + "parent_span_id": str(span.parent.span_id) if span.parent else None, + "operation_name": span.name, + "start_time": start_time, + "end_time": end_time, + "duration_ms": duration_ms, + "attributes": dict(span.attributes) if span.attributes else {}, + "status": str(span.status.status_code) if hasattr(span, "status") else "OK", + "session_id": self.session_id, + "entity_id": self.entity_id, + } + + # Add events if available + if hasattr(span, "events") and span.events: + trace_data["events"] = [ + { + "name": event.name, + "timestamp": event.timestamp / 1_000_000_000, + "attributes": dict(event.attributes) if event.attributes else {}, + } + for event in span.events + ] + + # Add error information if span failed + if hasattr(span, "status") and span.status.status_code.name == "ERROR": + trace_data["error"] = span.status.description or "Unknown error" + + return ResponseTraceEvent(type="trace_event", data=trace_data, timestamp=datetime.now().isoformat()) + + except Exception as e: + logger.warning(f"Failed to convert span {getattr(span, 'name', 'unknown')}: {e}") + return None + + +@contextmanager +def capture_traces( + session_id: str | None = None, entity_id: str | None = None +) -> Generator[SimpleTraceCollector, None, None]: + """Context manager to capture traces during execution. + + Args: + session_id: Session identifier for context + entity_id: Entity identifier for context + + Yields: + SimpleTraceCollector instance to get trace events from + """ + collector = SimpleTraceCollector(session_id, entity_id) + + try: + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + + # Get current tracer provider and add our collector + provider = trace.get_tracer_provider() + processor = SimpleSpanProcessor(collector) + + # Check if this is a real TracerProvider (not the default NoOpTracerProvider) + if isinstance(provider, TracerProvider): + provider.add_span_processor(processor) + logger.debug(f"Added trace collector to TracerProvider for session: {session_id}, entity: {entity_id}") + + try: + yield collector + finally: + # Clean up - shutdown processor + try: + processor.shutdown() + except Exception as e: + logger.debug(f"Error shutting down processor: {e}") + else: + logger.warning(f"No real TracerProvider available, got: {type(provider)}") + yield collector + + except ImportError: + logger.debug("OpenTelemetry not available") + yield collector + except Exception as e: + logger.error(f"Error setting up trace capture: {e}") + yield collector diff --git a/python/packages/devui/agent_framework_devui/models/__init__.py b/python/packages/devui/agent_framework_devui/models/__init__.py new file mode 100644 index 0000000000..d4c2d0da24 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/models/__init__.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent Framework DevUI Models - OpenAI-compatible types and custom extensions.""" + +# Import discovery models +# Import all OpenAI types directly from the openai package +from openai.types.responses import ( + Response, + ResponseErrorEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseInputParam, + ResponseOutputMessage, + ResponseOutputText, + ResponseReasoningTextDeltaEvent, + ResponseStreamEvent, + ResponseTextDeltaEvent, + ResponseUsage, + ToolParam, +) +from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails +from openai.types.shared import Metadata, ResponsesModel + +from ._discovery_models import DiscoveryResponse, EntityInfo +from ._openai_custom import ( + AgentFrameworkRequest, + OpenAIError, + ResponseFunctionResultComplete, + ResponseFunctionResultDelta, + ResponseTraceEvent, + ResponseTraceEventComplete, + ResponseTraceEventDelta, + ResponseUsageEventComplete, + ResponseUsageEventDelta, + ResponseWorkflowEventComplete, + ResponseWorkflowEventDelta, +) + +# Type alias for compatibility +OpenAIResponse = Response + +# Export all types for easy importing +__all__ = [ + "AgentFrameworkRequest", + "DiscoveryResponse", + "EntityInfo", + "InputTokensDetails", + "Metadata", + "OpenAIError", + "OpenAIResponse", + "OutputTokensDetails", + "Response", + "ResponseErrorEvent", + "ResponseFunctionCallArgumentsDeltaEvent", + "ResponseFunctionResultComplete", + "ResponseFunctionResultDelta", + "ResponseInputParam", + "ResponseOutputMessage", + "ResponseOutputText", + "ResponseReasoningTextDeltaEvent", + "ResponseStreamEvent", + "ResponseTextDeltaEvent", + "ResponseTraceEvent", + "ResponseTraceEventComplete", + "ResponseTraceEventDelta", + "ResponseUsage", + "ResponseUsageEventComplete", + "ResponseUsageEventDelta", + "ResponseWorkflowEventComplete", + "ResponseWorkflowEventDelta", + "ResponsesModel", + "ToolParam", +] diff --git a/python/packages/devui/agent_framework_devui/models/_discovery_models.py b/python/packages/devui/agent_framework_devui/models/_discovery_models.py new file mode 100644 index 0000000000..307ec7e37f --- /dev/null +++ b/python/packages/devui/agent_framework_devui/models/_discovery_models.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Discovery API models for entity information.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class EntityInfo(BaseModel): + """Entity information for discovery and detailed views.""" + + # Always present (core entity data) + id: str + type: str # "agent", "workflow" + name: str + description: str | None = None + framework: str + tools: list[str | dict[str, Any]] | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + # Workflow-specific fields (populated only for detailed info requests) + executors: list[str] | None = None + workflow_dump: dict[str, Any] | None = None + input_schema: dict[str, Any] | None = None + input_type_name: str | None = None + start_executor_id: str | None = None + + +class DiscoveryResponse(BaseModel): + """Response model for entity discovery.""" + + entities: list[EntityInfo] = Field(default_factory=list) diff --git a/python/packages/devui/agent_framework_devui/models/_openai_custom.py b/python/packages/devui/agent_framework_devui/models/_openai_custom.py new file mode 100644 index 0000000000..bc2b006c64 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/models/_openai_custom.py @@ -0,0 +1,202 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Custom OpenAI-compatible event types for Agent Framework extensions. + +These are custom event types that extend beyond the standard OpenAI Responses API +to support Agent Framework specific features like workflows, traces, and function results. +""" + +from typing import Any, Literal + +from pydantic import BaseModel + +# Custom Agent Framework OpenAI event types for structured data + + +class ResponseWorkflowEventDelta(BaseModel): + """Structured workflow event with completion tracking.""" + + type: Literal["response.workflow_event.delta"] = "response.workflow_event.delta" + delta: dict[str, Any] + executor_id: str | None = None + is_complete: bool = False # Track if this is the final part + item_id: str + output_index: int = 0 + sequence_number: int + + +class ResponseWorkflowEventComplete(BaseModel): + """Complete workflow event data.""" + + type: Literal["response.workflow_event.complete"] = "response.workflow_event.complete" + data: dict[str, Any] # Complete event data, not delta + executor_id: str | None = None + item_id: str + output_index: int = 0 + sequence_number: int + + +class ResponseFunctionResultDelta(BaseModel): + """Structured function result with completion tracking.""" + + type: Literal["response.function_result.delta"] = "response.function_result.delta" + delta: dict[str, Any] + call_id: str + is_complete: bool = False + item_id: str + output_index: int = 0 + sequence_number: int + + +class ResponseFunctionResultComplete(BaseModel): + """Complete function result data.""" + + type: Literal["response.function_result.complete"] = "response.function_result.complete" + data: dict[str, Any] # Complete function result data, not delta + call_id: str + item_id: str + output_index: int = 0 + sequence_number: int + + +class ResponseTraceEventDelta(BaseModel): + """Structured trace event with completion tracking.""" + + type: Literal["response.trace.delta"] = "response.trace.delta" + delta: dict[str, Any] + span_id: str | None = None + is_complete: bool = False + item_id: str + output_index: int = 0 + sequence_number: int + + +class ResponseTraceEventComplete(BaseModel): + """Complete trace event data.""" + + type: Literal["response.trace.complete"] = "response.trace.complete" + data: dict[str, Any] # Complete trace data, not delta + span_id: str | None = None + item_id: str + output_index: int = 0 + sequence_number: int + + +class ResponseUsageEventDelta(BaseModel): + """Structured usage event with completion tracking.""" + + type: Literal["response.usage.delta"] = "response.usage.delta" + delta: dict[str, Any] + is_complete: bool = False + item_id: str + output_index: int = 0 + sequence_number: int + + +class ResponseUsageEventComplete(BaseModel): + """Complete usage event data.""" + + type: Literal["response.usage.complete"] = "response.usage.complete" + data: dict[str, Any] # Complete usage data, not delta + item_id: str + output_index: int = 0 + sequence_number: int + + +# Agent Framework extension fields +class AgentFrameworkExtraBody(BaseModel): + """Agent Framework specific routing fields for OpenAI requests.""" + + entity_id: str + thread_id: str | None = None + input_data: dict[str, Any] | None = None + + class Config: + extra = "allow" # Allow additional fields + + +# Agent Framework Request Model - Extending real OpenAI types +class AgentFrameworkRequest(BaseModel): + """OpenAI ResponseCreateParams with Agent Framework extensions. + + This properly extends the real OpenAI API request format while adding + our custom routing fields in extra_body. + """ + + # All OpenAI fields from ResponseCreateParams + model: str + input: str | list[Any] # ResponseInputParam + stream: bool | None = False + + # Common OpenAI optional fields + instructions: str | None = None + metadata: dict[str, Any] | None = None + temperature: float | None = None + max_output_tokens: int | None = None + tools: list[dict[str, Any]] | None = None + + # Agent Framework extension - strongly typed + extra_body: AgentFrameworkExtraBody | None = None + + class Config: + # Allow extra fields from OpenAI spec + extra = "allow" + + entity_id: str | None = None # Allow entity_id as top-level field + + def get_entity_id(self) -> str | None: + """Get entity_id from either top-level field or extra_body.""" + # Priority 1: Top-level entity_id field + if self.entity_id: + return self.entity_id + + # Priority 2: entity_id in extra_body + if self.extra_body and hasattr(self.extra_body, "entity_id"): + return self.extra_body.entity_id + + return None + + def to_openai_params(self) -> dict[str, Any]: + """Convert to dict for OpenAI client compatibility.""" + data = self.model_dump(exclude={"extra_body", "entity_id"}, exclude_none=True) + if self.extra_body: + # Don't merge extra_body into main params to keep them separate + data["extra_body"] = self.extra_body + return data + + +# Error handling +class ResponseTraceEvent(BaseModel): + """Trace event for execution tracing.""" + + type: Literal["trace_event"] = "trace_event" + data: dict[str, Any] + timestamp: str + + +class OpenAIError(BaseModel): + """OpenAI standard error response model.""" + + error: dict[str, Any] + + @classmethod + def create(cls, message: str, type: str = "invalid_request_error", code: str | None = None) -> "OpenAIError": + """Create a standard OpenAI error response.""" + error_data = {"message": message, "type": type, "code": code} + return cls(error=error_data) + + +# Export all custom types +__all__ = [ + "AgentFrameworkRequest", + "OpenAIError", + "ResponseFunctionResultComplete", + "ResponseFunctionResultDelta", + "ResponseTraceEvent", + "ResponseTraceEventComplete", + "ResponseTraceEventDelta", + "ResponseUsageEventComplete", + "ResponseUsageEventDelta", + "ResponseWorkflowEventComplete", + "ResponseWorkflowEventDelta", +] diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index-BESRiUNX.js b/python/packages/devui/agent_framework_devui/ui/assets/index-BESRiUNX.js new file mode 100644 index 0000000000..b2cdcf0dc4 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/ui/assets/index-BESRiUNX.js @@ -0,0 +1,383 @@ +function t_(e,r){for(var o=0;os[l]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))s(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const u of c.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&s(u)}).observe(document,{childList:!0,subtree:!0});function o(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function s(l){if(l.ep)return;l.ep=!0;const c=o(l);fetch(l.href,c)}})();function hm(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var eh={exports:{}},ai={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Gx;function n_(){if(Gx)return ai;Gx=1;var e=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function o(s,l,c){var u=null;if(c!==void 0&&(u=""+c),l.key!==void 0&&(u=""+l.key),"key"in l){c={};for(var f in l)f!=="key"&&(c[f]=l[f])}else c=l;return l=c.ref,{$$typeof:e,type:s,key:u,ref:l!==void 0?l:null,props:c}}return ai.Fragment=r,ai.jsx=o,ai.jsxs=o,ai}var qx;function r_(){return qx||(qx=1,eh.exports=n_()),eh.exports}var d=r_(),th={exports:{}},Me={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Xx;function o_(){if(Xx)return Me;Xx=1;var e=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),o=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),l=Symbol.for("react.profiler"),c=Symbol.for("react.consumer"),u=Symbol.for("react.context"),f=Symbol.for("react.forward_ref"),m=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),g=Symbol.for("react.lazy"),v=Symbol.iterator;function y(M){return M===null||typeof M!="object"?null:(M=v&&M[v]||M["@@iterator"],typeof M=="function"?M:null)}var b={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},E=Object.assign,S={};function C(M,I,X){this.props=M,this.context=I,this.refs=S,this.updater=X||b}C.prototype.isReactComponent={},C.prototype.setState=function(M,I){if(typeof M!="object"&&typeof M!="function"&&M!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,M,I,"setState")},C.prototype.forceUpdate=function(M){this.updater.enqueueForceUpdate(this,M,"forceUpdate")};function j(){}j.prototype=C.prototype;function R(M,I,X){this.props=M,this.context=I,this.refs=S,this.updater=X||b}var N=R.prototype=new j;N.constructor=R,E(N,C.prototype),N.isPureReactComponent=!0;var A=Array.isArray,D={H:null,A:null,T:null,S:null,V:null},z=Object.prototype.hasOwnProperty;function B(M,I,X,F,ee,ue){return X=ue.ref,{$$typeof:e,type:M,key:I,ref:X!==void 0?X:null,props:ue}}function U(M,I){return B(M.type,I,void 0,void 0,void 0,M.props)}function Y(M){return typeof M=="object"&&M!==null&&M.$$typeof===e}function oe(M){var I={"=":"=0",":":"=2"};return"$"+M.replace(/[=:]/g,function(X){return I[X]})}var J=/\/+/g;function q(M,I){return typeof M=="object"&&M!==null&&M.key!=null?oe(""+M.key):I.toString(36)}function re(){}function L(M){switch(M.status){case"fulfilled":return M.value;case"rejected":throw M.reason;default:switch(typeof M.status=="string"?M.then(re,re):(M.status="pending",M.then(function(I){M.status==="pending"&&(M.status="fulfilled",M.value=I)},function(I){M.status==="pending"&&(M.status="rejected",M.reason=I)})),M.status){case"fulfilled":return M.value;case"rejected":throw M.reason}}throw M}function $(M,I,X,F,ee){var ue=typeof M;(ue==="undefined"||ue==="boolean")&&(M=null);var se=!1;if(M===null)se=!0;else switch(ue){case"bigint":case"string":case"number":se=!0;break;case"object":switch(M.$$typeof){case e:case r:se=!0;break;case g:return se=M._init,$(se(M._payload),I,X,F,ee)}}if(se)return ee=ee(M),se=F===""?"."+q(M,0):F,A(ee)?(X="",se!=null&&(X=se.replace(J,"$&/")+"/"),$(ee,I,X,"",function(de){return de})):ee!=null&&(Y(ee)&&(ee=U(ee,X+(ee.key==null||M&&M.key===ee.key?"":(""+ee.key).replace(J,"$&/")+"/")+se)),I.push(ee)),1;se=0;var Q=F===""?".":F+":";if(A(M))for(var ae=0;ae>>1,M=T[P];if(0>>1;Pl(F,H))eel(ue,F)?(T[P]=ue,T[ee]=H,P=ee):(T[P]=F,T[X]=H,P=X);else if(eel(ue,H))T[P]=ue,T[ee]=H,P=ee;else break e}}return O}function l(T,O){var H=T.sortIndex-O.sortIndex;return H!==0?H:T.id-O.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var u=Date,f=u.now();e.unstable_now=function(){return u.now()-f}}var m=[],p=[],g=1,v=null,y=3,b=!1,E=!1,S=!1,C=!1,j=typeof setTimeout=="function"?setTimeout:null,R=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;function A(T){for(var O=o(p);O!==null;){if(O.callback===null)s(p);else if(O.startTime<=T)s(p),O.sortIndex=O.expirationTime,r(m,O);else break;O=o(p)}}function D(T){if(S=!1,A(T),!E)if(o(m)!==null)E=!0,z||(z=!0,q());else{var O=o(p);O!==null&&$(D,O.startTime-T)}}var z=!1,B=-1,U=5,Y=-1;function oe(){return C?!0:!(e.unstable_now()-YT&&oe());){var P=v.callback;if(typeof P=="function"){v.callback=null,y=v.priorityLevel;var M=P(v.expirationTime<=T);if(T=e.unstable_now(),typeof M=="function"){v.callback=M,A(T),O=!0;break t}v===o(m)&&s(m),A(T)}else s(m);v=o(m)}if(v!==null)O=!0;else{var I=o(p);I!==null&&$(D,I.startTime-T),O=!1}}break e}finally{v=null,y=H,b=!1}O=void 0}}finally{O?q():z=!1}}}var q;if(typeof N=="function")q=function(){N(J)};else if(typeof MessageChannel<"u"){var re=new MessageChannel,L=re.port2;re.port1.onmessage=J,q=function(){L.postMessage(null)}}else q=function(){j(J,0)};function $(T,O){B=j(function(){T(e.unstable_now())},O)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(T){T.callback=null},e.unstable_forceFrameRate=function(T){0>T||125P?(T.sortIndex=H,r(p,T),o(m)===null&&T===o(p)&&(S?(R(B),B=-1):S=!0,$(D,H-P))):(T.sortIndex=M,r(m,T),E||b||(E=!0,z||(z=!0,q()))),T},e.unstable_shouldYield=oe,e.unstable_wrapCallback=function(T){var O=y;return function(){var H=y;y=O;try{return T.apply(this,arguments)}finally{y=H}}}})(oh)),oh}var Kx;function s_(){return Kx||(Kx=1,rh.exports=a_()),rh.exports}var ah={exports:{}},Nt={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Qx;function i_(){if(Qx)return Nt;Qx=1;var e=Ri();function r(m){var p="https://react.dev/errors/"+m;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(r){console.error(r)}}return e(),ah.exports=i_(),ah.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Jx;function l_(){if(Jx)return si;Jx=1;var e=s_(),r=Ri(),o=Yv();function s(t){var n="https://react.dev/errors/"+t;if(1M||(t.current=P[M],P[M]=null,M--)}function F(t,n){M++,P[M]=t.current,t.current=n}var ee=I(null),ue=I(null),se=I(null),Q=I(null);function ae(t,n){switch(F(se,n),F(ue,t),F(ee,null),n.nodeType){case 9:case 11:t=(t=n.documentElement)&&(t=t.namespaceURI)?vx(t):0;break;default:if(t=n.tagName,n=n.namespaceURI)n=vx(n),t=bx(n,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}X(ee),F(ee,t)}function de(){X(ee),X(ue),X(se)}function he(t){t.memoizedState!==null&&F(Q,t);var n=ee.current,a=bx(n,t.type);n!==a&&(F(ue,t),F(ee,a))}function me(t){ue.current===t&&(X(ee),X(ue)),Q.current===t&&(X(Q),ei._currentValue=H)}var fe=Object.prototype.hasOwnProperty,ve=e.unstable_scheduleCallback,we=e.unstable_cancelCallback,Be=e.unstable_shouldYield,Je=e.unstable_requestPaint,Xe=e.unstable_now,hr=e.unstable_getCurrentPriorityLevel,Qr=e.unstable_ImmediatePriority,Wr=e.unstable_UserBlockingPriority,zn=e.unstable_NormalPriority,Jr=e.unstable_LowPriority,mr=e.unstable_IdlePriority,Ja=e.log,Ho=e.unstable_setDisableYieldValue,qt=null,lt=null;function rn(t){if(typeof Ja=="function"&&Ho(t),lt&&typeof lt.setStrictMode=="function")try{lt.setStrictMode(qt,t)}catch{}}var _t=Math.clz32?Math.clz32:Pu,es=Math.log,Uu=Math.LN2;function Pu(t){return t>>>=0,t===0?32:31-(es(t)/Uu|0)|0}var Bo=256,Io=4194304;function Ln(t){var n=t&42;if(n!==0)return n;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Uo(t,n,a){var i=t.pendingLanes;if(i===0)return 0;var h=0,x=t.suspendedLanes,_=t.pingedLanes;t=t.warmLanes;var k=i&134217727;return k!==0?(i=k&~x,i!==0?h=Ln(i):(_&=k,_!==0?h=Ln(_):a||(a=k&~t,a!==0&&(h=Ln(a))))):(k=i&~x,k!==0?h=Ln(k):_!==0?h=Ln(_):a||(a=i&~t,a!==0&&(h=Ln(a)))),h===0?0:n!==0&&n!==h&&(n&x)===0&&(x=h&-h,a=n&-n,x>=a||x===32&&(a&4194048)!==0)?n:h}function eo(t,n){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&n)===0}function Vu(t,n){switch(t){case 1:case 2:case 4:case 8:case 64:return n+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return n+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function qi(){var t=Bo;return Bo<<=1,(Bo&4194048)===0&&(Bo=256),t}function Xi(){var t=Io;return Io<<=1,(Io&62914560)===0&&(Io=4194304),t}function ts(t){for(var n=[],a=0;31>a;a++)n.push(t);return n}function to(t,n){t.pendingLanes|=n,n!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function $u(t,n,a,i,h,x){var _=t.pendingLanes;t.pendingLanes=a,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=a,t.entangledLanes&=a,t.errorRecoveryDisabledLanes&=a,t.shellSuspendCounter=0;var k=t.entanglements,V=t.expirationTimes,W=t.hiddenUpdates;for(a=_&~a;0)":-1h||V[i]!==W[h]){var ie=` +`+V[i].replace(" at new "," at ");return t.displayName&&ie.includes("")&&(ie=ie.replace("",t.displayName)),ie}while(1<=i&&0<=h);break}}}finally{cs=!1,Error.prepareStackTrace=a}return(a=t?t.displayName||t.name:"")?Un(a):""}function Fu(t){switch(t.tag){case 26:case 27:case 5:return Un(t.type);case 16:return Un("Lazy");case 13:return Un("Suspense");case 19:return Un("SuspenseList");case 0:case 15:return us(t.type,!1);case 11:return us(t.type.render,!1);case 1:return us(t.type,!0);case 31:return Un("Activity");default:return""}}function tl(t){try{var n="";do n+=Fu(t),t=t.return;while(t);return n}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}function Mt(t){switch(typeof t){case"bigint":case"boolean":case"number":case"string":case"undefined":return t;case"object":return t;default:return""}}function nl(t){var n=t.type;return(t=t.nodeName)&&t.toLowerCase()==="input"&&(n==="checkbox"||n==="radio")}function Ku(t){var n=nl(t)?"checked":"value",a=Object.getOwnPropertyDescriptor(t.constructor.prototype,n),i=""+t[n];if(!t.hasOwnProperty(n)&&typeof a<"u"&&typeof a.get=="function"&&typeof a.set=="function"){var h=a.get,x=a.set;return Object.defineProperty(t,n,{configurable:!0,get:function(){return h.call(this)},set:function(_){i=""+_,x.call(this,_)}}),Object.defineProperty(t,n,{enumerable:a.enumerable}),{getValue:function(){return i},setValue:function(_){i=""+_},stopTracking:function(){t._valueTracker=null,delete t[n]}}}}function $o(t){t._valueTracker||(t._valueTracker=Ku(t))}function ds(t){if(!t)return!1;var n=t._valueTracker;if(!n)return!0;var a=n.getValue(),i="";return t&&(i=nl(t)?t.checked?"true":"false":t.value),t=i,t!==a?(n.setValue(t),!0):!1}function Yo(t){if(t=t||(typeof document<"u"?document:void 0),typeof t>"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var Qu=/[\n"\\]/g;function At(t){return t.replace(Qu,function(n){return"\\"+n.charCodeAt(0).toString(16)+" "})}function ro(t,n,a,i,h,x,_,k){t.name="",_!=null&&typeof _!="function"&&typeof _!="symbol"&&typeof _!="boolean"?t.type=_:t.removeAttribute("type"),n!=null?_==="number"?(n===0&&t.value===""||t.value!=n)&&(t.value=""+Mt(n)):t.value!==""+Mt(n)&&(t.value=""+Mt(n)):_!=="submit"&&_!=="reset"||t.removeAttribute("value"),n!=null?fs(t,_,Mt(n)):a!=null?fs(t,_,Mt(a)):i!=null&&t.removeAttribute("value"),h==null&&x!=null&&(t.defaultChecked=!!x),h!=null&&(t.checked=h&&typeof h!="function"&&typeof h!="symbol"),k!=null&&typeof k!="function"&&typeof k!="symbol"&&typeof k!="boolean"?t.name=""+Mt(k):t.removeAttribute("name")}function rl(t,n,a,i,h,x,_,k){if(x!=null&&typeof x!="function"&&typeof x!="symbol"&&typeof x!="boolean"&&(t.type=x),n!=null||a!=null){if(!(x!=="submit"&&x!=="reset"||n!=null))return;a=a!=null?""+Mt(a):"",n=n!=null?""+Mt(n):a,k||n===t.value||(t.value=n),t.defaultValue=n}i=i??h,i=typeof i!="function"&&typeof i!="symbol"&&!!i,t.checked=k?t.checked:!!i,t.defaultChecked=!!i,_!=null&&typeof _!="function"&&typeof _!="symbol"&&typeof _!="boolean"&&(t.name=_)}function fs(t,n,a){n==="number"&&Yo(t.ownerDocument)===t||t.defaultValue===""+a||(t.defaultValue=""+a)}function Pn(t,n,a,i){if(t=t.options,n){n={};for(var h=0;h"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),nd=!1;if(Vn)try{var ms={};Object.defineProperty(ms,"passive",{get:function(){nd=!0}}),window.addEventListener("test",ms,ms),window.removeEventListener("test",ms,ms)}catch{nd=!1}var vr=null,rd=null,al=null;function Ep(){if(al)return al;var t,n=rd,a=n.length,i,h="value"in vr?vr.value:vr.textContent,x=h.length;for(t=0;t=xs),Ap=" ",Tp=!1;function Rp(t,n){switch(t){case"keyup":return _E.indexOf(n.keyCode)!==-1;case"keydown":return n.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function kp(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var Zo=!1;function jE(t,n){switch(t){case"compositionend":return kp(n);case"keypress":return n.which!==32?null:(Tp=!0,Ap);case"textInput":return t=n.data,t===Ap&&Tp?null:t;default:return null}}function ME(t,n){if(Zo)return t==="compositionend"||!ld&&Rp(t,n)?(t=Ep(),al=rd=vr=null,Zo=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(n.ctrlKey||n.altKey||n.metaKey)||n.ctrlKey&&n.altKey){if(n.char&&1=n)return{node:a,offset:n-t};t=i}e:{for(;a;){if(a.nextSibling){a=a.nextSibling;break e}a=a.parentNode}a=void 0}a=Up(a)}}function Vp(t,n){return t&&n?t===n?!0:t&&t.nodeType===3?!1:n&&n.nodeType===3?Vp(t,n.parentNode):"contains"in t?t.contains(n):t.compareDocumentPosition?!!(t.compareDocumentPosition(n)&16):!1:!1}function $p(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var n=Yo(t.document);n instanceof t.HTMLIFrameElement;){try{var a=typeof n.contentWindow.location.href=="string"}catch{a=!1}if(a)t=n.contentWindow;else break;n=Yo(t.document)}return n}function dd(t){var n=t&&t.nodeName&&t.nodeName.toLowerCase();return n&&(n==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||n==="textarea"||t.contentEditable==="true")}var LE=Vn&&"documentMode"in document&&11>=document.documentMode,Fo=null,fd=null,ws=null,hd=!1;function Yp(t,n,a){var i=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;hd||Fo==null||Fo!==Yo(i)||(i=Fo,"selectionStart"in i&&dd(i)?i={start:i.selectionStart,end:i.selectionEnd}:(i=(i.ownerDocument&&i.ownerDocument.defaultView||window).getSelection(),i={anchorNode:i.anchorNode,anchorOffset:i.anchorOffset,focusNode:i.focusNode,focusOffset:i.focusOffset}),ws&&bs(ws,i)||(ws=i,i=Fl(fd,"onSelect"),0>=_,h-=_,Yn=1<<32-_t(n)+h|a<x?x:8;var _=T.T,k={};T.T=k,Wd(t,!1,n,a);try{var V=h(),W=T.S;if(W!==null&&W(k,V),V!==null&&typeof V=="object"&&typeof V.then=="function"){var ie=GE(V,i);Ls(t,n,ie,Ut(t))}else Ls(t,n,i,Ut(t))}catch(ce){Ls(t,n,{then:function(){},status:"rejected",reason:ce},Ut())}finally{O.p=x,T.T=_}}function KE(){}function Kd(t,n,a,i){if(t.tag!==5)throw Error(s(476));var h=Gg(t).queue;Yg(t,h,n,H,a===null?KE:function(){return qg(t),a(i)})}function Gg(t){var n=t.memoizedState;if(n!==null)return n;n={memoizedState:H,baseState:H,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Zn,lastRenderedState:H},next:null};var a={};return n.next={memoizedState:a,baseState:a,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Zn,lastRenderedState:a},next:null},t.memoizedState=n,t=t.alternate,t!==null&&(t.memoizedState=n),n}function qg(t){var n=Gg(t).next.queue;Ls(t,n,{},Ut())}function Qd(){return Et(ei)}function Xg(){return st().memoizedState}function Zg(){return st().memoizedState}function QE(t){for(var n=t.return;n!==null;){switch(n.tag){case 24:case 3:var a=Ut();t=Sr(a);var i=Er(n,t,a);i!==null&&(Pt(i,n,a),Ts(i,n,a)),n={cache:jd()},t.payload=n;return}n=n.return}}function WE(t,n,a){var i=Ut();a={lane:i,revertLane:0,action:a,hasEagerState:!1,eagerState:null,next:null},Al(t)?Kg(n,a):(a=xd(t,n,a,i),a!==null&&(Pt(a,t,i),Qg(a,n,i)))}function Fg(t,n,a){var i=Ut();Ls(t,n,a,i)}function Ls(t,n,a,i){var h={lane:i,revertLane:0,action:a,hasEagerState:!1,eagerState:null,next:null};if(Al(t))Kg(n,h);else{var x=t.alternate;if(t.lanes===0&&(x===null||x.lanes===0)&&(x=n.lastRenderedReducer,x!==null))try{var _=n.lastRenderedState,k=x(_,a);if(h.hasEagerState=!0,h.eagerState=k,zt(k,_))return fl(t,n,h,0),Ze===null&&dl(),!1}catch{}finally{}if(a=xd(t,n,h,i),a!==null)return Pt(a,t,i),Qg(a,n,i),!0}return!1}function Wd(t,n,a,i){if(i={lane:2,revertLane:Rf(),action:i,hasEagerState:!1,eagerState:null,next:null},Al(t)){if(n)throw Error(s(479))}else n=xd(t,a,i,2),n!==null&&Pt(n,t,2)}function Al(t){var n=t.alternate;return t===Ae||n!==null&&n===Ae}function Kg(t,n){aa=El=!0;var a=t.pending;a===null?n.next=n:(n.next=a.next,a.next=n),t.pending=n}function Qg(t,n,a){if((a&4194048)!==0){var i=n.lanes;i&=t.pendingLanes,a|=i,n.lanes=a,ns(t,a)}}var Tl={readContext:Et,use:_l,useCallback:nt,useContext:nt,useEffect:nt,useImperativeHandle:nt,useLayoutEffect:nt,useInsertionEffect:nt,useMemo:nt,useReducer:nt,useRef:nt,useState:nt,useDebugValue:nt,useDeferredValue:nt,useTransition:nt,useSyncExternalStore:nt,useId:nt,useHostTransitionStatus:nt,useFormState:nt,useActionState:nt,useOptimistic:nt,useMemoCache:nt,useCacheRefresh:nt},Wg={readContext:Et,use:_l,useCallback:function(t,n){return Rt().memoizedState=[t,n===void 0?null:n],t},useContext:Et,useEffect:zg,useImperativeHandle:function(t,n,a){a=a!=null?a.concat([t]):null,Ml(4194308,4,Ig.bind(null,n,t),a)},useLayoutEffect:function(t,n){return Ml(4194308,4,t,n)},useInsertionEffect:function(t,n){Ml(4,2,t,n)},useMemo:function(t,n){var a=Rt();n=n===void 0?null:n;var i=t();if(go){rn(!0);try{t()}finally{rn(!1)}}return a.memoizedState=[i,n],i},useReducer:function(t,n,a){var i=Rt();if(a!==void 0){var h=a(n);if(go){rn(!0);try{a(n)}finally{rn(!1)}}}else h=n;return i.memoizedState=i.baseState=h,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:h},i.queue=t,t=t.dispatch=WE.bind(null,Ae,t),[i.memoizedState,t]},useRef:function(t){var n=Rt();return t={current:t},n.memoizedState=t},useState:function(t){t=qd(t);var n=t.queue,a=Fg.bind(null,Ae,n);return n.dispatch=a,[t.memoizedState,a]},useDebugValue:Zd,useDeferredValue:function(t,n){var a=Rt();return Fd(a,t,n)},useTransition:function(){var t=qd(!1);return t=Yg.bind(null,Ae,t.queue,!0,!1),Rt().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,n,a){var i=Ae,h=Rt();if(Ie){if(a===void 0)throw Error(s(407));a=a()}else{if(a=n(),Ze===null)throw Error(s(349));(Oe&124)!==0||vg(i,n,a)}h.memoizedState=a;var x={value:a,getSnapshot:n};return h.queue=x,zg(wg.bind(null,i,x,t),[t]),i.flags|=2048,ia(9,jl(),bg.bind(null,i,x,a,n),null),a},useId:function(){var t=Rt(),n=Ze.identifierPrefix;if(Ie){var a=Gn,i=Yn;a=(i&~(1<<32-_t(i)-1)).toString(32)+a,n="«"+n+"R"+a,a=Nl++,0Ne?(gt=be,be=null):gt=be.sibling;var Le=te(Z,be,K[Ne],le);if(Le===null){be===null&&(be=gt);break}t&&be&&Le.alternate===null&&n(Z,be),G=x(Le,G,Ne),Re===null?pe=Le:Re.sibling=Le,Re=Le,be=gt}if(Ne===K.length)return a(Z,be),Ie&&co(Z,Ne),pe;if(be===null){for(;NeNe?(gt=be,be=null):gt=be.sibling;var Ur=te(Z,be,Le.value,le);if(Ur===null){be===null&&(be=gt);break}t&&be&&Ur.alternate===null&&n(Z,be),G=x(Ur,G,Ne),Re===null?pe=Ur:Re.sibling=Ur,Re=Ur,be=gt}if(Le.done)return a(Z,be),Ie&&co(Z,Ne),pe;if(be===null){for(;!Le.done;Ne++,Le=K.next())Le=ce(Z,Le.value,le),Le!==null&&(G=x(Le,G,Ne),Re===null?pe=Le:Re.sibling=Le,Re=Le);return Ie&&co(Z,Ne),pe}for(be=i(be);!Le.done;Ne++,Le=K.next())Le=ne(be,Z,Ne,Le.value,le),Le!==null&&(t&&Le.alternate!==null&&be.delete(Le.key===null?Ne:Le.key),G=x(Le,G,Ne),Re===null?pe=Le:Re.sibling=Le,Re=Le);return t&&be.forEach(function(e_){return n(Z,e_)}),Ie&&co(Z,Ne),pe}function Ge(Z,G,K,le){if(typeof K=="object"&&K!==null&&K.type===E&&K.key===null&&(K=K.props.children),typeof K=="object"&&K!==null){switch(K.$$typeof){case y:e:{for(var pe=K.key;G!==null;){if(G.key===pe){if(pe=K.type,pe===E){if(G.tag===7){a(Z,G.sibling),le=h(G,K.props.children),le.return=Z,Z=le;break e}}else if(G.elementType===pe||typeof pe=="object"&&pe!==null&&pe.$$typeof===U&&e0(pe)===G.type){a(Z,G.sibling),le=h(G,K.props),Bs(le,K),le.return=Z,Z=le;break e}a(Z,G);break}else n(Z,G);G=G.sibling}K.type===E?(le=io(K.props.children,Z.mode,le,K.key),le.return=Z,Z=le):(le=ml(K.type,K.key,K.props,null,Z.mode,le),Bs(le,K),le.return=Z,Z=le)}return _(Z);case b:e:{for(pe=K.key;G!==null;){if(G.key===pe)if(G.tag===4&&G.stateNode.containerInfo===K.containerInfo&&G.stateNode.implementation===K.implementation){a(Z,G.sibling),le=h(G,K.children||[]),le.return=Z,Z=le;break e}else{a(Z,G);break}else n(Z,G);G=G.sibling}le=bd(K,Z.mode,le),le.return=Z,Z=le}return _(Z);case U:return pe=K._init,K=pe(K._payload),Ge(Z,G,K,le)}if($(K))return _e(Z,G,K,le);if(q(K)){if(pe=q(K),typeof pe!="function")throw Error(s(150));return K=pe.call(K),Ee(Z,G,K,le)}if(typeof K.then=="function")return Ge(Z,G,Rl(K),le);if(K.$$typeof===N)return Ge(Z,G,yl(Z,K),le);kl(Z,K)}return typeof K=="string"&&K!==""||typeof K=="number"||typeof K=="bigint"?(K=""+K,G!==null&&G.tag===6?(a(Z,G.sibling),le=h(G,K),le.return=Z,Z=le):(a(Z,G),le=vd(K,Z.mode,le),le.return=Z,Z=le),_(Z)):a(Z,G)}return function(Z,G,K,le){try{Hs=0;var pe=Ge(Z,G,K,le);return la=null,pe}catch(be){if(be===Ms||be===bl)throw be;var Re=Lt(29,be,null,Z.mode);return Re.lanes=le,Re.return=Z,Re}finally{}}}var ca=t0(!0),n0=t0(!1),Qt=I(null),vn=null;function _r(t){var n=t.alternate;F(ut,ut.current&1),F(Qt,t),vn===null&&(n===null||oa.current!==null||n.memoizedState!==null)&&(vn=t)}function r0(t){if(t.tag===22){if(F(ut,ut.current),F(Qt,t),vn===null){var n=t.alternate;n!==null&&n.memoizedState!==null&&(vn=t)}}else Cr()}function Cr(){F(ut,ut.current),F(Qt,Qt.current)}function Fn(t){X(Qt),vn===t&&(vn=null),X(ut)}var ut=I(0);function Dl(t){for(var n=t;n!==null;){if(n.tag===13){var a=n.memoizedState;if(a!==null&&(a=a.dehydrated,a===null||a.data==="$?"||$f(a)))return n}else if(n.tag===19&&n.memoizedProps.revealOrder!==void 0){if((n.flags&128)!==0)return n}else if(n.child!==null){n.child.return=n,n=n.child;continue}if(n===t)break;for(;n.sibling===null;){if(n.return===null||n.return===t)return null;n=n.return}n.sibling.return=n.return,n=n.sibling}return null}function Jd(t,n,a,i){n=t.memoizedState,a=a(i,n),a=a==null?n:g({},n,a),t.memoizedState=a,t.lanes===0&&(t.updateQueue.baseState=a)}var ef={enqueueSetState:function(t,n,a){t=t._reactInternals;var i=Ut(),h=Sr(i);h.payload=n,a!=null&&(h.callback=a),n=Er(t,h,i),n!==null&&(Pt(n,t,i),Ts(n,t,i))},enqueueReplaceState:function(t,n,a){t=t._reactInternals;var i=Ut(),h=Sr(i);h.tag=1,h.payload=n,a!=null&&(h.callback=a),n=Er(t,h,i),n!==null&&(Pt(n,t,i),Ts(n,t,i))},enqueueForceUpdate:function(t,n){t=t._reactInternals;var a=Ut(),i=Sr(a);i.tag=2,n!=null&&(i.callback=n),n=Er(t,i,a),n!==null&&(Pt(n,t,a),Ts(n,t,a))}};function o0(t,n,a,i,h,x,_){return t=t.stateNode,typeof t.shouldComponentUpdate=="function"?t.shouldComponentUpdate(i,x,_):n.prototype&&n.prototype.isPureReactComponent?!bs(a,i)||!bs(h,x):!0}function a0(t,n,a,i){t=n.state,typeof n.componentWillReceiveProps=="function"&&n.componentWillReceiveProps(a,i),typeof n.UNSAFE_componentWillReceiveProps=="function"&&n.UNSAFE_componentWillReceiveProps(a,i),n.state!==t&&ef.enqueueReplaceState(n,n.state,null)}function xo(t,n){var a=n;if("ref"in n){a={};for(var i in n)i!=="ref"&&(a[i]=n[i])}if(t=t.defaultProps){a===n&&(a=g({},a));for(var h in t)a[h]===void 0&&(a[h]=t[h])}return a}var Ol=typeof reportError=="function"?reportError:function(t){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var n=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof t=="object"&&t!==null&&typeof t.message=="string"?String(t.message):String(t),error:t});if(!window.dispatchEvent(n))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",t);return}console.error(t)};function s0(t){Ol(t)}function i0(t){console.error(t)}function l0(t){Ol(t)}function zl(t,n){try{var a=t.onUncaughtError;a(n.value,{componentStack:n.stack})}catch(i){setTimeout(function(){throw i})}}function c0(t,n,a){try{var i=t.onCaughtError;i(a.value,{componentStack:a.stack,errorBoundary:n.tag===1?n.stateNode:null})}catch(h){setTimeout(function(){throw h})}}function tf(t,n,a){return a=Sr(a),a.tag=3,a.payload={element:null},a.callback=function(){zl(t,n)},a}function u0(t){return t=Sr(t),t.tag=3,t}function d0(t,n,a,i){var h=a.type.getDerivedStateFromError;if(typeof h=="function"){var x=i.value;t.payload=function(){return h(x)},t.callback=function(){c0(n,a,i)}}var _=a.stateNode;_!==null&&typeof _.componentDidCatch=="function"&&(t.callback=function(){c0(n,a,i),typeof h!="function"&&(kr===null?kr=new Set([this]):kr.add(this));var k=i.stack;this.componentDidCatch(i.value,{componentStack:k!==null?k:""})})}function eN(t,n,a,i,h){if(a.flags|=32768,i!==null&&typeof i=="object"&&typeof i.then=="function"){if(n=a.alternate,n!==null&&_s(n,a,h,!0),a=Qt.current,a!==null){switch(a.tag){case 13:return vn===null?Cf():a.alternate===null&&tt===0&&(tt=3),a.flags&=-257,a.flags|=65536,a.lanes=h,i===Td?a.flags|=16384:(n=a.updateQueue,n===null?a.updateQueue=new Set([i]):n.add(i),Mf(t,i,h)),!1;case 22:return a.flags|=65536,i===Td?a.flags|=16384:(n=a.updateQueue,n===null?(n={transitions:null,markerInstances:null,retryQueue:new Set([i])},a.updateQueue=n):(a=n.retryQueue,a===null?n.retryQueue=new Set([i]):a.add(i)),Mf(t,i,h)),!1}throw Error(s(435,a.tag))}return Mf(t,i,h),Cf(),!1}if(Ie)return n=Qt.current,n!==null?((n.flags&65536)===0&&(n.flags|=256),n.flags|=65536,n.lanes=h,i!==Ed&&(t=Error(s(422),{cause:i}),Ns(Xt(t,a)))):(i!==Ed&&(n=Error(s(423),{cause:i}),Ns(Xt(n,a))),t=t.current.alternate,t.flags|=65536,h&=-h,t.lanes|=h,i=Xt(i,a),h=tf(t.stateNode,i,h),Dd(t,h),tt!==4&&(tt=2)),!1;var x=Error(s(520),{cause:i});if(x=Xt(x,a),Gs===null?Gs=[x]:Gs.push(x),tt!==4&&(tt=2),n===null)return!0;i=Xt(i,a),a=n;do{switch(a.tag){case 3:return a.flags|=65536,t=h&-h,a.lanes|=t,t=tf(a.stateNode,i,t),Dd(a,t),!1;case 1:if(n=a.type,x=a.stateNode,(a.flags&128)===0&&(typeof n.getDerivedStateFromError=="function"||x!==null&&typeof x.componentDidCatch=="function"&&(kr===null||!kr.has(x))))return a.flags|=65536,h&=-h,a.lanes|=h,h=u0(h),d0(h,t,a,i),Dd(a,h),!1}a=a.return}while(a!==null);return!1}var f0=Error(s(461)),mt=!1;function xt(t,n,a,i){n.child=t===null?n0(n,null,a,i):ca(n,t.child,a,i)}function h0(t,n,a,i,h){a=a.render;var x=n.ref;if("ref"in i){var _={};for(var k in i)k!=="ref"&&(_[k]=i[k])}else _=i;return mo(n),i=Bd(t,n,a,_,x,h),k=Id(),t!==null&&!mt?(Ud(t,n,h),Kn(t,n,h)):(Ie&&k&&wd(n),n.flags|=1,xt(t,n,i,h),n.child)}function m0(t,n,a,i,h){if(t===null){var x=a.type;return typeof x=="function"&&!yd(x)&&x.defaultProps===void 0&&a.compare===null?(n.tag=15,n.type=x,p0(t,n,x,i,h)):(t=ml(a.type,null,i,n,n.mode,h),t.ref=n.ref,t.return=n,n.child=t)}if(x=t.child,!uf(t,h)){var _=x.memoizedProps;if(a=a.compare,a=a!==null?a:bs,a(_,i)&&t.ref===n.ref)return Kn(t,n,h)}return n.flags|=1,t=$n(x,i),t.ref=n.ref,t.return=n,n.child=t}function p0(t,n,a,i,h){if(t!==null){var x=t.memoizedProps;if(bs(x,i)&&t.ref===n.ref)if(mt=!1,n.pendingProps=i=x,uf(t,h))(t.flags&131072)!==0&&(mt=!0);else return n.lanes=t.lanes,Kn(t,n,h)}return nf(t,n,a,i,h)}function g0(t,n,a){var i=n.pendingProps,h=i.children,x=t!==null?t.memoizedState:null;if(i.mode==="hidden"){if((n.flags&128)!==0){if(i=x!==null?x.baseLanes|a:a,t!==null){for(h=n.child=t.child,x=0;h!==null;)x=x|h.lanes|h.childLanes,h=h.sibling;n.childLanes=x&~i}else n.childLanes=0,n.child=null;return x0(t,n,i,a)}if((a&536870912)!==0)n.memoizedState={baseLanes:0,cachePool:null},t!==null&&vl(n,x!==null?x.cachePool:null),x!==null?pg(n,x):zd(),r0(n);else return n.lanes=n.childLanes=536870912,x0(t,n,x!==null?x.baseLanes|a:a,a)}else x!==null?(vl(n,x.cachePool),pg(n,x),Cr(),n.memoizedState=null):(t!==null&&vl(n,null),zd(),Cr());return xt(t,n,h,a),n.child}function x0(t,n,a,i){var h=Ad();return h=h===null?null:{parent:ct._currentValue,pool:h},n.memoizedState={baseLanes:a,cachePool:h},t!==null&&vl(n,null),zd(),r0(n),t!==null&&_s(t,n,i,!0),null}function Ll(t,n){var a=n.ref;if(a===null)t!==null&&t.ref!==null&&(n.flags|=4194816);else{if(typeof a!="function"&&typeof a!="object")throw Error(s(284));(t===null||t.ref!==a)&&(n.flags|=4194816)}}function nf(t,n,a,i,h){return mo(n),a=Bd(t,n,a,i,void 0,h),i=Id(),t!==null&&!mt?(Ud(t,n,h),Kn(t,n,h)):(Ie&&i&&wd(n),n.flags|=1,xt(t,n,a,h),n.child)}function y0(t,n,a,i,h,x){return mo(n),n.updateQueue=null,a=xg(n,i,a,h),gg(t),i=Id(),t!==null&&!mt?(Ud(t,n,x),Kn(t,n,x)):(Ie&&i&&wd(n),n.flags|=1,xt(t,n,a,x),n.child)}function v0(t,n,a,i,h){if(mo(n),n.stateNode===null){var x=Jo,_=a.contextType;typeof _=="object"&&_!==null&&(x=Et(_)),x=new a(i,x),n.memoizedState=x.state!==null&&x.state!==void 0?x.state:null,x.updater=ef,n.stateNode=x,x._reactInternals=n,x=n.stateNode,x.props=i,x.state=n.memoizedState,x.refs={},Rd(n),_=a.contextType,x.context=typeof _=="object"&&_!==null?Et(_):Jo,x.state=n.memoizedState,_=a.getDerivedStateFromProps,typeof _=="function"&&(Jd(n,a,_,i),x.state=n.memoizedState),typeof a.getDerivedStateFromProps=="function"||typeof x.getSnapshotBeforeUpdate=="function"||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(_=x.state,typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount(),_!==x.state&&ef.enqueueReplaceState(x,x.state,null),ks(n,i,x,h),Rs(),x.state=n.memoizedState),typeof x.componentDidMount=="function"&&(n.flags|=4194308),i=!0}else if(t===null){x=n.stateNode;var k=n.memoizedProps,V=xo(a,k);x.props=V;var W=x.context,ie=a.contextType;_=Jo,typeof ie=="object"&&ie!==null&&(_=Et(ie));var ce=a.getDerivedStateFromProps;ie=typeof ce=="function"||typeof x.getSnapshotBeforeUpdate=="function",k=n.pendingProps!==k,ie||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(k||W!==_)&&a0(n,x,i,_),wr=!1;var te=n.memoizedState;x.state=te,ks(n,i,x,h),Rs(),W=n.memoizedState,k||te!==W||wr?(typeof ce=="function"&&(Jd(n,a,ce,i),W=n.memoizedState),(V=wr||o0(n,a,V,i,te,W,_))?(ie||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount()),typeof x.componentDidMount=="function"&&(n.flags|=4194308)):(typeof x.componentDidMount=="function"&&(n.flags|=4194308),n.memoizedProps=i,n.memoizedState=W),x.props=i,x.state=W,x.context=_,i=V):(typeof x.componentDidMount=="function"&&(n.flags|=4194308),i=!1)}else{x=n.stateNode,kd(t,n),_=n.memoizedProps,ie=xo(a,_),x.props=ie,ce=n.pendingProps,te=x.context,W=a.contextType,V=Jo,typeof W=="object"&&W!==null&&(V=Et(W)),k=a.getDerivedStateFromProps,(W=typeof k=="function"||typeof x.getSnapshotBeforeUpdate=="function")||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(_!==ce||te!==V)&&a0(n,x,i,V),wr=!1,te=n.memoizedState,x.state=te,ks(n,i,x,h),Rs();var ne=n.memoizedState;_!==ce||te!==ne||wr||t!==null&&t.dependencies!==null&&xl(t.dependencies)?(typeof k=="function"&&(Jd(n,a,k,i),ne=n.memoizedState),(ie=wr||o0(n,a,ie,i,te,ne,V)||t!==null&&t.dependencies!==null&&xl(t.dependencies))?(W||typeof x.UNSAFE_componentWillUpdate!="function"&&typeof x.componentWillUpdate!="function"||(typeof x.componentWillUpdate=="function"&&x.componentWillUpdate(i,ne,V),typeof x.UNSAFE_componentWillUpdate=="function"&&x.UNSAFE_componentWillUpdate(i,ne,V)),typeof x.componentDidUpdate=="function"&&(n.flags|=4),typeof x.getSnapshotBeforeUpdate=="function"&&(n.flags|=1024)):(typeof x.componentDidUpdate!="function"||_===t.memoizedProps&&te===t.memoizedState||(n.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||_===t.memoizedProps&&te===t.memoizedState||(n.flags|=1024),n.memoizedProps=i,n.memoizedState=ne),x.props=i,x.state=ne,x.context=V,i=ie):(typeof x.componentDidUpdate!="function"||_===t.memoizedProps&&te===t.memoizedState||(n.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||_===t.memoizedProps&&te===t.memoizedState||(n.flags|=1024),i=!1)}return x=i,Ll(t,n),i=(n.flags&128)!==0,x||i?(x=n.stateNode,a=i&&typeof a.getDerivedStateFromError!="function"?null:x.render(),n.flags|=1,t!==null&&i?(n.child=ca(n,t.child,null,h),n.child=ca(n,null,a,h)):xt(t,n,a,h),n.memoizedState=x.state,t=n.child):t=Kn(t,n,h),t}function b0(t,n,a,i){return Es(),n.flags|=256,xt(t,n,a,i),n.child}var rf={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function of(t){return{baseLanes:t,cachePool:ig()}}function af(t,n,a){return t=t!==null?t.childLanes&~a:0,n&&(t|=Wt),t}function w0(t,n,a){var i=n.pendingProps,h=!1,x=(n.flags&128)!==0,_;if((_=x)||(_=t!==null&&t.memoizedState===null?!1:(ut.current&2)!==0),_&&(h=!0,n.flags&=-129),_=(n.flags&32)!==0,n.flags&=-33,t===null){if(Ie){if(h?_r(n):Cr(),Ie){var k=et,V;if(V=k){e:{for(V=k,k=yn;V.nodeType!==8;){if(!k){k=null;break e}if(V=ln(V.nextSibling),V===null){k=null;break e}}k=V}k!==null?(n.memoizedState={dehydrated:k,treeContext:lo!==null?{id:Yn,overflow:Gn}:null,retryLane:536870912,hydrationErrors:null},V=Lt(18,null,null,0),V.stateNode=k,V.return=n,n.child=V,Ct=n,et=null,V=!0):V=!1}V||fo(n)}if(k=n.memoizedState,k!==null&&(k=k.dehydrated,k!==null))return $f(k)?n.lanes=32:n.lanes=536870912,null;Fn(n)}return k=i.children,i=i.fallback,h?(Cr(),h=n.mode,k=Hl({mode:"hidden",children:k},h),i=io(i,h,a,null),k.return=n,i.return=n,k.sibling=i,n.child=k,h=n.child,h.memoizedState=of(a),h.childLanes=af(t,_,a),n.memoizedState=rf,i):(_r(n),sf(n,k))}if(V=t.memoizedState,V!==null&&(k=V.dehydrated,k!==null)){if(x)n.flags&256?(_r(n),n.flags&=-257,n=lf(t,n,a)):n.memoizedState!==null?(Cr(),n.child=t.child,n.flags|=128,n=null):(Cr(),h=i.fallback,k=n.mode,i=Hl({mode:"visible",children:i.children},k),h=io(h,k,a,null),h.flags|=2,i.return=n,h.return=n,i.sibling=h,n.child=i,ca(n,t.child,null,a),i=n.child,i.memoizedState=of(a),i.childLanes=af(t,_,a),n.memoizedState=rf,n=h);else if(_r(n),$f(k)){if(_=k.nextSibling&&k.nextSibling.dataset,_)var W=_.dgst;_=W,i=Error(s(419)),i.stack="",i.digest=_,Ns({value:i,source:null,stack:null}),n=lf(t,n,a)}else if(mt||_s(t,n,a,!1),_=(a&t.childLanes)!==0,mt||_){if(_=Ze,_!==null&&(i=a&-a,i=(i&42)!==0?1:rs(i),i=(i&(_.suspendedLanes|a))!==0?0:i,i!==0&&i!==V.retryLane))throw V.retryLane=i,Wo(t,i),Pt(_,t,i),f0;k.data==="$?"||Cf(),n=lf(t,n,a)}else k.data==="$?"?(n.flags|=192,n.child=t.child,n=null):(t=V.treeContext,et=ln(k.nextSibling),Ct=n,Ie=!0,uo=null,yn=!1,t!==null&&(Ft[Kt++]=Yn,Ft[Kt++]=Gn,Ft[Kt++]=lo,Yn=t.id,Gn=t.overflow,lo=n),n=sf(n,i.children),n.flags|=4096);return n}return h?(Cr(),h=i.fallback,k=n.mode,V=t.child,W=V.sibling,i=$n(V,{mode:"hidden",children:i.children}),i.subtreeFlags=V.subtreeFlags&65011712,W!==null?h=$n(W,h):(h=io(h,k,a,null),h.flags|=2),h.return=n,i.return=n,i.sibling=h,n.child=i,i=h,h=n.child,k=t.child.memoizedState,k===null?k=of(a):(V=k.cachePool,V!==null?(W=ct._currentValue,V=V.parent!==W?{parent:W,pool:W}:V):V=ig(),k={baseLanes:k.baseLanes|a,cachePool:V}),h.memoizedState=k,h.childLanes=af(t,_,a),n.memoizedState=rf,i):(_r(n),a=t.child,t=a.sibling,a=$n(a,{mode:"visible",children:i.children}),a.return=n,a.sibling=null,t!==null&&(_=n.deletions,_===null?(n.deletions=[t],n.flags|=16):_.push(t)),n.child=a,n.memoizedState=null,a)}function sf(t,n){return n=Hl({mode:"visible",children:n},t.mode),n.return=t,t.child=n}function Hl(t,n){return t=Lt(22,t,null,n),t.lanes=0,t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},t}function lf(t,n,a){return ca(n,t.child,null,a),t=sf(n,n.pendingProps.children),t.flags|=2,n.memoizedState=null,t}function S0(t,n,a){t.lanes|=n;var i=t.alternate;i!==null&&(i.lanes|=n),_d(t.return,n,a)}function cf(t,n,a,i,h){var x=t.memoizedState;x===null?t.memoizedState={isBackwards:n,rendering:null,renderingStartTime:0,last:i,tail:a,tailMode:h}:(x.isBackwards=n,x.rendering=null,x.renderingStartTime=0,x.last=i,x.tail=a,x.tailMode=h)}function E0(t,n,a){var i=n.pendingProps,h=i.revealOrder,x=i.tail;if(xt(t,n,i.children,a),i=ut.current,(i&2)!==0)i=i&1|2,n.flags|=128;else{if(t!==null&&(t.flags&128)!==0)e:for(t=n.child;t!==null;){if(t.tag===13)t.memoizedState!==null&&S0(t,a,n);else if(t.tag===19)S0(t,a,n);else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===n)break e;for(;t.sibling===null;){if(t.return===null||t.return===n)break e;t=t.return}t.sibling.return=t.return,t=t.sibling}i&=1}switch(F(ut,i),h){case"forwards":for(a=n.child,h=null;a!==null;)t=a.alternate,t!==null&&Dl(t)===null&&(h=a),a=a.sibling;a=h,a===null?(h=n.child,n.child=null):(h=a.sibling,a.sibling=null),cf(n,!1,h,a,x);break;case"backwards":for(a=null,h=n.child,n.child=null;h!==null;){if(t=h.alternate,t!==null&&Dl(t)===null){n.child=h;break}t=h.sibling,h.sibling=a,a=h,h=t}cf(n,!0,a,null,x);break;case"together":cf(n,!1,null,null,void 0);break;default:n.memoizedState=null}return n.child}function Kn(t,n,a){if(t!==null&&(n.dependencies=t.dependencies),Rr|=n.lanes,(a&n.childLanes)===0)if(t!==null){if(_s(t,n,a,!1),(a&n.childLanes)===0)return null}else return null;if(t!==null&&n.child!==t.child)throw Error(s(153));if(n.child!==null){for(t=n.child,a=$n(t,t.pendingProps),n.child=a,a.return=n;t.sibling!==null;)t=t.sibling,a=a.sibling=$n(t,t.pendingProps),a.return=n;a.sibling=null}return n.child}function uf(t,n){return(t.lanes&n)!==0?!0:(t=t.dependencies,!!(t!==null&&xl(t)))}function tN(t,n,a){switch(n.tag){case 3:ae(n,n.stateNode.containerInfo),br(n,ct,t.memoizedState.cache),Es();break;case 27:case 5:he(n);break;case 4:ae(n,n.stateNode.containerInfo);break;case 10:br(n,n.type,n.memoizedProps.value);break;case 13:var i=n.memoizedState;if(i!==null)return i.dehydrated!==null?(_r(n),n.flags|=128,null):(a&n.child.childLanes)!==0?w0(t,n,a):(_r(n),t=Kn(t,n,a),t!==null?t.sibling:null);_r(n);break;case 19:var h=(t.flags&128)!==0;if(i=(a&n.childLanes)!==0,i||(_s(t,n,a,!1),i=(a&n.childLanes)!==0),h){if(i)return E0(t,n,a);n.flags|=128}if(h=n.memoizedState,h!==null&&(h.rendering=null,h.tail=null,h.lastEffect=null),F(ut,ut.current),i)break;return null;case 22:case 23:return n.lanes=0,g0(t,n,a);case 24:br(n,ct,t.memoizedState.cache)}return Kn(t,n,a)}function N0(t,n,a){if(t!==null)if(t.memoizedProps!==n.pendingProps)mt=!0;else{if(!uf(t,a)&&(n.flags&128)===0)return mt=!1,tN(t,n,a);mt=(t.flags&131072)!==0}else mt=!1,Ie&&(n.flags&1048576)!==0&&eg(n,gl,n.index);switch(n.lanes=0,n.tag){case 16:e:{t=n.pendingProps;var i=n.elementType,h=i._init;if(i=h(i._payload),n.type=i,typeof i=="function")yd(i)?(t=xo(i,t),n.tag=1,n=v0(null,n,i,t,a)):(n.tag=0,n=nf(null,n,i,t,a));else{if(i!=null){if(h=i.$$typeof,h===A){n.tag=11,n=h0(null,n,i,t,a);break e}else if(h===B){n.tag=14,n=m0(null,n,i,t,a);break e}}throw n=L(i)||i,Error(s(306,n,""))}}return n;case 0:return nf(t,n,n.type,n.pendingProps,a);case 1:return i=n.type,h=xo(i,n.pendingProps),v0(t,n,i,h,a);case 3:e:{if(ae(n,n.stateNode.containerInfo),t===null)throw Error(s(387));i=n.pendingProps;var x=n.memoizedState;h=x.element,kd(t,n),ks(n,i,null,a);var _=n.memoizedState;if(i=_.cache,br(n,ct,i),i!==x.cache&&Cd(n,[ct],a,!0),Rs(),i=_.element,x.isDehydrated)if(x={element:i,isDehydrated:!1,cache:_.cache},n.updateQueue.baseState=x,n.memoizedState=x,n.flags&256){n=b0(t,n,i,a);break e}else if(i!==h){h=Xt(Error(s(424)),n),Ns(h),n=b0(t,n,i,a);break e}else{switch(t=n.stateNode.containerInfo,t.nodeType){case 9:t=t.body;break;default:t=t.nodeName==="HTML"?t.ownerDocument.body:t}for(et=ln(t.firstChild),Ct=n,Ie=!0,uo=null,yn=!0,a=n0(n,null,i,a),n.child=a;a;)a.flags=a.flags&-3|4096,a=a.sibling}else{if(Es(),i===h){n=Kn(t,n,a);break e}xt(t,n,i,a)}n=n.child}return n;case 26:return Ll(t,n),t===null?(a=Mx(n.type,null,n.pendingProps,null))?n.memoizedState=a:Ie||(a=n.type,t=n.pendingProps,i=Ql(se.current).createElement(a),i[ht]=n,i[St]=t,vt(i,a,t),ot(i),n.stateNode=i):n.memoizedState=Mx(n.type,t.memoizedProps,n.pendingProps,t.memoizedState),null;case 27:return he(n),t===null&&Ie&&(i=n.stateNode=_x(n.type,n.pendingProps,se.current),Ct=n,yn=!0,h=et,zr(n.type)?(Yf=h,et=ln(i.firstChild)):et=h),xt(t,n,n.pendingProps.children,a),Ll(t,n),t===null&&(n.flags|=4194304),n.child;case 5:return t===null&&Ie&&((h=i=et)&&(i=AN(i,n.type,n.pendingProps,yn),i!==null?(n.stateNode=i,Ct=n,et=ln(i.firstChild),yn=!1,h=!0):h=!1),h||fo(n)),he(n),h=n.type,x=n.pendingProps,_=t!==null?t.memoizedProps:null,i=x.children,Uf(h,x)?i=null:_!==null&&Uf(h,_)&&(n.flags|=32),n.memoizedState!==null&&(h=Bd(t,n,XE,null,null,a),ei._currentValue=h),Ll(t,n),xt(t,n,i,a),n.child;case 6:return t===null&&Ie&&((t=a=et)&&(a=TN(a,n.pendingProps,yn),a!==null?(n.stateNode=a,Ct=n,et=null,t=!0):t=!1),t||fo(n)),null;case 13:return w0(t,n,a);case 4:return ae(n,n.stateNode.containerInfo),i=n.pendingProps,t===null?n.child=ca(n,null,i,a):xt(t,n,i,a),n.child;case 11:return h0(t,n,n.type,n.pendingProps,a);case 7:return xt(t,n,n.pendingProps,a),n.child;case 8:return xt(t,n,n.pendingProps.children,a),n.child;case 12:return xt(t,n,n.pendingProps.children,a),n.child;case 10:return i=n.pendingProps,br(n,n.type,i.value),xt(t,n,i.children,a),n.child;case 9:return h=n.type._context,i=n.pendingProps.children,mo(n),h=Et(h),i=i(h),n.flags|=1,xt(t,n,i,a),n.child;case 14:return m0(t,n,n.type,n.pendingProps,a);case 15:return p0(t,n,n.type,n.pendingProps,a);case 19:return E0(t,n,a);case 31:return i=n.pendingProps,a=n.mode,i={mode:i.mode,children:i.children},t===null?(a=Hl(i,a),a.ref=n.ref,n.child=a,a.return=n,n=a):(a=$n(t.child,i),a.ref=n.ref,n.child=a,a.return=n,n=a),n;case 22:return g0(t,n,a);case 24:return mo(n),i=Et(ct),t===null?(h=Ad(),h===null&&(h=Ze,x=jd(),h.pooledCache=x,x.refCount++,x!==null&&(h.pooledCacheLanes|=a),h=x),n.memoizedState={parent:i,cache:h},Rd(n),br(n,ct,h)):((t.lanes&a)!==0&&(kd(t,n),ks(n,null,null,a),Rs()),h=t.memoizedState,x=n.memoizedState,h.parent!==i?(h={parent:i,cache:i},n.memoizedState=h,n.lanes===0&&(n.memoizedState=n.updateQueue.baseState=h),br(n,ct,i)):(i=x.cache,br(n,ct,i),i!==h.cache&&Cd(n,[ct],a,!0))),xt(t,n,n.pendingProps.children,a),n.child;case 29:throw n.pendingProps}throw Error(s(156,n.tag))}function Qn(t){t.flags|=4}function _0(t,n){if(n.type!=="stylesheet"||(n.state.loading&4)!==0)t.flags&=-16777217;else if(t.flags|=16777216,!Dx(n)){if(n=Qt.current,n!==null&&((Oe&4194048)===Oe?vn!==null:(Oe&62914560)!==Oe&&(Oe&536870912)===0||n!==vn))throw As=Td,lg;t.flags|=8192}}function Bl(t,n){n!==null&&(t.flags|=4),t.flags&16384&&(n=t.tag!==22?Xi():536870912,t.lanes|=n,ha|=n)}function Is(t,n){if(!Ie)switch(t.tailMode){case"hidden":n=t.tail;for(var a=null;n!==null;)n.alternate!==null&&(a=n),n=n.sibling;a===null?t.tail=null:a.sibling=null;break;case"collapsed":a=t.tail;for(var i=null;a!==null;)a.alternate!==null&&(i=a),a=a.sibling;i===null?n||t.tail===null?t.tail=null:t.tail.sibling=null:i.sibling=null}}function Qe(t){var n=t.alternate!==null&&t.alternate.child===t.child,a=0,i=0;if(n)for(var h=t.child;h!==null;)a|=h.lanes|h.childLanes,i|=h.subtreeFlags&65011712,i|=h.flags&65011712,h.return=t,h=h.sibling;else for(h=t.child;h!==null;)a|=h.lanes|h.childLanes,i|=h.subtreeFlags,i|=h.flags,h.return=t,h=h.sibling;return t.subtreeFlags|=i,t.childLanes=a,n}function nN(t,n,a){var i=n.pendingProps;switch(Sd(n),n.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Qe(n),null;case 1:return Qe(n),null;case 3:return a=n.stateNode,i=null,t!==null&&(i=t.memoizedState.cache),n.memoizedState.cache!==i&&(n.flags|=2048),Xn(ct),de(),a.pendingContext&&(a.context=a.pendingContext,a.pendingContext=null),(t===null||t.child===null)&&(Ss(n)?Qn(n):t===null||t.memoizedState.isDehydrated&&(n.flags&256)===0||(n.flags|=1024,rg())),Qe(n),null;case 26:return a=n.memoizedState,t===null?(Qn(n),a!==null?(Qe(n),_0(n,a)):(Qe(n),n.flags&=-16777217)):a?a!==t.memoizedState?(Qn(n),Qe(n),_0(n,a)):(Qe(n),n.flags&=-16777217):(t.memoizedProps!==i&&Qn(n),Qe(n),n.flags&=-16777217),null;case 27:me(n),a=se.current;var h=n.type;if(t!==null&&n.stateNode!=null)t.memoizedProps!==i&&Qn(n);else{if(!i){if(n.stateNode===null)throw Error(s(166));return Qe(n),null}t=ee.current,Ss(n)?tg(n):(t=_x(h,i,a),n.stateNode=t,Qn(n))}return Qe(n),null;case 5:if(me(n),a=n.type,t!==null&&n.stateNode!=null)t.memoizedProps!==i&&Qn(n);else{if(!i){if(n.stateNode===null)throw Error(s(166));return Qe(n),null}if(t=ee.current,Ss(n))tg(n);else{switch(h=Ql(se.current),t){case 1:t=h.createElementNS("http://www.w3.org/2000/svg",a);break;case 2:t=h.createElementNS("http://www.w3.org/1998/Math/MathML",a);break;default:switch(a){case"svg":t=h.createElementNS("http://www.w3.org/2000/svg",a);break;case"math":t=h.createElementNS("http://www.w3.org/1998/Math/MathML",a);break;case"script":t=h.createElement("div"),t.innerHTML=" + + + +
+ + diff --git a/python/packages/devui/agent_framework_devui/ui/vite.svg b/python/packages/devui/agent_framework_devui/ui/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/ui/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/python/packages/devui/dev.md b/python/packages/devui/dev.md new file mode 100644 index 0000000000..2ff2f430dc --- /dev/null +++ b/python/packages/devui/dev.md @@ -0,0 +1,89 @@ +# Testing DevUI - Quick Setup Guide + +Hi everyone! Here are the step-by-step instructions to test the new DevUI feature: + +## 1. Get the Code + +```bash +git pull +git checkout victordibia/devui +``` + +## 2. Setup Environment + +Navigate to the Python directory and install dependencies: + +```bash +cd python +uv sync --dev +source .venv/bin/activate +``` + +## 3. Configure Environment Variables + +Create a `.env` file in the `python/` directory with your API credentials: + +```bash +# Copy the example file +cp .env.example .env +``` + +Then edit `.env` and add your API keys: + +```bash +# For OpenAI (minimum required) +OPENAI_API_KEY="your-api-key-here" +OPENAI_CHAT_MODEL_ID="gpt-4o-mini" + +# Or for Azure OpenAI +AZURE_OPENAI_ENDPOINT="your-endpoint" +AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="your-deployment-name" +``` + +## 4. Test DevUI + +**Option A: In-Memory Mode (Recommended for quick testing)** + +```bash +cd packages/devui/samples +python in_memory_mode.py +``` + +This runs a simple example with predefined agents and opens your browser automatically at http://localhost:8090 + +**Option B: Directory-Based Discovery** + +```bash +cd packages/devui/samples +devui +``` + +This launches the UI with all example agents/workflows at http://localhost:8080 + +## 5. What You'll See + +- A web interface for testing agents interactively +- Multiple example agents (weather assistant, general assistant, etc.) +- OpenAI-compatible API endpoints for programmatic access + +## 6. API Testing (Optional) + +You can also test via API calls: + +```bash +curl -X POST http://localhost:8080/v1/responses \ + -H "Content-Type: application/json" \ + -d '{ + "model": "agent-framework", + "input": "What is the weather in Seattle?", + "extra_body": {"entity_id": "weather_agent"} + }' +``` + +## Troubleshooting + +- **Missing API key**: Make sure your `.env` file is in the `python/` directory with valid credentials +- **Import errors**: Run `uv sync --dev` again to ensure all dependencies are installed +- **Port conflicts**: DevUI uses ports 8080 and 8090 by default - close other services using these ports + +Let me know if you run into any issues! diff --git a/python/packages/devui/docs/devuiscreen.png b/python/packages/devui/docs/devuiscreen.png new file mode 100644 index 0000000000..e3dc267610 Binary files /dev/null and b/python/packages/devui/docs/devuiscreen.png differ diff --git a/python/packages/devui/frontend/.gitignore b/python/packages/devui/frontend/.gitignore new file mode 100644 index 0000000000..8b23663a88 --- /dev/null +++ b/python/packages/devui/frontend/.gitignore @@ -0,0 +1,22 @@ +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +.env.* + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/python/packages/devui/frontend/README.md b/python/packages/devui/frontend/README.md new file mode 100644 index 0000000000..7959ce4269 --- /dev/null +++ b/python/packages/devui/frontend/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/python/packages/devui/frontend/components.json b/python/packages/devui/frontend/components.json new file mode 100644 index 0000000000..73afbdbccb --- /dev/null +++ b/python/packages/devui/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/python/packages/devui/frontend/eslint.config.js b/python/packages/devui/frontend/eslint.config.js new file mode 100644 index 0000000000..d94e7deb72 --- /dev/null +++ b/python/packages/devui/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { globalIgnores } from 'eslint/config' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/python/packages/devui/frontend/index.html b/python/packages/devui/frontend/index.html new file mode 100644 index 0000000000..c326c28d0f --- /dev/null +++ b/python/packages/devui/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Agent Framework Dev UI + + +
+ + + diff --git a/python/packages/devui/frontend/package.json b/python/packages/devui/frontend/package.json new file mode 100644 index 0000000000..553a38e3ab --- /dev/null +++ b/python/packages/devui/frontend/package.json @@ -0,0 +1,46 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", + "@tailwindcss/vite": "^4.1.12", + "@xyflow/react": "^12.8.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.540.0", + "next-themes": "^0.4.6", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.12" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/node": "^24.3.0", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "tw-animate-css": "^1.3.7", + "typescript": "~5.8.3", + "typescript-eslint": "^8.39.1", + "vite": "^7.1.2" + } +} diff --git a/python/packages/devui/frontend/public/vite.svg b/python/packages/devui/frontend/public/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/python/packages/devui/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/python/packages/devui/frontend/src/App.css b/python/packages/devui/frontend/src/App.css new file mode 100644 index 0000000000..b9d355df2a --- /dev/null +++ b/python/packages/devui/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/python/packages/devui/frontend/src/App.tsx b/python/packages/devui/frontend/src/App.tsx new file mode 100644 index 0000000000..c2e57918de --- /dev/null +++ b/python/packages/devui/frontend/src/App.tsx @@ -0,0 +1,312 @@ +/** + * DevUI App - Minimal orchestrator for agent/workflow interactions + * Features: Entity selection, layout management, debug coordination + */ + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { AppHeader } from "@/components/shared/app-header"; +import { DebugPanel } from "@/components/shared/debug-panel"; +import { AboutModal } from "@/components/shared/about-modal"; +import { AgentView } from "@/components/agent/agent-view"; +import { WorkflowView } from "@/components/workflow/workflow-view"; +import { LoadingState } from "@/components/ui/loading-state"; +import { apiClient } from "@/services/api"; +import { ChevronLeft } from "lucide-react"; +import type { + AgentInfo, + WorkflowInfo, + AppState, + ExtendedResponseStreamEvent, +} from "@/types"; + +export default function App() { + const [appState, setAppState] = useState({ + agents: [], + workflows: [], + isLoading: true, + }); + + const [debugEvents, setDebugEvents] = useState( + [] + ); + const [debugPanelOpen, setDebugPanelOpen] = useState(true); + const [debugPanelWidth, setDebugPanelWidth] = useState(() => { + // Initialize from localStorage or default to 320 + const savedWidth = localStorage.getItem("debugPanelWidth"); + return savedWidth ? parseInt(savedWidth, 10) : 320; + }); + const [isResizing, setIsResizing] = useState(false); + const [showAboutModal, setShowAboutModal] = useState(false); + + // Initialize app - load agents and workflows + useEffect(() => { + const loadData = async () => { + try { + // Load agents and workflows in parallel + const [agents, workflows] = await Promise.all([ + apiClient.getAgents(), + apiClient.getWorkflows(), + ]); + + setAppState((prev) => ({ + ...prev, + agents, + workflows, + selectedAgent: + agents.length > 0 + ? agents[0] + : workflows.length > 0 + ? workflows[0] + : undefined, + isLoading: false, + })); + } catch (error) { + console.error("Failed to load agents/workflows:", error); + setAppState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : "Failed to load data", + isLoading: false, + })); + } + }; + + loadData(); + }, []); + + // Save debug panel width to localStorage + useEffect(() => { + localStorage.setItem("debugPanelWidth", debugPanelWidth.toString()); + }, [debugPanelWidth]); + + // Handle resize drag + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + + const startX = e.clientX; + const startWidth = debugPanelWidth; + + const handleMouseMove = (e: MouseEvent) => { + const deltaX = startX - e.clientX; // Subtract because we're dragging from right + const newWidth = Math.max( + 200, + Math.min(window.innerWidth * 0.5, startWidth + deltaX) + ); + setDebugPanelWidth(newWidth); + }; + + const handleMouseUp = () => { + setIsResizing(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }, + [debugPanelWidth] + ); + + // Handle double-click to collapse + const handleDoubleClick = useCallback(() => { + setDebugPanelOpen(false); + }, []); + + // Handle entity selection + const handleEntitySelect = useCallback((item: AgentInfo | WorkflowInfo) => { + setAppState((prev) => ({ + ...prev, + selectedAgent: item, + currentThread: undefined, + })); + + // Clear debug events when switching entities + setDebugEvents([]); + }, []); + + // Handle debug events from active view + const handleDebugEvent = useCallback((event: ExtendedResponseStreamEvent | 'clear') => { + if (event === 'clear') { + setDebugEvents([]); + } else { + setDebugEvents((prev) => [...prev, event]); + } + }, []); + + // Show loading state while initializing + if (appState.isLoading) { + return ( +
+ {/* Top Bar - Skeleton */} +
+
+
+
+
+
+
+ + {/* Loading Content */} + +
+ ); + } + + // Show error state if loading failed + if (appState.error) { + return ( +
+ {}} + isLoading={false} + /> + + {/* Error Content */} +
+
+
+ Failed to load entities +
+

{appState.error}

+ +
+
+
+ ); + } + + // Show empty state if no agents or workflows are available + if ( + !appState.isLoading && + appState.agents.length === 0 && + appState.workflows.length === 0 + ) { + return ( +
+ {}} + isLoading={false} + /> + + {/* Empty State Content */} +
+
+
No entities configured
+

+ No agents or workflows were found in your configuration. Please + check your setup and ensure entities are properly configured. +

+ +
+
+
+ ); + } + + return ( +
+ setShowAboutModal(true)} + /> + + {/* Main Content - Split Panel */} +
+ {/* Left Panel - Main View */} +
+ {appState.selectedAgent ? ( + appState.selectedAgent.type === "agent" ? ( + + ) : ( + + ) + ) : ( +
+ Select an agent or workflow to get started. +
+ )} +
+ + {/* Resize Handle */} + {debugPanelOpen && ( +
+
+
+
+
+ )} + + {/* Button to reopen when closed */} + {!debugPanelOpen && ( +
+ +
+ )} + + {/* Right Panel - Debug */} + {debugPanelOpen && ( +
+ +
+ )} +
+ + {/* About Modal */} + +
+ ); +} diff --git a/python/packages/devui/frontend/src/assets/react.svg b/python/packages/devui/frontend/src/assets/react.svg new file mode 100644 index 0000000000..6c87de9bb3 --- /dev/null +++ b/python/packages/devui/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/agent/agent-view.tsx b/python/packages/devui/frontend/src/components/agent/agent-view.tsx new file mode 100644 index 0000000000..069599080f --- /dev/null +++ b/python/packages/devui/frontend/src/components/agent/agent-view.tsx @@ -0,0 +1,799 @@ +/** + * AgentView - Complete agent interaction interface + * Features: Chat interface, message streaming, thread management + */ + +import { useState, useCallback, useRef, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { FileUpload } from "@/components/ui/file-upload"; +import { + AttachmentGallery, + type AttachmentItem, +} from "@/components/ui/attachment-gallery"; +import { MessageRenderer } from "@/components/message_renderer"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Send, User, Bot, Plus, AlertCircle } from "lucide-react"; +import { apiClient } from "@/services/api"; +import type { + AgentInfo, + ChatMessage, + RunAgentRequest, + ThreadInfo, + ExtendedResponseStreamEvent, +} from "@/types"; + +interface ChatState { + messages: ChatMessage[]; + isStreaming: boolean; +} + +type DebugEventHandler = (event: ExtendedResponseStreamEvent | 'clear') => void; + +interface AgentViewProps { + selectedAgent: AgentInfo; + onDebugEvent: DebugEventHandler; +} + +interface MessageBubbleProps { + message: ChatMessage; +} + +function MessageBubble({ message }: MessageBubbleProps) { + const isUser = message.role === "user"; + const isError = message.error; + const Icon = isUser ? User : isError ? AlertCircle : Bot; + + return ( +
+
+ +
+ +
+
+ {isError && ( +
+ + + Unable to process request + +
+ )} +
+ +
+
+ +
+ {new Date(message.timestamp).toLocaleTimeString()} +
+
+
+ ); +} + +function TypingIndicator() { + return ( +
+
+ +
+
+
+
+
+
+
+
+
+ ); +} + +export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { + const [chatState, setChatState] = useState({ + messages: [], + isStreaming: false, + }); + const [currentThread, setCurrentThread] = useState( + undefined + ); + const [availableThreads, setAvailableThreads] = useState([]); + const [inputValue, setInputValue] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [attachments, setAttachments] = useState([]); + const [loadingThreads, setLoadingThreads] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const [dragCounter, setDragCounter] = useState(0); + + const scrollAreaRef = useRef(null); + const messagesEndRef = useRef(null); + const accumulatedText = useRef(""); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [chatState.messages, chatState.isStreaming]); + + // Load threads when agent changes + useEffect(() => { + const loadThreads = async () => { + if (!selectedAgent) return; + + setLoadingThreads(true); + try { + const threads = await apiClient.getThreads(selectedAgent.id); + setAvailableThreads(threads); + + // Auto-select the most recent thread if available + if (threads.length > 0) { + const mostRecentThread = threads[0]; // Assuming threads are sorted by creation date (newest first) + setCurrentThread(mostRecentThread); + + // Load messages for the selected thread + try { + const threadMessages = await apiClient.getThreadMessages(mostRecentThread.id); + setChatState({ + messages: threadMessages, + isStreaming: false, + }); + } catch (error) { + console.error("Failed to load thread messages:", error); + setChatState({ + messages: [], + isStreaming: false, + }); + } + } + } catch (error) { + console.error("Failed to load threads:", error); + setAvailableThreads([]); + } finally { + setLoadingThreads(false); + } + }; + + // Clear chat when agent changes + setChatState({ + messages: [], + isStreaming: false, + }); + setCurrentThread(undefined); + accumulatedText.current = ""; + + loadThreads(); + }, [selectedAgent]); + + // Handle file uploads + const handleFilesSelected = async (files: File[]) => { + const newAttachments: AttachmentItem[] = []; + + for (const file of files) { + const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const type = getFileType(file); + + let preview: string | undefined; + if (type === "image") { + preview = await readFileAsDataURL(file); + } + + newAttachments.push({ + id, + file, + preview, + type, + }); + } + + setAttachments((prev) => [...prev, ...newAttachments]); + }; + + const handleRemoveAttachment = (id: string) => { + setAttachments((prev) => prev.filter((att) => att.id !== id)); + }; + + // Drag and drop handlers + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragCounter((prev) => prev + 1); + if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { + setIsDragOver(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + const newCounter = dragCounter - 1; + setDragCounter(newCounter); + if (newCounter === 0) { + setIsDragOver(false); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + setDragCounter(0); + + if (isSubmitting || chatState.isStreaming) return; + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + await handleFilesSelected(files); + } + }; + + // Helper functions + const getFileType = (file: File): AttachmentItem["type"] => { + if (file.type.startsWith("image/")) return "image"; + if (file.type === "application/pdf") return "pdf"; + return "other"; + }; + + const readFileAsDataURL = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }; + + // Handle new thread creation + const handleNewThread = useCallback(async () => { + if (!selectedAgent) return; + + try { + const newThread = await apiClient.createThread(selectedAgent.id); + setCurrentThread(newThread); + setAvailableThreads((prev) => [newThread, ...prev]); + setChatState({ + messages: [], + isStreaming: false, + }); + accumulatedText.current = ""; + } catch (error) { + console.error("Failed to create thread:", error); + } + }, [selectedAgent]); + + // Handle thread selection + const handleThreadSelect = useCallback( + async (threadId: string) => { + const thread = availableThreads.find((t) => t.id === threadId); + if (!thread) return; + + setCurrentThread(thread); + + try { + // Load thread messages from backend + const threadMessages = await apiClient.getThreadMessages(threadId); + + setChatState({ + messages: threadMessages, + isStreaming: false, + }); + + console.log( + `Restored ${threadMessages.length} messages for thread ${threadId}` + ); + } catch (error) { + console.error("Failed to load thread messages:", error); + // Fallback to clearing messages + setChatState({ + messages: [], + isStreaming: false, + }); + } + + accumulatedText.current = ""; + }, + [availableThreads] + ); + + // Handle message sending + const handleSendMessage = useCallback( + async (request: RunAgentRequest) => { + if (!selectedAgent) return; + + // Extract text and attachments from OpenAI format for UI display + let displayText = ""; + const attachmentContents: import("@/types/agent-framework").Contents[] = + []; + + // Parse OpenAI ResponseInputParam to extract display content + for (const inputItem of request.input) { + if (inputItem.type === "message" && Array.isArray(inputItem.content)) { + for (const contentItem of inputItem.content) { + if (contentItem.type === "input_text") { + displayText += contentItem.text + " "; + } else if (contentItem.type === "input_image") { + attachmentContents.push({ + type: "data", + uri: contentItem.image_url || "", + media_type: "image/png", // Default, should extract from data URI + } as import("@/types/agent-framework").DataContent); + } else if (contentItem.type === "input_file") { + const dataUri = `data:application/octet-stream;base64,${contentItem.file_data}`; + attachmentContents.push({ + type: "data", + uri: dataUri, + media_type: "application/pdf", // Should be dynamic based on filename + } as import("@/types/agent-framework").DataContent); + } + } + } + } + + const userMessageContents: import("@/types/agent-framework").Contents[] = + [ + ...(displayText.trim() + ? [ + { + type: "text", + text: displayText.trim(), + } as import("@/types/agent-framework").TextContent, + ] + : []), + ...attachmentContents, + ]; + + // Add user message to UI state + const userMessage: ChatMessage = { + id: `user-${Date.now()}`, + role: "user", + contents: userMessageContents, + timestamp: new Date().toISOString(), + }; + + setChatState((prev) => ({ + ...prev, + messages: [...prev.messages, userMessage], + isStreaming: true, + })); + + // Create assistant message placeholder + const assistantMessage: ChatMessage = { + id: `assistant-${Date.now()}`, + role: "assistant", + contents: [], + timestamp: new Date().toISOString(), + streaming: true, + }; + + setChatState((prev) => ({ + ...prev, + messages: [...prev.messages, assistantMessage], + })); + + try { + // If no thread selected, create one automatically + let threadToUse = currentThread; + if (!threadToUse) { + try { + threadToUse = await apiClient.createThread(selectedAgent.id); + setCurrentThread(threadToUse); + setAvailableThreads((prev) => [threadToUse!, ...prev]); + } catch (error) { + console.error("Failed to create thread:", error); + } + } + + const apiRequest = { + input: request.input, + thread_id: threadToUse?.id, + }; + + // Clear text accumulator for new response + accumulatedText.current = ""; + + // Clear debug panel events for new agent run + onDebugEvent('clear'); + + // Use OpenAI-compatible API streaming - direct event handling + const streamGenerator = apiClient.streamAgentExecutionOpenAI( + selectedAgent.id, + apiRequest + ); + + for await (const openAIEvent of streamGenerator) { + // Pass all events to debug panel + onDebugEvent(openAIEvent); + + // Handle error events from the stream + if (openAIEvent.type === "error") { + const errorEvent = openAIEvent as ExtendedResponseStreamEvent & { + message?: string; + }; + const errorMessage = errorEvent.message || "An error occurred"; + + // Update assistant message with error and stop streaming + setChatState((prev) => ({ + ...prev, + isStreaming: false, + messages: prev.messages.map((msg) => + msg.id === assistantMessage.id + ? { + ...msg, + contents: [ + { + type: "text", + text: errorMessage, + }, + ], + streaming: false, + error: true, // Add error flag for styling + } + : msg + ), + })); + return; // Exit stream processing early on error + } + + // Handle text delta events for chat + if ( + openAIEvent.type === "response.output_text.delta" && + "delta" in openAIEvent && + openAIEvent.delta + ) { + accumulatedText.current += openAIEvent.delta; + + // Update assistant message with accumulated content + setChatState((prev) => ({ + ...prev, + messages: prev.messages.map((msg) => + msg.id === assistantMessage.id + ? { + ...msg, + contents: [ + { + type: "text", + text: accumulatedText.current, + }, + ], + } + : msg + ), + })); + } + + // Handle completion/error by detecting when streaming stops + // (Server will close the stream when done, so we'll exit the loop naturally) + } + + // Stream ended - mark as complete + setChatState((prev) => ({ + ...prev, + isStreaming: false, + messages: prev.messages.map((msg) => + msg.id === assistantMessage.id ? { ...msg, streaming: false } : msg + ), + })); + } catch (error) { + console.error("Streaming error:", error); + setChatState((prev) => ({ + ...prev, + isStreaming: false, + messages: prev.messages.map((msg) => + msg.id === assistantMessage.id + ? { + ...msg, + contents: [ + { + type: "text", + text: `Error: ${ + error instanceof Error + ? error.message + : "Failed to get response" + }`, + }, + ], + streaming: false, + } + : msg + ), + })); + } + }, + [selectedAgent, currentThread, onDebugEvent] + ); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if ( + (!inputValue.trim() && attachments.length === 0) || + isSubmitting || + !selectedAgent + ) + return; + + setIsSubmitting(true); + const messageText = inputValue.trim(); + setInputValue(""); + + try { + // Create OpenAI Responses API format + if (attachments.length > 0 || messageText) { + const content: import("@/types/agent-framework").ResponseInputContent[] = + []; + + // Add text content if present - EXACT OpenAI ResponseInputTextParam + if (messageText) { + content.push({ + text: messageText, + type: "input_text", + } as import("@/types/agent-framework").ResponseInputTextParam); + } + + // Add attachments using EXACT OpenAI types + for (const attachment of attachments) { + const dataUri = await readFileAsDataURL(attachment.file); + + if (attachment.file.type.startsWith("image/")) { + // EXACT OpenAI ResponseInputImageParam + content.push({ + detail: "auto", + type: "input_image", + image_url: dataUri, + } as import("@/types/agent-framework").ResponseInputImageParam); + } else { + // EXACT OpenAI ResponseInputFileParam (but we need to handle the required fields) + const base64Data = dataUri.split(",")[1]; // Extract base64 part + content.push({ + type: "input_file", + file_data: base64Data, + file_url: dataUri, // Use data URI as the URL + filename: attachment.file.name, + } as import("@/types/agent-framework").ResponseInputFileParam); + } + } + + const openaiInput: import("@/types/agent-framework").ResponseInputParam = + [ + { + type: "message", + role: "user", + content, + }, + ]; + + // Use pure OpenAI format + await handleSendMessage({ + input: openaiInput, + thread_id: currentThread?.id, + }); + } else { + // Simple text message using OpenAI format + const openaiInput: import("@/types/agent-framework").ResponseInputParam = + [ + { + type: "message", + role: "user", + content: [ + { + text: messageText, + type: "input_text", + } as import("@/types/agent-framework").ResponseInputTextParam, + ], + }, + ]; + + await handleSendMessage({ + input: openaiInput, + thread_id: currentThread?.id, + }); + } + + // Clear attachments after sending + setAttachments([]); + } finally { + setIsSubmitting(false); + } + }; + + const canSendMessage = + selectedAgent && + !isSubmitting && + !chatState.isStreaming && + (inputValue.trim() || attachments.length > 0); + + return ( +
+ {/* Header */} +
+
+

+
+ + Chat with {selectedAgent.name || selectedAgent.id} +
+

+ + {/* Thread Controls */} +
+ + + +
+
+ + {selectedAgent.description && ( +

+ {selectedAgent.description} +

+ )} +
+ + {/* Messages */} + +
+ {chatState.messages.length === 0 ? ( +
+
+ Start a conversation with{" "} + {selectedAgent.name || selectedAgent.id} +
+
+ Type a message below to begin +
+
+ ) : ( + chatState.messages.map((message) => ( + + )) + )} + + {chatState.isStreaming && !isSubmitting && } + +
+
+ + + {/* Input */} +
+
+ {/* Drag overlay */} + {isDragOver && ( +
+
+
+ Drop files here +
+
+ Images, PDFs, and other files +
+
+
+ )} + + {/* Attachment gallery */} + {attachments.length > 0 && ( +
+ +
+ )} + + {/* Input form */} +
+ setInputValue(e.target.value)} + placeholder={`Message ${ + selectedAgent.name || selectedAgent.id + }...`} + disabled={isSubmitting || chatState.isStreaming} + className="flex-1" + /> + + + +
+
+
+ ); +} diff --git a/python/packages/devui/frontend/src/components/message_renderer/ContentRenderer.tsx b/python/packages/devui/frontend/src/components/message_renderer/ContentRenderer.tsx new file mode 100644 index 0000000000..d4df87701d --- /dev/null +++ b/python/packages/devui/frontend/src/components/message_renderer/ContentRenderer.tsx @@ -0,0 +1,268 @@ +/** + * ContentRenderer - Renders individual content items based on type + */ + +import { useState } from "react"; +import { Download, FileText, AlertCircle, Code } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { RenderProps } from "./types"; +import { + isTextContent, + isFunctionCallContent, + isFunctionResultContent, +} from "@/types/agent-framework"; + +function TextContentRenderer({ content, isStreaming, className }: RenderProps) { + if (!isTextContent(content)) return null; + + return ( +
+ {content.text} + {isStreaming && ( + + )} +
+ ); +} + +function DataContentRenderer({ content, className }: RenderProps) { + const [imageError, setImageError] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + if (content.type !== "data") return null; + + // Extract data URI and media type (updated for new field names) + const dataUri = typeof content.uri === "string" ? content.uri : ""; + const mediaTypeMatch = dataUri.match(/^data:([^;]+)/); + const mediaType = content.media_type || mediaTypeMatch?.[1] || "unknown"; + + const isImage = mediaType.startsWith("image/"); + const isPdf = mediaType === "application/pdf"; + + if (isImage && !imageError) { + return ( +
+ Uploaded image setIsExpanded(!isExpanded)} + onError={() => setImageError(true)} + /> +
+ {mediaType} • Click to {isExpanded ? "collapse" : "expand"} +
+
+ ); + } + + // Fallback for non-images or failed images + return ( +
+
+ {isPdf ? ( + + ) : ( + + )} + + {isPdf ? "PDF Document" : "File Attachment"} + + ({mediaType}) +
+ +
+ ); +} + +function FunctionCallRenderer({ content, className }: RenderProps) { + const [isExpanded, setIsExpanded] = useState(false); + + if (!isFunctionCallContent(content)) return null; + + let parsedArgs; + try { + parsedArgs = + typeof content.arguments === "string" + ? JSON.parse(content.arguments) + : content.arguments; + } catch { + parsedArgs = content.arguments; + } + + return ( +
+
setIsExpanded(!isExpanded)} + > + + + Function Call: {content.name} + + + {isExpanded ? "▼" : "▶"} + +
+ {isExpanded && ( +
+
Arguments:
+
+            {JSON.stringify(parsedArgs, null, 2)}
+          
+
+ )} +
+ ); +} + +function FunctionResultRenderer({ content, className }: RenderProps) { + const [isExpanded, setIsExpanded] = useState(false); + + if (!isFunctionResultContent(content)) return null; + + return ( +
+
setIsExpanded(!isExpanded)} + > + + + Function Result + + + {isExpanded ? "▼" : "▶"} + +
+ {isExpanded && ( +
+
+            {typeof content.result === "string"
+              ? content.result
+              : JSON.stringify(content.result, null, 2)}
+          
+
+ )} +
+ ); +} + +function ErrorContentRenderer({ content, className }: RenderProps) { + if (content.type !== "error") return null; + + return ( +
+
+ + Error + {content.error_code && ( + ({content.error_code}) + )} +
+
{content.error}
+
+ ); +} + +function UriContentRenderer({ content, className }: RenderProps) { + const [imageError, setImageError] = useState(false); + + if (content.type !== "uri") return null; + + const isImage = content.media_type?.startsWith("image/"); + + if (isImage && !imageError) { + return ( +
+ Referenced image setImageError(true)} + /> + +
+ ); + } + + return ( +
+ +
+ {content.uri} +
+
+ ); +} + +export function ContentRenderer({ content, isStreaming, className }: RenderProps) { + switch (content.type) { + case "text": + return ( + + ); + case "data": + return ; + case "uri": + return ; + case "function_call": + return ( + + ); + case "function_result": + return ( + + ); + case "error": + return ; + default: + // Fallback for unsupported content types + return ( +
+
Unsupported content type: {content.type}
+
+            {JSON.stringify(content, null, 2)}
+          
+
+ ); + } +} \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/message_renderer/MessageRenderer.tsx b/python/packages/devui/frontend/src/components/message_renderer/MessageRenderer.tsx new file mode 100644 index 0000000000..21bef64730 --- /dev/null +++ b/python/packages/devui/frontend/src/components/message_renderer/MessageRenderer.tsx @@ -0,0 +1,38 @@ +/** + * MessageRenderer - Main orchestrator for rendering message contents + */ + +import { StreamingRenderer } from "./StreamingRenderer"; +import { ContentRenderer } from "./ContentRenderer"; +import type { MessageRendererProps } from "./types"; + +export function MessageRenderer({ + contents, + isStreaming = false, + className, +}: MessageRendererProps) { + // If not streaming, render each content item individually + if (!isStreaming) { + return ( +
+ {contents.map((content, index) => ( + 0 ? "mt-2" : ""} + /> + ))} +
+ ); + } + + // For streaming, use the streaming renderer for smart accumulation + return ( + + ); +} \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/message_renderer/StreamingRenderer.tsx b/python/packages/devui/frontend/src/components/message_renderer/StreamingRenderer.tsx new file mode 100644 index 0000000000..b08dceb6ef --- /dev/null +++ b/python/packages/devui/frontend/src/components/message_renderer/StreamingRenderer.tsx @@ -0,0 +1,114 @@ +/** + * StreamingRenderer - Handles accumulation and display of streaming content + */ + +import { useState, useEffect } from "react"; +import { ContentRenderer } from "./ContentRenderer"; +import type { Contents, MessageRenderState } from "./types"; +import { isTextContent } from "@/types/agent-framework"; + +interface StreamingRendererProps { + contents: Contents[]; + isStreaming?: boolean; + className?: string; +} + +export function StreamingRenderer({ + contents, + isStreaming = false, + className, +}: StreamingRendererProps) { + const [renderState, setRenderState] = useState({ + textAccumulator: "", + dataContentItems: [], + functionCalls: [], + errors: [], + isComplete: !isStreaming, + }); + + useEffect(() => { + // Process and accumulate content + let textAccumulator = ""; + const dataContentItems: Contents[] = []; + const functionCalls: Contents[] = []; + const errors: Contents[] = []; + + contents.forEach((content) => { + if (isTextContent(content)) { + textAccumulator += content.text; + } else if (content.type === "data") { + // Only show data content when streaming is complete or item is complete + if (!isStreaming) { + dataContentItems.push(content); + } + } else if (content.type === "function_call") { + functionCalls.push(content); + } else if (content.type === "error") { + errors.push(content); + } else { + // Other content types (uri, function_result, etc.) + dataContentItems.push(content); + } + }); + + setRenderState({ + textAccumulator, + dataContentItems, + functionCalls, + errors, + isComplete: !isStreaming, + }); + }, [contents, isStreaming]); + + const hasTextContent = renderState.textAccumulator.length > 0; + const hasOtherContent = + renderState.dataContentItems.length > 0 || + renderState.functionCalls.length > 0 || + renderState.errors.length > 0; + + return ( +
+ {/* Render accumulated text with streaming indicator */} + {hasTextContent && ( +
+ {renderState.textAccumulator} + {isStreaming && hasTextContent && ( + + )} +
+ )} + + {/* Render other content types when complete or non-data items immediately */} + {hasOtherContent && ( +
+ {renderState.errors.map((content, index) => ( + + ))} + + {renderState.functionCalls.map((content, index) => ( + + ))} + + {renderState.dataContentItems.map((content, index) => ( + + ))} +
+ )} + + {/* Show loading indicator when streaming and no text content yet */} + {isStreaming && !hasTextContent && !hasOtherContent && ( +
+
+
+
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/message_renderer/index.ts b/python/packages/devui/frontend/src/components/message_renderer/index.ts new file mode 100644 index 0000000000..f4f8c0fd89 --- /dev/null +++ b/python/packages/devui/frontend/src/components/message_renderer/index.ts @@ -0,0 +1,8 @@ +/** + * Message Renderer - Exports + */ + +export { MessageRenderer } from "./MessageRenderer"; +export { ContentRenderer } from "./ContentRenderer"; +export { StreamingRenderer } from "./StreamingRenderer"; +export type { MessageRendererProps, RenderProps, MessageRenderState } from "./types"; \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/message_renderer/types.ts b/python/packages/devui/frontend/src/components/message_renderer/types.ts new file mode 100644 index 0000000000..ea00abb4d9 --- /dev/null +++ b/python/packages/devui/frontend/src/components/message_renderer/types.ts @@ -0,0 +1,48 @@ +/** + * Types for message rendering components + */ + +// Re-export and extend types from agent-framework +import type { + Contents, + TextContent, + DataContent, + UriContent, + FunctionCallContent, + FunctionResultContent, + ErrorContent, + AgentRunResponseUpdate, +} from "@/types/agent-framework"; + +export type { + Contents, + TextContent, + DataContent, + UriContent, + FunctionCallContent, + FunctionResultContent, + ErrorContent, + AgentRunResponseUpdate, +}; + +// UI-specific types for message rendering +export interface MessageRenderState { + // Track accumulated content during streaming + textAccumulator: string; + dataContentItems: Contents[]; + functionCalls: Contents[]; + errors: Contents[]; + isComplete: boolean; +} + +export interface RenderProps { + content: Contents; + isStreaming?: boolean; + className?: string; +} + +export interface MessageRendererProps { + contents: Contents[]; + isStreaming?: boolean; + className?: string; +} \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/mode-toggle.tsx b/python/packages/devui/frontend/src/components/mode-toggle.tsx new file mode 100644 index 0000000000..b4259e484a --- /dev/null +++ b/python/packages/devui/frontend/src/components/mode-toggle.tsx @@ -0,0 +1,39 @@ +"use client" + +import { Moon, Sun } from "lucide-react" +import { useTheme } from "next-themes" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +export function ModeToggle() { + const { setTheme } = useTheme() + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ) +} \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/shared/about-modal.tsx b/python/packages/devui/frontend/src/components/shared/about-modal.tsx new file mode 100644 index 0000000000..9573065dfd --- /dev/null +++ b/python/packages/devui/frontend/src/components/shared/about-modal.tsx @@ -0,0 +1,54 @@ +/** + * About DevUI Modal - Shows information about the DevUI sample app + */ + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogClose, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ExternalLink } from "lucide-react"; + +interface AboutModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AboutModal({ open, onOpenChange }: AboutModalProps) { + return ( + + + + About DevUI + onOpenChange(false)} /> + + +
+

+ DevUI is a sample app for getting started with Agent Framework. +

+ +
+ +
+
+
+
+ ); +} diff --git a/python/packages/devui/frontend/src/components/shared/app-header.tsx b/python/packages/devui/frontend/src/components/shared/app-header.tsx new file mode 100644 index 0000000000..9c2af3751d --- /dev/null +++ b/python/packages/devui/frontend/src/components/shared/app-header.tsx @@ -0,0 +1,48 @@ +/** + * AppHeader - Global application header + * Features: Entity selection, global settings, theme toggle + */ + +import { Button } from "@/components/ui/button"; +import { EntitySelector } from "@/components/shared/entity-selector"; +import { ModeToggle } from "@/components/mode-toggle"; +import { Settings } from "lucide-react"; +import type { AgentInfo, WorkflowInfo } from "@/types"; + +interface AppHeaderProps { + agents: AgentInfo[]; + workflows: WorkflowInfo[]; + selectedItem?: AgentInfo | WorkflowInfo; + onSelect: (item: AgentInfo | WorkflowInfo) => void; + isLoading?: boolean; + onSettingsClick?: () => void; +} + +export function AppHeader({ + agents, + workflows, + selectedItem, + onSelect, + isLoading = false, + onSettingsClick, +}: AppHeaderProps) { + return ( +
+
Dev UI
+ + +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/shared/debug-panel.tsx b/python/packages/devui/frontend/src/components/shared/debug-panel.tsx new file mode 100644 index 0000000000..4aa8d5e0ae --- /dev/null +++ b/python/packages/devui/frontend/src/components/shared/debug-panel.tsx @@ -0,0 +1,1390 @@ +/** + * DebugPanel - Tabbed interface for OpenAI events, traces, and tool information + * Features: Real-time event streaming, trace visualization, tool call details + */ + +import { useRef, useState } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { + Activity, + Search, + Wrench, + CheckCircle2, + XCircle, + AlertCircle, + Zap, + MessageSquare, + ChevronRight, + ChevronDown, + Info, +} from "lucide-react"; +import type { ExtendedResponseStreamEvent } from "@/types"; + +// Type definitions for event data structures +interface EventDataBase { + call_id?: string; + executor_id?: string; + timestamp?: string; + [key: string]: unknown; +} + +interface FunctionResultData extends EventDataBase { + result?: unknown; + status?: "completed" | "failed"; + exception?: string; +} + +interface FunctionCallData extends EventDataBase { + name?: string; + arguments?: string | object; + function?: unknown; + tool_calls?: unknown[]; +} + +interface WorkflowEventData extends EventDataBase { + event_type?: string; + data?: Record; +} + +interface TraceEventData extends EventDataBase { + operation_name?: string; + duration_ms?: number; + status?: string; + attributes?: Record; + span_id?: string; + trace_id?: string; + parent_span_id?: string | null; + start_time?: number; + end_time?: number; + entity_id?: string; + session_id?: string | null; +} + +interface DebugPanelProps { + events: ExtendedResponseStreamEvent[]; + isStreaming?: boolean; +} + +// Helper function to accumulate OpenAI events into meaningful units +function processEventsForDisplay( + events: ExtendedResponseStreamEvent[] +): ExtendedResponseStreamEvent[] { + const processedEvents: ExtendedResponseStreamEvent[] = []; + const functionCalls = new Map< + string, + { + name?: string; + arguments: string; + callId: string; + timestamp: string; + } + >(); + const callIdToName = new Map(); // Track call_id -> function name mappings + let accumulatedText = ""; + const lastFunctionCallId: string | null = null; // Track the most recent function call + + for (const event of events) { + // Always show completion, error, workflow events, and function results + if ( + event.type === "response.done" || + event.type === "error" || + event.type === "response.workflow_event.complete" || + event.type === "response.trace_event.complete" || + event.type === "response.trace.complete" || + event.type === "response.function_result.complete" + ) { + // Flush any accumulated text before showing these events + if (accumulatedText.trim()) { + processedEvents.push({ + type: "response.output_text.delta", + delta: accumulatedText.trim(), + } as ExtendedResponseStreamEvent); + accumulatedText = ""; + } + + // Extract function names from trace events + if ( + (event.type === "response.trace_event.complete" || + event.type === "response.trace.complete") && + "data" in event + ) { + const traceData = event.data as TraceEventData; + if ( + traceData.attributes && + traceData.attributes["gen_ai.output.messages"] && + typeof traceData.attributes["gen_ai.output.messages"] === "string" + ) { + try { + const messages = JSON.parse( + traceData.attributes["gen_ai.output.messages"] as string + ); + for (const msg of messages) { + if (msg.parts) { + for (const part of msg.parts) { + if (part.type === "tool_call" && part.name && part.id) { + // Store the call_id -> function name mapping + callIdToName.set(part.id, part.name); + } + } + } + } + } catch { + // Ignore parsing errors + } + } + } + + // For function results, ensure we have the corresponding function call + if ( + event.type === "response.function_result.complete" && + "data" in event + ) { + const resultData = event.data as FunctionResultData; + const callId = resultData.call_id; + + // Only create function call event if we have actual argument data + if (callId && functionCalls.has(callId)) { + const call = functionCalls.get(callId)!; + const functionName = + callIdToName.get(callId) || call.name || "unknown"; + + processedEvents.push({ + type: "response.function_call.complete", + data: { + name: functionName, + arguments: call.arguments, + call_id: call.callId, + }, + } as ExtendedResponseStreamEvent); + functionCalls.delete(callId); + } + } + + processedEvents.push(event); + continue; + } + + // Handle function call start events + if (event.type === "response.function_call.delta" && "data" in event) { + const callData = event.data as FunctionCallData; + const callId = callData.call_id || `call_${Date.now()}`; + + // Initialize or update the function call + if (!functionCalls.has(callId)) { + functionCalls.set(callId, { + name: callData.name || undefined, + arguments: "", + callId, + timestamp: new Date().toISOString(), + }); + } + + // Update name if provided + if (callData.name && callData.name.trim()) { + functionCalls.get(callId)!.name = callData.name.trim(); + } + continue; + } + + // Handle function call complete events that come directly (not generated by us) + if (event.type === "response.function_call.complete" && "data" in event) { + // This is already a complete function call event, just pass it through + processedEvents.push(event); + continue; + } + + // Handle function call arguments accumulation - ACTUAL BACKEND FORMAT + if (event.type === "response.function_call_arguments.delta") { + let deltaData: string = ""; + let callId: string; + + // Extract delta from actual backend format + if ("delta" in event && typeof event.delta === "string") { + deltaData = event.delta; + } + + // Use a simple tracking approach since backend doesn't provide call_id in argument deltas + if ( + "data" in event && + event.data && + (event.data as EventDataBase).call_id + ) { + callId = String((event.data as EventDataBase).call_id); + } else { + callId = lastFunctionCallId || `call_${Date.now()}`; + } + + if (deltaData && callId) { + // Ensure we have a function call entry + if (!functionCalls.has(callId)) { + functionCalls.set(callId, { + name: "unknown", // Backend doesn't provide function name in these events + arguments: "", + callId, + timestamp: new Date().toISOString(), + }); + } + + // Accumulate the delta + const call = functionCalls.get(callId)!; + + // Skip the initial "{}" delta that backend sends + if (deltaData === "{}" && call.arguments === "") { + continue; + } + + // Accumulate and clean up the delta + let cleanedDelta = deltaData; + try { + // Remove extra quotes and escaping that backend adds + cleanedDelta = deltaData.replace(/^"|"$/g, "").replace(/\\"/g, '"'); + } catch { + cleanedDelta = deltaData; + } + call.arguments += cleanedDelta; + } + continue; + } + + // Handle text delta events + if (event.type === "response.output_text.delta" && "delta" in event) { + accumulatedText += event.delta || ""; + + // Only emit if we have substantial content AND hit a natural paragraph break + // This makes the text accumulation much more aggressive + if ( + accumulatedText.length > 100 && + (accumulatedText.includes("\n\n") || + accumulatedText.trim().match(/[.!?]\s*$/)) + ) { + processedEvents.push({ + type: "response.output_text.delta", + delta: accumulatedText.trim(), + } as ExtendedResponseStreamEvent); + accumulatedText = ""; + } + continue; + } + + // Handle usage events (skip them as they're noise) + if (event.type === "response.usage.complete") { + continue; + } + + // Handle other event types - pass through + processedEvents.push(event); + } + + // Finalize any remaining function calls that didn't get results + for (const [, call] of functionCalls) { + if (call.arguments.trim() && call.arguments.trim().length > 2) { + const functionName = + callIdToName.get(call.callId) || call.name || "unknown"; + processedEvents.push({ + type: "response.function_call.complete", + data: { + name: functionName, + arguments: call.arguments, + call_id: call.callId, + }, + } as ExtendedResponseStreamEvent); + } + } + + // Finalize any remaining text + if (accumulatedText.trim()) { + processedEvents.push({ + type: "response.output_text.delta", + delta: accumulatedText.trim(), + } as ExtendedResponseStreamEvent); + } + + return processedEvents; +} + +interface EventItemProps { + event: ExtendedResponseStreamEvent; +} + +function getEventSummary(event: ExtendedResponseStreamEvent): string { + switch (event.type) { + case "response.output_text.delta": + if ("delta" in event) { + const text = event.delta || ""; + return text.length > 60 ? `${text.slice(0, 60)}...` : text; + } + return "Text output"; + + case "response.function_call.complete": + if ("data" in event && event.data) { + const data = event.data as FunctionCallData; + + // Try to extract function name from various possible locations + let functionName = data.name || "unknown"; + + // Use the function name as provided, no complex inference needed + if (!functionName || functionName === "unknown") { + functionName = "function_call"; + } + + const argsStr = data.arguments + ? typeof data.arguments === "string" + ? data.arguments.slice(0, 30) + : JSON.stringify(data.arguments).slice(0, 30) + : ""; + return `Calling ${functionName}(${argsStr}${ + argsStr.length >= 30 ? "..." : "" + })`; + } + return "Function call"; + + case "response.function_call_arguments.delta": + if ("delta" in event && event.delta) { + return `Function arg delta: ${event.delta.slice(0, 30)}${ + event.delta.length > 30 ? "..." : "" + }`; + } + return "Function arguments..."; + + case "response.function_result.complete": + if ("data" in event && event.data) { + const data = event.data as FunctionResultData; + const resultStr = data.result + ? typeof data.result === "string" + ? data.result + : JSON.stringify(data.result) + : "no result"; + const truncated = resultStr.slice(0, 40); + return `Tool result: ${truncated}${ + truncated.length >= 40 ? "..." : "" + }`; + } + return "Function result"; + + case "response.workflow_event.complete": + if ("data" in event && event.data) { + const data = event.data as WorkflowEventData; + return `Executor: ${data.executor_id || "unknown"}`; + } + return "Workflow event"; + + case "response.trace_event.complete": + case "response.trace.complete": + if ("data" in event && event.data) { + const data = event.data as TraceEventData; + return `Trace: ${data.operation_name || "unknown"}`; + } + return "Trace event"; + + case "response.done": + return "Response complete"; + + case "error": + // Extract actual error message from error events + if ("message" in event && typeof event.message === "string") { + return event.message; + } + return "Error occurred"; + + default: + return `${event.type}`; + } +} + +function getEventIcon(type: string) { + switch (type) { + case "response.output_text.delta": + return MessageSquare; + case "response.function_call.complete": + case "response.function_call.delta": + case "response.function_call_arguments.delta": + return Wrench; + case "response.function_result.complete": + return CheckCircle2; + case "response.workflow_event.complete": + return Activity; + case "response.trace_event.complete": + case "response.trace.complete": + return Search; + case "response.done": + return CheckCircle2; + case "error": + return XCircle; + default: + return AlertCircle; + } +} + +function getEventColor(type: string) { + switch (type) { + case "response.output_text.delta": + return "text-gray-600 dark:text-gray-400"; + case "response.function_call.complete": + case "response.function_call.delta": + case "response.function_call_arguments.delta": + return "text-blue-600 dark:text-blue-400"; + case "response.function_result.complete": + return "text-green-600 dark:text-green-400"; + case "response.workflow_event.complete": + return "text-purple-600 dark:text-purple-400"; + case "response.trace_event.complete": + case "response.trace.complete": + return "text-orange-600 dark:text-orange-400"; + case "response.done": + return "text-green-600 dark:text-green-400"; + case "error": + return "text-red-600 dark:text-red-400"; + default: + return "text-gray-600 dark:text-gray-400"; + } +} + +function EventItem({ event }: EventItemProps) { + const [isExpanded, setIsExpanded] = useState(false); + const Icon = getEventIcon(event.type); + const colorClass = getEventColor(event.type); + const timestamp = new Date().toLocaleTimeString(); + const summary = getEventSummary(event); + + // Determine if this event has expandable content + const hasExpandableContent = + (event.type === "response.function_call.complete" && + "data" in event && + event.data) || + (event.type === "response.function_result.complete" && + "data" in event && + event.data) || + (event.type === "response.workflow_event.complete" && + "data" in event && + event.data) || + (event.type === "response.trace_event.complete" && + "data" in event && + event.data) || + (event.type === "response.trace.complete" && + "data" in event && + event.data) || + (event.type === "response.output_text.delta" && + "delta" in event && + event.delta && + event.delta.length > 100) || + // Make error events expandable to show full error details + event.type === "error"; + + return ( +
+
+ + {timestamp} + + {event.type.replace("response.", "")} + +
+ +
+
hasExpandableContent && setIsExpanded(!isExpanded)} + > + {hasExpandableContent && ( +
+ {isExpanded ? ( + + ) : ( + + )} +
+ )} +
+ {hasExpandableContent && summary.length > 80 + ? `${summary.slice(0, 80)}...` + : summary} +
+
+ + {/* Expandable content */} + {isExpanded && hasExpandableContent && ( +
+ +
+ )} +
+
+ ); +} + +function EventExpandedContent({ + event, +}: { + event: ExtendedResponseStreamEvent; +}) { + // Handle error events with detailed information + if (event.type === "error") { + const errorEvent = event as ExtendedResponseStreamEvent & { + message?: string; + code?: string; + param?: string; + }; + return ( +
+
+ + Error Details +
+
+ {errorEvent.message && ( +
+ + Message: + +
+
+                  {errorEvent.message}
+                
+
+
+ )} + {errorEvent.code && ( +
+ Code: + {errorEvent.code} +
+ )} + {errorEvent.param && ( +
+ + Parameter: + + {errorEvent.param} +
+ )} +
+ + Raw Event: + +
+
+                {JSON.stringify(event, null, 2)}
+              
+
+
+
+
+ ); + } + + switch (event.type) { + case "response.function_call.complete": + if ("data" in event && event.data) { + const data = event.data as FunctionCallData; + return ( +
+
+ + Function Call +
+
+
+ + Function: + + + {data.name || "unknown"} + +
+ {data.call_id && ( +
+ + Call ID: + + {data.call_id} +
+ )} + {data.arguments && ( +
+ + Arguments: + +
+
+                      {typeof data.arguments === "string"
+                        ? data.arguments
+                        : JSON.stringify(data.arguments, null, 1)}
+                    
+
+
+ )} +
+
+ ); + } + break; + + case "response.function_result.complete": + if ("data" in event && event.data) { + const data = event.data as FunctionResultData; + return ( +
+
+ + Function Result +
+
+ {data.call_id && ( +
+ + Call ID: + + {data.call_id} +
+ )} +
+ + Status: + + + {data.status || "unknown"} + +
+ {data.result !== undefined && ( +
+ + Result: + +
+
+                      {typeof data.result === "string"
+                        ? data.result
+                        : JSON.stringify(data.result, null, 1)}
+                    
+
+
+ )} + {data.exception !== null && data.exception !== undefined && ( +
+ Error: +
+
+                      {data.exception}
+                    
+
+
+ )} +
+
+ ); + } + break; + + case "response.workflow_event.complete": + if ("data" in event && event.data) { + const data = event.data as WorkflowEventData; + return ( +
+
+ + Workflow Event +
+
+
+ + Event Type: + + + {data.event_type || "unknown"} + +
+ {data.executor_id && ( +
+ + Executor: + + {data.executor_id} +
+ )} + {data.timestamp && ( +
+ + Timestamp: + + + {data.timestamp} + +
+ )} + {data.data && ( +
+ + Data: + +
+
+                      {typeof data.data === "string"
+                        ? data.data
+                        : JSON.stringify(data.data, null, 1)}
+                    
+
+
+ )} +
+
+ ); + } + break; + + case "response.trace_event.complete": + case "response.trace.complete": + if ("data" in event && event.data) { + const data = event.data as TraceEventData; + return ( +
+
+ + Trace Event +
+
+
+ + Operation: + + + {data.operation_name || "unknown"} + +
+ {data.span_id && ( +
+ + Span ID: + + {data.span_id} +
+ )} + {data.trace_id && ( +
+ + Trace ID: + + + {data.trace_id} + +
+ )} + {data.duration_ms && ( +
+ + Duration: + + + {Number(data.duration_ms).toFixed(2)}ms + +
+ )} + {data.status && ( +
+ + Status: + + + {data.status || "unknown"} + +
+ )} + {data.entity_id && ( +
+ + Entity: + + + {data.entity_id} + +
+ )} + {data.attributes && Object.keys(data.attributes).length > 0 && ( +
+ + Attributes: + +
+
+                      {(() => {
+                        try {
+                          // Try to pretty-print JSON, and unescape string values that contain JSON
+                          const attrs = { ...data.attributes };
+                          Object.keys(attrs).forEach((key) => {
+                            if (
+                              typeof attrs[key] === "string" &&
+                              attrs[key].startsWith("[")
+                            ) {
+                              try {
+                                attrs[key] = JSON.parse(attrs[key]);
+                              } catch {
+                                // Keep original if parsing fails
+                              }
+                            }
+                          });
+                          return JSON.stringify(attrs, null, 2);
+                        } catch {
+                          return JSON.stringify(data.attributes, null, 2);
+                        }
+                      })()}
+                    
+
+
+ )} +
+
+ ); + } + break; + + case "response.output_text.delta": + if ("delta" in event && event.delta) { + return ( +
+
+ + Text Output +
+
+
+                {event.delta}
+              
+
+
+ ); + } + break; + + default: + return ( +
+
+            {JSON.stringify(event, null, 2)}
+          
+
+ ); + } + + return null; +} + +function EventsTab({ + events, + isStreaming, +}: { + events: ExtendedResponseStreamEvent[]; + isStreaming?: boolean; +}) { + const scrollRef = useRef(null); + + // Process events to accumulate tool calls and reduce noise + const processedEvents = processEventsForDisplay(events); + + // Reverse events so latest appears at top + const reversedEvents = [...processedEvents].reverse(); + + return ( +
+
+
+ + Events + + {processedEvents.length} + {events.length > processedEvents.length + ? ` (${events.length} raw)` + : ""} + +
+ {isStreaming && ( +
+
+ Streaming +
+ )} +
+ + +
+ {processedEvents.length === 0 ? ( +
+ {events.length === 0 + ? "No events yet. Start a conversation to see real-time events." + : "Processing events... Accumulated events will appear here."} +
+ ) : ( +
+ {reversedEvents.map((event, index) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) { + // ONLY show actual trace events - handle both event type formats + const traceEvents = events.filter( + (e) => + e.type === "response.trace_event.complete" || + e.type === "response.trace.complete" + ); + + // Reverse to show latest traces at the top + const reversedTraceEvents = [...traceEvents].reverse(); + + return ( +
+
+ + Traces + {traceEvents.length} +
+ + +
+ {traceEvents.length === 0 ? ( +
+ No trace data available. +
+ {events && events.length > 0 && ( +
+ {" "} + + You may have to set the environment variable{" "} + + ENABLE_OTEL=true + {" "} + to enable tracing. +
+ )} +
+ ) : ( +
+ {reversedTraceEvents.map((event, index) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) { + const [isExpanded, setIsExpanded] = useState(false); + + if ( + (event.type !== "response.trace_event.complete" && + event.type !== "response.trace.complete") || + !("data" in event) + ) { + return ( +
+ Error: Expected trace event but got {event.type} +
+ ); + } + + const data = event.data as TraceEventData; + + // Use actual trace timestamp if available, fallback to current time + let timestamp = new Date().toLocaleTimeString(); + if (data.end_time) { + timestamp = new Date(data.end_time * 1000).toLocaleTimeString(); + } else if (data.start_time) { + timestamp = new Date(data.start_time * 1000).toLocaleTimeString(); + } else if (data.timestamp) { + timestamp = new Date(data.timestamp).toLocaleTimeString(); + } + + const operationName = data.operation_name || "Unknown Operation"; + const duration = data.duration_ms + ? `${Number(data.duration_ms).toFixed(1)}ms` + : ""; + const entityId = data.entity_id || ""; + + return ( +
+
+ + {timestamp} + + trace + + {duration && ( + + {duration} + + )} +
+ +
+
setIsExpanded(!isExpanded)} + > +
+ {isExpanded ? ( + + ) : ( + + )} +
+
+ {operationName} + {entityId && ({entityId})} +
+
+ + {/* Expandable content */} + {isExpanded && ( +
+
+
+ + Trace Details +
+
+
+ + Operation: + + + {operationName} + +
+ {data.span_id && ( +
+ + Span ID: + + + {data.span_id} + +
+ )} + {data.trace_id && ( +
+ + Trace ID: + + + {data.trace_id} + +
+ )} + {data.parent_span_id && ( +
+ + Parent Span: + + + {data.parent_span_id} + +
+ )} + {data.duration_ms && ( +
+ + Duration: + + + {Number(data.duration_ms).toFixed(2)}ms + +
+ )} + {data.status && ( +
+ + Status: + + + {data.status || "unknown"} + +
+ )} + {data.entity_id && ( +
+ + Entity: + + + {data.entity_id} + +
+ )} + {data.attributes && Object.keys(data.attributes).length > 0 && ( +
+ + Attributes: + +
+
+                        {(() => {
+                          try {
+                            // Try to pretty-print JSON, and unescape string values that contain JSON
+                            const attrs = { ...data.attributes };
+                            Object.keys(attrs).forEach((key) => {
+                              if (
+                                typeof attrs[key] === "string" &&
+                                attrs[key].startsWith("[")
+                              ) {
+                                try {
+                                  attrs[key] = JSON.parse(attrs[key]);
+                                } catch {
+                                  // Keep original if parsing fails
+                                }
+                              }
+                            });
+                            return JSON.stringify(attrs, null, 2);
+                          } catch {
+                            return JSON.stringify(data.attributes, null, 2);
+                          }
+                        })()}
+                      
+
+
+ )} +
+
+
+ )} +
+
+ ); +} + +function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) { + // Process events first to get clean tool calls + const processedEvents = processEventsForDisplay(events); + + // Create call->result pairs in chronological order + const toolEvents: ExtendedResponseStreamEvent[] = []; + const functionCalls = processedEvents.filter( + (event) => event.type === "response.function_call.complete" + ); + const functionResults = events.filter( + (event) => event.type === "response.function_result.complete" + ); + + // Create a map of call_id to results for easy lookup + const resultsByCallId = new Map(); + functionResults.forEach((result) => { + if ( + "data" in result && + result.data && + (result.data as EventDataBase).call_id + ) { + resultsByCallId.set( + String((result.data as EventDataBase).call_id), + result + ); + } + }); + + // Add call->result pairs in chronological order + functionCalls.forEach((call) => { + toolEvents.push(call); + + // Find matching result and add it immediately after the call + if ("data" in call && call.data && (call.data as EventDataBase).call_id) { + const callId = String((call.data as EventDataBase).call_id); + const matchingResult = resultsByCallId.get(callId); + if (matchingResult) { + toolEvents.push(matchingResult); + resultsByCallId.delete(callId); // Remove so we don't add it again + } + } + }); + + // Add any orphaned results that didn't match calls + resultsByCallId.forEach((result) => { + toolEvents.push(result); + }); + + // Reverse to show latest tools at the top + const reversedToolEvents = [...toolEvents].reverse(); + + return ( +
+
+ + Tools + {toolEvents.length} +
+ + +
+ {toolEvents.length === 0 ? ( +
+ No tool executions yet. Tool calls will appear here during + conversations. +
+ ) : ( +
+ {reversedToolEvents.map((event, index) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) { + if (!("data" in event)) { + return null; + } + + const data = event.data as EventDataBase; + const timestamp = new Date().toLocaleTimeString(); + + // Check if this is a function call event + const isFunctionCall = event.type === "response.function_call.complete"; + const isFunctionResult = event.type === "response.function_result.complete"; + + if (!isFunctionCall && !isFunctionResult) { + return null; + } + + return ( +
+
+
+ + + {isFunctionCall ? "Tool Call" : "Tool Result"} + + {isFunctionCall && data.name !== undefined && ( + + ({String(data.name)}) + + )} +
+ + {timestamp} + +
+ + {/* Function Calls */} + {isFunctionCall && ( +
+
+ + + CALL + + + {String(data.name || "unknown")} + +
+ + {data.arguments !== undefined && ( +
+ + Arguments: + +
+                {typeof data.arguments === "string"
+                  ? data.arguments
+                  : JSON.stringify(data.arguments, null, 1)}
+              
+
+ )} +
+ )} + + {/* Function Results */} + {isFunctionResult && ( +
+
+ + + RESULT + +
+ +
+ Result: +
+              {typeof data.result === "string"
+                ? data.result
+                : JSON.stringify(data.result, null, 1)}
+            
+
+ + {data.exception !== null && data.exception !== undefined && ( +
+ + Error: {String(data.exception)} + +
+ )} +
+ )} +
+ ); +} + +export function DebugPanel({ events, isStreaming = false }: DebugPanelProps) { + return ( +
+ +
+ + + Events + + + Traces + + + Tools + + +
+ + + + + + + + + + + + +
+
+ ); +} diff --git a/python/packages/devui/frontend/src/components/shared/entity-selector.tsx b/python/packages/devui/frontend/src/components/shared/entity-selector.tsx new file mode 100644 index 0000000000..72d717a215 --- /dev/null +++ b/python/packages/devui/frontend/src/components/shared/entity-selector.tsx @@ -0,0 +1,187 @@ +/** + * EntitySelector - High-quality dropdown for selecting agents/workflows + * Features: Type indicators, tool counts, keyboard navigation, search + */ + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Badge } from "@/components/ui/badge"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ChevronDown, Bot, Workflow, FolderOpen, Database } from "lucide-react"; +import type { AgentInfo, WorkflowInfo } from "@/types"; + +interface EntitySelectorProps { + agents: AgentInfo[]; + workflows: WorkflowInfo[]; + selectedItem?: AgentInfo | WorkflowInfo; + onSelect: (item: AgentInfo | WorkflowInfo) => void; + isLoading?: boolean; +} + +const getTypeIcon = (type: "agent" | "workflow") => { + return type === "workflow" ? Workflow : Bot; +}; + +const getSourceIcon = (source: "directory" | "in_memory") => { + return source === "directory" ? FolderOpen : Database; +}; + +export function EntitySelector({ + agents, + workflows, + selectedItem, + onSelect, + isLoading = false, +}: EntitySelectorProps) { + const [open, setOpen] = useState(false); + + const allItems = [...agents, ...workflows].sort( + (a, b) => a.name?.localeCompare(b.name || a.id) || a.id.localeCompare(b.id) + ); + + const handleSelect = (item: AgentInfo | WorkflowInfo) => { + onSelect(item); + setOpen(false); + }; + + const TypeIcon = selectedItem ? getTypeIcon(selectedItem.type) : Bot; + const displayName = selectedItem?.name || selectedItem?.id || "Select Entity"; + const itemCount = + selectedItem?.type === "workflow" + ? (selectedItem as WorkflowInfo).executors?.length || 0 + : (selectedItem as AgentInfo)?.tools?.length || 0; + const itemLabel = selectedItem?.type === "workflow" ? "executors" : "tools"; + + return ( + + + + + + + {agents.length > 0 && ( + <> + + + Agents ({agents.length}) + + {agents.map((agent) => { + const SourceIcon = getSourceIcon(agent.source); + return ( + handleSelect(agent)} + className="cursor-pointer" + > +
+
+ +
+
+ {agent.name || agent.id} +
+ {agent.description && ( +
+ {agent.description} +
+ )} +
+
+
+ + + {agent.tools.length} + +
+
+
+ ); + })} + + )} + + {workflows.length > 0 && ( + <> + {agents.length > 0 && } + + + Workflows ({workflows.length}) + + {workflows.map((workflow) => { + const SourceIcon = getSourceIcon(workflow.source); + return ( + handleSelect(workflow)} + className="cursor-pointer" + > +
+
+ +
+
+ {workflow.name || workflow.id} +
+ {workflow.description && ( +
+ {workflow.description} +
+ )} +
+
+
+ + + {workflow.executors.length} + +
+
+
+ ); + })} + + )} + + {allItems.length === 0 && ( + +
+ {isLoading ? "Loading entities..." : "No entities found"} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/theme-provider.tsx b/python/packages/devui/frontend/src/components/theme-provider.tsx new file mode 100644 index 0000000000..4712a22957 --- /dev/null +++ b/python/packages/devui/frontend/src/components/theme-provider.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as React from "react" +import { ThemeProvider as NextThemesProvider } from "next-themes" + +interface ThemeProviderProps { + children: React.ReactNode + attribute?: "class" | "data-theme" | "data-mode" + defaultTheme?: string + enableSystem?: boolean + disableTransitionOnChange?: boolean +} + +export function ThemeProvider({ + children, + attribute = "class", + defaultTheme = "dark", + enableSystem = true, + disableTransitionOnChange = true, + ...props +}: ThemeProviderProps) { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/ui/attachment-gallery.tsx b/python/packages/devui/frontend/src/components/ui/attachment-gallery.tsx new file mode 100644 index 0000000000..82e02bde27 --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/attachment-gallery.tsx @@ -0,0 +1,115 @@ +/** + * AttachmentGallery - Shows uploaded files with thumbnails and remove options + */ + +import { useState } from "react"; +import { FileText, Image, Trash2 } from "lucide-react"; + +export interface AttachmentItem { + id: string; + file: File; + preview?: string; // Data URL for preview + type: "image" | "pdf" | "other"; +} + +interface AttachmentGalleryProps { + attachments: AttachmentItem[]; + onRemoveAttachment: (id: string) => void; + className?: string; +} + +export function AttachmentGallery({ + attachments, + onRemoveAttachment, + className = "", +}: AttachmentGalleryProps) { + if (attachments.length === 0) return null; + + return ( +
+ {attachments.map((attachment) => ( + onRemoveAttachment(attachment.id)} + /> + ))} +
+ ); +} + +interface AttachmentPreviewProps { + attachment: AttachmentItem; + onRemove: () => void; +} + +function AttachmentPreview({ attachment, onRemove }: AttachmentPreviewProps) { + const [isHovered, setIsHovered] = useState(false); + + const renderPreview = () => { + switch (attachment.type) { + case "image": + return attachment.preview ? ( + {attachment.file.name} + ) : ( +
+ +
+ ); + + case "pdf": + return ( +
+ + PDF +
+ ); + + default: + return ( +
+ + FILE +
+ ); + } + }; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + title={attachment.file.name} + > + {renderPreview()} + + {/* Dark overlay with centered delete icon on hover */} +
+
+ +
+
+ + {/* File name tooltip */} +
+ {attachment.file.name} +
+
+ ); +} \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/ui/badge.tsx b/python/packages/devui/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000000..124fa26b45 --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge }; diff --git a/python/packages/devui/frontend/src/components/ui/button.tsx b/python/packages/devui/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000000..a2df8dce67 --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/python/packages/devui/frontend/src/components/ui/card.tsx b/python/packages/devui/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000000..027bfcd4d8 --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/python/packages/devui/frontend/src/components/ui/checkbox.tsx b/python/packages/devui/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000000..fa0e4b59fa --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/python/packages/devui/frontend/src/components/ui/dialog.tsx b/python/packages/devui/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000000..a2fb2866fb --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { X } from "lucide-react"; +import { Button } from "./button"; + +interface DialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; +} + +interface DialogContentProps { + children: React.ReactNode; + className?: string; +} + +interface DialogHeaderProps { + children: React.ReactNode; +} + +interface DialogTitleProps { + children: React.ReactNode; +} + +interface DialogFooterProps { + children: React.ReactNode; +} + +export function Dialog({ open, onOpenChange, children }: DialogProps) { + if (!open) return null; + + return ( +
onOpenChange(false)} + > + {/* Backdrop */} +
+ + {/* Modal content */} +
e.stopPropagation()}>{children}
+
+ ); +} + +export function DialogContent({ + children, + className = "", +}: DialogContentProps) { + return ( +
+ {children} +
+ ); +} + +export function DialogHeader({ children }: DialogHeaderProps) { + return ( +
+ {children} +
+ ); +} + +export function DialogTitle({ children }: DialogTitleProps) { + return

{children}

; +} + +export function DialogClose({ onClose }: { onClose: () => void }) { + return ( + + ); +} + +export function DialogFooter({ children }: DialogFooterProps) { + return ( +
+ {children} +
+ ); +} diff --git a/python/packages/devui/frontend/src/components/ui/dropdown-menu.tsx b/python/packages/devui/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000000..0d6741b4dc --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,255 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/python/packages/devui/frontend/src/components/ui/file-upload.tsx b/python/packages/devui/frontend/src/components/ui/file-upload.tsx new file mode 100644 index 0000000000..4f2d9af71a --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/file-upload.tsx @@ -0,0 +1,141 @@ +/** + * FileUpload - Upload button with drag & drop support + */ + +import { useRef } from "react"; +import { Upload } from "lucide-react"; +import { Button } from "./button"; + +interface FileUploadProps { + onFilesSelected: (files: File[]) => void; + accept?: string; + multiple?: boolean; + maxSize?: number; // in bytes + disabled?: boolean; + className?: string; +} + +export function FileUpload({ + onFilesSelected, + accept = "image/*,.pdf", + multiple = true, + maxSize = 50 * 1024 * 1024, // 50MB default for local dev tool + disabled = false, + className = "", +}: FileUploadProps) { + const fileInputRef = useRef(null); + + const handleFileSelect = (files: FileList | null) => { + if (!files || files.length === 0) return; + + const validFiles: File[] = []; + const errors: string[] = []; + + Array.from(files).forEach((file) => { + // Size validation + if (file.size > maxSize) { + errors.push(`${file.name} is too large (max ${formatFileSize(maxSize)})`); + return; + } + + // Type validation (basic) + if (accept && !isFileAccepted(file, accept)) { + errors.push(`${file.name} is not an accepted file type`); + return; + } + + validFiles.push(file); + }); + + if (errors.length > 0) { + console.warn("File upload errors:", errors); + // In a production app, you might want to show these errors to the user + } + + if (validFiles.length > 0) { + onFilesSelected(validFiles); + } + }; + + const handleButtonClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + const handleFileInputChange = (e: React.ChangeEvent) => { + handleFileSelect(e.target.files); + // Reset input to allow selecting the same file again + e.target.value = ""; + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (disabled) return; + + const files = e.dataTransfer.files; + handleFileSelect(files); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + return ( +
+ + + +
+ ); +} + +// Helper functions +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} + +function isFileAccepted(file: File, accept: string): boolean { + const acceptPatterns = accept.split(",").map((pattern) => pattern.trim()); + + return acceptPatterns.some((pattern) => { + if (pattern.startsWith(".")) { + // File extension check + return file.name.toLowerCase().endsWith(pattern.toLowerCase()); + } else if (pattern.includes("/*")) { + // MIME type wildcard check (e.g., "image/*") + const [mainType] = pattern.split("/"); + return file.type.startsWith(mainType + "/"); + } else { + // Exact MIME type check + return file.type === pattern; + } + }); +} \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/ui/input.tsx b/python/packages/devui/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000000..03295ca6ac --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/python/packages/devui/frontend/src/components/ui/label.tsx b/python/packages/devui/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000000..ef7133a756 --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/python/packages/devui/frontend/src/components/ui/loading-spinner.tsx b/python/packages/devui/frontend/src/components/ui/loading-spinner.tsx new file mode 100644 index 0000000000..00b2e65f02 --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/loading-spinner.tsx @@ -0,0 +1,23 @@ +import { Loader2 } from "lucide-react" +import { cn } from "@/lib/utils" + +interface LoadingSpinnerProps { + size?: "sm" | "md" | "lg" + className?: string +} + +export function LoadingSpinner({ size = "md", className }: LoadingSpinnerProps) { + return ( + + ) +} \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/ui/loading-state.tsx b/python/packages/devui/frontend/src/components/ui/loading-state.tsx new file mode 100644 index 0000000000..ac4303a981 --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/loading-state.tsx @@ -0,0 +1,52 @@ +import { LoadingSpinner } from "./loading-spinner" +import { cn } from "@/lib/utils" + +interface LoadingStateProps { + message?: string + description?: string + size?: "sm" | "md" | "lg" + className?: string + fullPage?: boolean +} + +export function LoadingState({ + message = "Loading...", + description, + size = "md", + className, + fullPage = false +}: LoadingStateProps) { + const content = ( +
+ +
+

+ {message} +

+ {description && ( +

+ {description} +

+ )} +
+
+ ) + + if (fullPage) { + return ( +
+ {content} +
+ ) + } + + return content +} \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/ui/scroll-area.tsx b/python/packages/devui/frontend/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000000..62515484b8 --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/scroll-area.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/ui/select.tsx b/python/packages/devui/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000000..51f466eccb --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/select.tsx @@ -0,0 +1,183 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/python/packages/devui/frontend/src/components/ui/tabs.tsx b/python/packages/devui/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000000..125b4bb119 --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } \ No newline at end of file diff --git a/python/packages/devui/frontend/src/components/ui/textarea.tsx b/python/packages/devui/frontend/src/components/ui/textarea.tsx new file mode 100644 index 0000000000..7f21b5e78a --- /dev/null +++ b/python/packages/devui/frontend/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +