Python: Add DevUI to AgentFramework (#781)

* add initial backend service code for devui

* add tests

* add frontendcode

* ui updates

* update readme

* ui updates and tweaks

* update ui bundle

* improve ui, add react flow base

* add react flow ui, fix background

* update ui, fix introspection bug

* update readme

* update ui build

* add support for multimodal input - both backend and frontend

* update ui build

* refactor as main framework package

* backend and tests refactor

* ui build update

* ui build update and refactor

* update pyproject.toml, update uv.lock

* update ui build

* ui update to fit oai responses types

* add backend updat and readme update

* mypy and other fixes

* add intial dev guide

* update ui and fix workflow bug

* update ui build, add thread support

* type fixes

* update workflow view

* update uv.lock

* fix workflow iport errors

* lint and other fixes

* mypy fixes

* minor update

* update ui build

* refactor to use oai dependencies directly, update examples to samples, improve typing

* readme update

* update ui and ui build

* fix workflow pyright error

* update ui, fix issues with run workflow placement, miniamp menu, etc

* make samples integrate serve

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
Victor Dibia
2025-09-22 23:30:08 +00:00
committed by GitHub
co-authored by Chris Eric Zhu
parent adb6dcd2af
commit 1ef24d3e91
98 changed files with 18045 additions and 4 deletions
+19
View File
@@ -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
+21
View File
@@ -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
+140
View File
@@ -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
@@ -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",
]
@@ -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()
@@ -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]}"
@@ -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
@@ -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=[],
)
@@ -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
@@ -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")
@@ -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
@@ -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",
]
@@ -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)
@@ -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",
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Framework Dev UI</title>
<script type="module" crossorigin src="/assets/index-BESRiUNX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BZfe_njJ.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+89
View File
@@ -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!
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

+22
View File
@@ -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*
+69
View File
@@ -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...
},
},
])
```
@@ -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"
}
@@ -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,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Framework Dev UI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -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"
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -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;
}
+312
View File
@@ -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<AppState>({
agents: [],
workflows: [],
isLoading: true,
});
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>(
[]
);
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 (
<div className="h-screen flex flex-col bg-background">
{/* Top Bar - Skeleton */}
<header className="flex h-14 items-center gap-4 border-b px-4">
<div className="w-64 h-9 bg-muted animate-pulse rounded-md" />
<div className="flex items-center gap-2 ml-auto">
<div className="w-8 h-8 bg-muted animate-pulse rounded-md" />
<div className="w-8 h-8 bg-muted animate-pulse rounded-md" />
</div>
</header>
{/* Loading Content */}
<LoadingState
message="Initializing DevUI..."
description="Loading agents and workflows from your configuration"
fullPage={true}
/>
</div>
);
}
// Show error state if loading failed
if (appState.error) {
return (
<div className="h-screen flex flex-col bg-background">
<AppHeader
agents={[]}
workflows={[]}
selectedItem={undefined}
onSelect={() => {}}
isLoading={false}
/>
{/* Error Content */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4 max-w-md">
<div className="text-destructive text-lg font-medium">
Failed to load entities
</div>
<p className="text-muted-foreground text-sm">{appState.error}</p>
<Button onClick={() => window.location.reload()} variant="outline">
Retry
</Button>
</div>
</div>
</div>
);
}
// Show empty state if no agents or workflows are available
if (
!appState.isLoading &&
appState.agents.length === 0 &&
appState.workflows.length === 0
) {
return (
<div className="h-screen flex flex-col bg-background">
<AppHeader
agents={[]}
workflows={[]}
selectedItem={undefined}
onSelect={() => {}}
isLoading={false}
/>
{/* Empty State Content */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4 max-w-md">
<div className="text-lg font-medium">No entities configured</div>
<p className="text-muted-foreground text-sm">
No agents or workflows were found in your configuration. Please
check your setup and ensure entities are properly configured.
</p>
<Button onClick={() => window.location.reload()} variant="outline">
Retry
</Button>
</div>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col bg-background max-h-screen">
<AppHeader
agents={appState.agents}
workflows={appState.workflows}
selectedItem={appState.selectedAgent}
onSelect={handleEntitySelect}
isLoading={appState.isLoading}
onSettingsClick={() => setShowAboutModal(true)}
/>
{/* Main Content - Split Panel */}
<div className="flex flex-1 overflow-hidden">
{/* Left Panel - Main View */}
<div className="flex-1 min-w-0">
{appState.selectedAgent ? (
appState.selectedAgent.type === "agent" ? (
<AgentView
selectedAgent={appState.selectedAgent as AgentInfo}
onDebugEvent={handleDebugEvent}
/>
) : (
<WorkflowView
selectedWorkflow={appState.selectedAgent as WorkflowInfo}
onDebugEvent={handleDebugEvent}
/>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select an agent or workflow to get started.
</div>
)}
</div>
{/* Resize Handle */}
{debugPanelOpen && (
<div
className={`w-1 cursor-col-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
}`}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
>
<div className="absolute inset-y-0 -left-2 -right-2 flex items-center justify-center">
<div
className={`h-12 w-1 rounded-full transition-all duration-200 ease-in-out ${
isResizing
? "bg-primary shadow-lg shadow-primary/25"
: "bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20"
}`}
></div>
</div>
</div>
)}
{/* Button to reopen when closed */}
{!debugPanelOpen && (
<div className="flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setDebugPanelOpen(true)}
className="h-full w-8 rounded-none border-l"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
)}
{/* Right Panel - Debug */}
{debugPanelOpen && (
<div
className="flex-shrink-0"
style={{ width: `${debugPanelWidth}px` }}
>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
/>
</div>
)}
</div>
{/* About Modal */}
<AboutModal
open={showAboutModal}
onOpenChange={setShowAboutModal}
/>
</div>
);
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -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 (
<div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
<div
className={`flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border ${
isUser
? "bg-primary text-primary-foreground"
: isError
? "bg-orange-100 dark:bg-orange-900 text-orange-600 dark:text-orange-400 border-orange-200 dark:border-orange-800"
: "bg-muted"
}`}
>
<Icon className="h-4 w-4" />
</div>
<div
className={`flex flex-col space-y-1 ${
isUser ? "items-end" : "items-start"
} max-w-[80%]`}
>
<div
className={`rounded px-3 py-2 text-sm break-all ${
isUser
? "bg-primary text-primary-foreground"
: isError
? "bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800"
: "bg-muted"
}`}
>
{isError && (
<div className="flex items-start gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="font-medium text-sm">
Unable to process request
</span>
</div>
)}
<div className={isError ? "text-xs leading-relaxed break-all" : ""}>
<MessageRenderer
contents={message.contents}
isStreaming={message.streaming}
/>
</div>
</div>
<div className="text-xs text-muted-foreground font-mono">
{new Date(message.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
);
}
function TypingIndicator() {
return (
<div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-muted">
<Bot className="h-4 w-4" />
</div>
<div className="flex items-center space-x-1 rounded bg-muted px-3 py-2">
<div className="flex space-x-1">
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
</div>
</div>
</div>
);
}
export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const [chatState, setChatState] = useState<ChatState>({
messages: [],
isStreaming: false,
});
const [currentThread, setCurrentThread] = useState<ThreadInfo | undefined>(
undefined
);
const [availableThreads, setAvailableThreads] = useState<ThreadInfo[]>([]);
const [inputValue, setInputValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [loadingThreads, setLoadingThreads] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const accumulatedText = useRef<string>("");
// 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<string> => {
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 (
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
{/* Header */}
<div className="border-b pb-2 p-4 flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-sm">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Chat with {selectedAgent.name || selectedAgent.id}
</div>
</h2>
{/* Thread Controls */}
<div className="flex items-center gap-2">
<Select
value={currentThread?.id || ""}
onValueChange={handleThreadSelect}
disabled={loadingThreads || isSubmitting}
>
<SelectTrigger className="w-48">
<SelectValue
placeholder={
loadingThreads
? "Loading..."
: availableThreads.length === 0
? "No threads"
: currentThread
? `Thread ${currentThread.id.slice(-8)}`
: "Select thread"
}
/>
</SelectTrigger>
<SelectContent>
{availableThreads.map((thread) => (
<SelectItem key={thread.id} value={thread.id}>
<div className="flex items-center justify-between w-full">
<span>Thread {thread.id.slice(-8)}</span>
{thread.created_at && (
<span className="text-xs text-muted-foreground ml-3">
{new Date(thread.created_at).toLocaleDateString()}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="lg"
onClick={handleNewThread}
disabled={!selectedAgent || isSubmitting}
>
<Plus className="h-4 w-4 mr-2" />
New Thread
</Button>
</div>
</div>
{selectedAgent.description && (
<p className="text-sm text-muted-foreground">
{selectedAgent.description}
</p>
)}
</div>
{/* Messages */}
<ScrollArea className="flex-1 p-4 h-0" ref={scrollAreaRef}>
<div className="space-y-4">
{chatState.messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
<div className="text-muted-foreground text-sm">
Start a conversation with{" "}
{selectedAgent.name || selectedAgent.id}
</div>
<div className="text-xs text-muted-foreground mt-1">
Type a message below to begin
</div>
</div>
) : (
chatState.messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))
)}
{chatState.isStreaming && !isSubmitting && <TypingIndicator />}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{/* Input */}
<div className="border-t flex-shrink-0">
<div
className={`p-4 relative transition-all duration-300 ease-in-out ${
isDragOver ? "bg-blue-50 dark:bg-blue-950/20" : ""
}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDragOver && (
<div className="absolute inset-2 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg bg-blue-50/80 dark:bg-blue-950/40 backdrop-blur-sm flex items-center justify-center transition-all duration-200 ease-in-out z-10">
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-sm font-medium mb-1">
Drop files here
</div>
<div className="text-blue-500 dark:text-blue-500 text-xs">
Images, PDFs, and other files
</div>
</div>
</div>
)}
{/* Attachment gallery */}
{attachments.length > 0 && (
<div className="mb-3">
<AttachmentGallery
attachments={attachments}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
)}
{/* Input form */}
<form onSubmit={handleSubmit} className="flex gap-2">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={`Message ${
selectedAgent.name || selectedAgent.id
}...`}
disabled={isSubmitting || chatState.isStreaming}
className="flex-1"
/>
<FileUpload
onFilesSelected={handleFilesSelected}
disabled={isSubmitting || chatState.isStreaming}
/>
<Button
type="submit"
size="icon"
disabled={!canSendMessage}
className="shrink-0"
>
{isSubmitting ? (
<LoadingSpinner size="sm" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</form>
</div>
</div>
</div>
);
}
@@ -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 (
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
{content.text}
{isStreaming && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
);
}
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 (
<div className={`my-2 ${className || ""}`}>
<img
src={dataUri}
alt="Uploaded image"
className={`rounded-lg border max-w-full transition-all cursor-pointer ${
isExpanded ? "max-h-none" : "max-h-64"
}`}
onClick={() => setIsExpanded(!isExpanded)}
onError={() => setImageError(true)}
/>
<div className="text-xs text-muted-foreground mt-1">
{mediaType} Click to {isExpanded ? "collapse" : "expand"}
</div>
</div>
);
}
// Fallback for non-images or failed images
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center gap-2">
{isPdf ? (
<FileText className="h-4 w-4 text-red-500" />
) : (
<Download className="h-4 w-4" />
)}
<span className="text-sm font-medium">
{isPdf ? "PDF Document" : "File Attachment"}
</span>
<span className="text-xs text-muted-foreground">({mediaType})</span>
</div>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => {
const link = document.createElement("a");
link.href = dataUri;
link.download = `attachment.${mediaType.split("/")[1] || "bin"}`;
link.click();
}}
>
<Download className="h-3 w-3 mr-1" />
Download
</Button>
</div>
);
}
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 (
<div className={`my-2 p-3 border rounded-lg bg-blue-50 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800">
Function Call: {content.name}
</span>
<span className="text-xs text-blue-600">
{isExpanded ? "▼" : "▶"}
</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
<div className="text-blue-600 mb-1">Arguments:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedArgs, null, 2)}
</pre>
</div>
)}
</div>
);
}
function FunctionResultRenderer({ content, className }: RenderProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (!isFunctionResultContent(content)) return null;
return (
<div className={`my-2 p-3 border rounded-lg bg-green-50 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-800">
Function Result
</span>
<span className="text-xs text-green-600">
{isExpanded ? "▼" : "▶"}
</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
<pre className="whitespace-pre-wrap">
{typeof content.result === "string"
? content.result
: JSON.stringify(content.result, null, 2)}
</pre>
</div>
)}
</div>
);
}
function ErrorContentRenderer({ content, className }: RenderProps) {
if (content.type !== "error") return null;
return (
<div className={`my-2 p-3 border rounded-lg bg-red-50 ${className || ""}`}>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-500" />
<span className="text-sm font-medium text-red-800">Error</span>
{content.error_code && (
<span className="text-xs text-red-600">({content.error_code})</span>
)}
</div>
<div className="mt-1 text-sm text-red-700">{content.error}</div>
</div>
);
}
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 (
<div className={`my-2 ${className || ""}`}>
<img
src={content.uri}
alt="Referenced image"
className="rounded-lg border max-w-full max-h-64"
onError={() => setImageError(true)}
/>
<div className="text-xs text-muted-foreground mt-1">
<a
href={content.uri}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{content.uri}
</a>
</div>
</div>
);
}
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
<a
href={content.uri}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium hover:underline"
>
{content.media_type || "External Link"}
</a>
</div>
<div className="text-xs text-muted-foreground mt-1 break-all">
{content.uri}
</div>
</div>
);
}
export function ContentRenderer({ content, isStreaming, className }: RenderProps) {
switch (content.type) {
case "text":
return (
<TextContentRenderer
content={content}
isStreaming={isStreaming}
className={className}
/>
);
case "data":
return <DataContentRenderer content={content} className={className} />;
case "uri":
return <UriContentRenderer content={content} className={className} />;
case "function_call":
return (
<FunctionCallRenderer content={content} className={className} />
);
case "function_result":
return (
<FunctionResultRenderer content={content} className={className} />
);
case "error":
return <ErrorContentRenderer content={content} className={className} />;
default:
// Fallback for unsupported content types
return (
<div className={`my-2 p-2 bg-gray-100 rounded text-xs ${className || ""}`}>
<div>Unsupported content type: {content.type}</div>
<pre className="mt-1 text-xs whitespace-pre-wrap">
{JSON.stringify(content, null, 2)}
</pre>
</div>
);
}
}
@@ -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 (
<div className={className}>
{contents.map((content, index) => (
<ContentRenderer
key={index}
content={content}
isStreaming={false}
className={index > 0 ? "mt-2" : ""}
/>
))}
</div>
);
}
// For streaming, use the streaming renderer for smart accumulation
return (
<StreamingRenderer
contents={contents}
isStreaming={isStreaming}
className={className}
/>
);
}
@@ -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<MessageRenderState>({
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 (
<div className={className}>
{/* Render accumulated text with streaming indicator */}
{hasTextContent && (
<div className="whitespace-pre-wrap break-words">
{renderState.textAccumulator}
{isStreaming && hasTextContent && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
)}
{/* Render other content types when complete or non-data items immediately */}
{hasOtherContent && (
<div className="mt-2 space-y-2">
{renderState.errors.map((content, index) => (
<ContentRenderer key={`error-${index}`} content={content} />
))}
{renderState.functionCalls.map((content, index) => (
<ContentRenderer key={`function-${index}`} content={content} />
))}
{renderState.dataContentItems.map((content, index) => (
<ContentRenderer
key={`data-${index}`}
content={content}
isStreaming={isStreaming}
/>
))}
</div>
)}
{/* Show loading indicator when streaming and no text content yet */}
{isStreaming && !hasTextContent && !hasOtherContent && (
<div className="flex items-center space-x-1">
<div className="flex space-x-1">
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
</div>
</div>
)}
</div>
);
}
@@ -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";
@@ -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;
}
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>About DevUI</DialogTitle>
<DialogClose onClose={() => onOpenChange(false)} />
</DialogHeader>
<div className="p-4 space-y-4">
<p className="text-sm text-muted-foreground">
DevUI is a sample app for getting started with Agent Framework.
</p>
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
"https://github.com/microsoft/agent-framework",
"_blank"
)
}
className="text-xs"
>
<ExternalLink className="h-3 w-3 mr-1" />
Learn More about Agent Framework
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -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 (
<header className="flex h-14 items-center gap-4 border-b px-4">
<div className="font-semibold">Dev UI</div>
<EntitySelector
agents={agents}
workflows={workflows}
selectedItem={selectedItem}
onSelect={onSelect}
isLoading={isLoading}
/>
<div className="flex items-center gap-2 ml-auto">
<ModeToggle />
<Button variant="ghost" size="sm" onClick={onSettingsClick}>
<Settings className="h-4 w-4" />
</Button>
</div>
</header>
);
}
File diff suppressed because it is too large Load Diff
@@ -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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-64 justify-between font-mono text-sm"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<LoadingSpinner size="sm" />
<span className="text-muted-foreground">Loading...</span>
</div>
) : (
<>
<div className="flex items-center gap-2 min-w-0">
<TypeIcon className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{displayName}</span>
{selectedItem && (
<Badge variant="secondary" className="ml-auto flex-shrink-0">
{itemCount} {itemLabel}
</Badge>
)}
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80 font-mono">
{agents.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Agents ({agents.length})
</DropdownMenuLabel>
{agents.map((agent) => {
const SourceIcon = getSourceIcon(agent.source);
return (
<DropdownMenuItem
key={agent.id}
onClick={() => handleSelect(agent)}
className="cursor-pointer"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 min-w-0">
<Bot className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium">
{agent.name || agent.id}
</div>
{agent.description && (
<div className="text-xs text-muted-foreground truncate">
{agent.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<SourceIcon className="h-3 w-3 opacity-60" />
<Badge variant="outline" className="text-xs">
{agent.tools.length}
</Badge>
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{workflows.length > 0 && (
<>
{agents.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflows.length})
</DropdownMenuLabel>
{workflows.map((workflow) => {
const SourceIcon = getSourceIcon(workflow.source);
return (
<DropdownMenuItem
key={workflow.id}
onClick={() => handleSelect(workflow)}
className="cursor-pointer"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 min-w-0">
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium">
{workflow.name || workflow.id}
</div>
{workflow.description && (
<div className="text-xs text-muted-foreground truncate">
{workflow.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<SourceIcon className="h-3 w-3 opacity-60" />
<Badge variant="outline" className="text-xs">
{workflow.executors.length}
</Badge>
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{allItems.length === 0 && (
<DropdownMenuItem disabled>
<div className="text-center text-muted-foreground py-2">
{isLoading ? "Loading entities..." : "No entities found"}
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -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 (
<NextThemesProvider
attribute={attribute}
defaultTheme={defaultTheme}
enableSystem={enableSystem}
disableTransitionOnChange={disableTransitionOnChange}
{...props}
>
{children}
</NextThemesProvider>
)
}
@@ -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 (
<div className={`flex flex-wrap gap-2 p-2 bg-muted rounded-lg ${className}`}>
{attachments.map((attachment) => (
<AttachmentPreview
key={attachment.id}
attachment={attachment}
onRemove={() => onRemoveAttachment(attachment.id)}
/>
))}
</div>
);
}
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 ? (
<img
src={attachment.preview}
alt={attachment.file.name}
className="w-full h-full object-cover"
/>
) : (
<div className="flex items-center justify-center w-full h-full bg-gray-200">
<Image className="h-6 w-6 text-gray-400" />
</div>
);
case "pdf":
return (
<div className="flex flex-col items-center justify-center w-full h-full bg-red-50">
<FileText className="h-6 w-6 text-red-500 mb-1" />
<span className="text-xs text-red-600">PDF</span>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center w-full h-full bg-gray-100">
<FileText className="h-6 w-6 text-gray-500 mb-1" />
<span className="text-xs text-gray-600">FILE</span>
</div>
);
}
};
return (
<div
className="relative w-16 h-16 rounded border overflow-hidden group cursor-pointer"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
title={attachment.file.name}
>
{renderPreview()}
{/* Dark overlay with centered delete icon on hover */}
<div
className={`absolute inset-0 bg-black/60 flex items-center justify-center transition-all duration-200 ease-in-out ${
isHovered
? 'opacity-100 backdrop-blur-sm'
: 'opacity-0 pointer-events-none'
}`}
onClick={onRemove}
>
<div className={`transition-all duration-200 ease-in-out ${
isHovered
? 'scale-100 opacity-100'
: 'scale-75 opacity-0'
}`}>
<Trash2 className="h-5 w-5 text-white drop-shadow-lg" />
</div>
</div>
{/* File name tooltip */}
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-75 text-white text-xs p-1 truncate opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{attachment.file.name}
</div>
</div>
);
}
@@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge };
@@ -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<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -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<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={() => onOpenChange(false)}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50" />
{/* Modal content */}
<div onClick={(e) => e.stopPropagation()}>{children}</div>
</div>
);
}
export function DialogContent({
children,
className = "",
}: DialogContentProps) {
return (
<div
className={`relative bg-background border rounded-lg shadow-lg max-w-lg w-full max-h-[90vh] overflow-hidden ${className}`}
>
{children}
</div>
);
}
export function DialogHeader({ children }: DialogHeaderProps) {
return (
<div className="flex items-center justify-between p-4 border-b">
{children}
</div>
);
}
export function DialogTitle({ children }: DialogTitleProps) {
return <h2 className="text-lg font-semibold">{children}</h2>;
}
export function DialogClose({ onClose }: { onClose: () => void }) {
return (
<Button variant="ghost" size="sm" onClick={onClose} className="h-6 w-6 p-0">
<X className="h-4 w-4" />
</Button>
);
}
export function DialogFooter({ children }: DialogFooterProps) {
return (
<div className="flex justify-end gap-2 p-4 border-t bg-muted/50">
{children}
</div>
);
}
@@ -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<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className={className}>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileInputChange}
className="hidden"
disabled={disabled}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleButtonClick}
disabled={disabled}
onDrop={handleDrop}
onDragOver={handleDragOver}
className="shrink-0 transition-colors hover:bg-muted"
title="Upload files (images, PDFs)"
>
<Upload className="h-4 w-4" />
</Button>
</div>
);
}
// 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;
}
});
}
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"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",
className
)}
{...props}
/>
)
}
export { Input }
@@ -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<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -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 (
<Loader2
className={cn(
"animate-spin",
{
"h-4 w-4": size === "sm",
"h-6 w-6": size === "md",
"h-8 w-8": size === "lg",
},
className
)}
/>
)
}
@@ -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 = (
<div className={cn(
"flex flex-col items-center justify-center gap-3",
fullPage ? "min-h-[50vh]" : "py-8",
className
)}>
<LoadingSpinner size={size} className="text-muted-foreground" />
<div className="text-center space-y-1">
<p className={cn(
"font-medium text-muted-foreground",
size === "sm" && "text-sm",
size === "lg" && "text-lg"
)}>
{message}
</p>
{description && (
<p className="text-sm text-muted-foreground/80">
{description}
</p>
)}
</div>
</div>
)
if (fullPage) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
{content}
</div>
)
}
return content
}
@@ -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<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
@@ -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<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -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<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,277 @@
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
CheckCircle,
XCircle,
Clock,
Loader2,
AlertCircle,
Play,
Flag,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type ExecutorState =
| "pending"
| "running"
| "completed"
| "failed"
| "cancelled";
export interface ExecutorNodeData extends Record<string, unknown> {
executorId: string;
executorType?: string;
name?: string;
state: ExecutorState;
inputData?: unknown;
outputData?: unknown;
error?: string;
isSelected?: boolean;
isStartNode?: boolean;
isEndNode?: boolean;
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
}
const getExecutorStateConfig = (state: ExecutorState) => {
switch (state) {
case "running":
return {
icon: Loader2,
text: "Running",
borderColor: "border-blue-500 dark:border-blue-400",
iconColor: "text-blue-600 dark:text-blue-400",
statusColor: "bg-blue-500 dark:bg-blue-400",
animate: "animate-spin",
glow: "shadow-lg shadow-blue-500/20",
};
case "completed":
return {
icon: CheckCircle,
text: "Completed",
borderColor: "border-green-500 dark:border-green-400",
iconColor: "text-green-600 dark:text-green-400",
statusColor: "bg-green-500 dark:bg-green-400",
animate: "",
glow: "shadow-lg shadow-green-500/20",
};
case "failed":
return {
icon: XCircle,
text: "Failed",
borderColor: "border-red-500 dark:border-red-400",
iconColor: "text-red-600 dark:text-red-400",
statusColor: "bg-red-500 dark:bg-red-400",
animate: "",
glow: "shadow-lg shadow-red-500/20",
};
case "cancelled":
return {
icon: AlertCircle,
text: "Cancelled",
borderColor: "border-orange-500 dark:border-orange-400",
iconColor: "text-orange-600 dark:text-orange-400",
statusColor: "bg-orange-500 dark:bg-orange-400",
animate: "",
glow: "shadow-lg shadow-orange-500/20",
};
case "pending":
default:
return {
icon: Clock,
text: "Pending",
borderColor: "border-gray-300 dark:border-gray-600",
iconColor: "text-gray-500 dark:text-gray-400",
statusColor: "bg-gray-400 dark:bg-gray-500",
animate: "",
glow: "",
};
}
};
export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const nodeData = data as ExecutorNodeData;
const config = getExecutorStateConfig(nodeData.state);
const IconComponent = config.icon;
const hasData = nodeData.inputData || nodeData.outputData || nodeData.error;
const isRunning = nodeData.state === "running";
// Helper to safely render data with full details
const renderDataDetails = () => {
const details = [];
if (nodeData.error && typeof nodeData.error === "string") {
details.push(
<div key="error" className="mb-2">
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">Error:</div>
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800">
{nodeData.error}
</div>
</div>
);
}
if (nodeData.outputData) {
try {
const outputStr =
typeof nodeData.outputData === "string"
? nodeData.outputData
: JSON.stringify(nodeData.outputData, null, 2);
details.push(
<div key="output" className="mb-2">
<div className="text-xs font-medium text-green-600 dark:text-green-400 mb-1">Output:</div>
<div className="text-xs text-gray-700 dark:text-gray-300 bg-green-50 dark:bg-green-950/20 p-2 rounded border border-green-200 dark:border-green-800 max-h-20 overflow-auto">
<pre className="whitespace-pre-wrap font-mono">{outputStr}</pre>
</div>
</div>
);
} catch {
details.push(
<div key="output" className="mb-2">
<div className="text-xs font-medium text-green-600 dark:text-green-400 mb-1">Output:</div>
<div className="text-xs text-gray-600 dark:text-gray-400 bg-green-50 dark:bg-green-950/20 p-2 rounded border border-green-200 dark:border-green-800">
[Unable to display output data]
</div>
</div>
);
}
}
if (nodeData.inputData) {
try {
const inputStr =
typeof nodeData.inputData === "string"
? nodeData.inputData
: JSON.stringify(nodeData.inputData, null, 2);
details.push(
<div key="input" className="mb-2">
<div className="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1">Input:</div>
<div className="text-xs text-gray-700 dark:text-gray-300 bg-blue-50 dark:bg-blue-950/20 p-2 rounded border border-blue-200 dark:border-blue-800 max-h-20 overflow-auto">
<pre className="whitespace-pre-wrap font-mono">{inputStr}</pre>
</div>
</div>
);
} catch {
details.push(
<div key="input" className="mb-2">
<div className="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1">Input:</div>
<div className="text-xs text-gray-600 dark:text-gray-400 bg-blue-50 dark:bg-blue-950/20 p-2 rounded border border-blue-200 dark:border-blue-800">
[Unable to display input data]
</div>
</div>
);
}
}
return details.length > 0 ? details : null;
};
return (
<div
className={cn(
"group relative w-64 bg-card dark:bg-card rounded border-2 transition-all duration-200",
config.borderColor,
selected ? "ring-2 ring-blue-500 ring-offset-2" : "",
isRunning ? config.glow : "shadow-sm",
)}
>
{/* Start/End Badge */}
{(nodeData.isStartNode || nodeData.isEndNode) && (
<div className={cn(
"absolute -top-6 left-2 px-2 py-1 rounded-t text-xs font-medium text-white flex items-center gap-1 z-10 shadow-sm",
nodeData.isStartNode ? "bg-green-600" : "bg-red-600"
)}>
{nodeData.isStartNode ? (
<>
<Play className="w-3 h-3" />
START
</>
) : (
<>
<Flag className="w-3 h-3" />
END
</>
)}
</div>
)}
{/* Only show target handle if not a start node */}
{!nodeData.isStartNode && (
<Handle
type="target"
position={Position.Left}
className="!w-2 !h-5 !rounded-r-sm !-ml-1 !border-0 transition-colors"
style={{
backgroundColor: nodeData.state === "running" ? "#3b82f6" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#9ca3af"
}}
/>
)}
{/* Only show source handle if not an end node */}
{!nodeData.isEndNode && (
<Handle
type="source"
position={Position.Right}
className="!w-2 !h-5 !rounded-l-sm !-mr-1 !border-0 transition-colors"
style={{
backgroundColor: nodeData.state === "running" ? "#3b82f6" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#9ca3af"
}}
/>
)}
<div className="p-4">
{/* Header with icon and title */}
<div className="flex items-start gap-3 mb-3">
<div className="flex-shrink-0 mt-0.5">
<IconComponent
className={cn("w-5 h-5", config.iconColor, config.animate)}
/>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{nodeData.name || nodeData.executorId}
</h3>
{nodeData.executorType && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{nodeData.executorType}
</p>
)}
</div>
</div>
{/* State indicator */}
<div className="flex items-center gap-2 mb-2">
<div
className={cn(
"w-2 h-2 rounded-full",
config.statusColor,
config.animate
)}
/>
<span className={cn("text-xs font-medium", config.iconColor)}>
{config.text}
</span>
</div>
{/* Data details */}
{hasData && (
<div className="mt-3">
{renderDataDetails()}
</div>
)}
{/* Running animation overlay */}
{isRunning && (
<div className="absolute inset-0 rounded border-2 border-blue-500/30 dark:border-blue-400/30 animate-pulse pointer-events-none" />
)}
</div>
</div>
);
});
ExecutorNode.displayName = "ExecutorNode";
@@ -0,0 +1,463 @@
import { useMemo, useCallback, useEffect } from "react";
import {
MoreVertical,
Map,
Grid3X3,
RotateCcw,
Maximize,
Shuffle,
Zap,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
useReactFlow,
BackgroundVariant,
type NodeTypes,
type Node,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { ExecutorNode, type ExecutorNodeData } from "./executor-node";
import {
convertWorkflowDumpToNodes,
convertWorkflowDumpToEdges,
applyDagreLayout,
processWorkflowEvents,
updateNodesWithEvents,
updateEdgesWithSequenceAnalysis,
type NodeUpdate,
} from "@/utils/workflow-utils";
import type { ExtendedResponseStreamEvent } from "@/types";
import type { Workflow } from "@/types/workflow";
const nodeTypes: NodeTypes = {
executor: ExecutorNode,
};
// ViewOptions panel component that renders inside ReactFlow
function ViewOptionsPanel({
workflowDump,
onNodeSelect,
viewOptions,
onToggleViewOption,
}: {
workflowDump?: Workflow;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
}) {
const { fitView, setViewport, setNodes } = useReactFlow();
const handleResetZoom = () => {
setViewport({ x: 0, y: 0, zoom: 1 });
};
const handleFitToScreen = () => {
fitView({ padding: 0.2 });
};
const handleAutoArrange = () => {
if (!workflowDump) return;
const currentNodes = convertWorkflowDumpToNodes(workflowDump, onNodeSelect);
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
const layoutedNodes = applyDagreLayout(currentNodes, currentEdges, "LR");
setNodes(layoutedNodes);
};
return (
<div className="absolute top-4 right-4 z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0 bg-white/90 backdrop-blur-sm border-gray-200 shadow-sm hover:bg-white dark:bg-gray-800/90 dark:border-gray-600 dark:hover:bg-gray-800"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">View options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("showMinimap")}
>
<div className="flex items-center">
<Map className="mr-2 h-4 w-4" />
Show Minimap
</div>
<Checkbox checked={viewOptions.showMinimap} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("showGrid")}
>
<div className="flex items-center">
<Grid3X3 className="mr-2 h-4 w-4" />
Show Grid
</div>
<Checkbox checked={viewOptions.showGrid} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("animateRun")}
>
<div className="flex items-center">
<Zap className="mr-2 h-4 w-4" />
Animate Run
</div>
<Checkbox checked={viewOptions.animateRun} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleResetZoom}>
<RotateCcw className="mr-2 h-4 w-4" />
Reset Zoom
</DropdownMenuItem>
<DropdownMenuItem onClick={handleFitToScreen}>
<Maximize className="mr-2 h-4 w-4" />
Fit to Screen
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAutoArrange}>
<Shuffle className="mr-2 h-4 w-4" />
Auto-arrange
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
interface WorkflowFlowProps {
workflowDump?: Workflow;
events: ExtendedResponseStreamEvent[];
isStreaming: boolean;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
className?: string;
viewOptions?: {
showMinimap: boolean;
showGrid: boolean;
animateRun: boolean;
};
onToggleViewOption?: (
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
) => void;
}
// Animation handler component that runs inside ReactFlow context
function WorkflowAnimationHandler({
nodes,
nodeUpdates,
isStreaming,
animateRun,
}: {
nodes: Node<ExecutorNodeData>[];
nodeUpdates: Record<string, NodeUpdate>;
isStreaming: boolean;
animateRun: boolean;
}) {
const { fitView } = useReactFlow();
// Smooth animation to center on running node when workflow starts/progresses
useEffect(() => {
if (!animateRun) return;
if (isStreaming) {
// Zoom in on running nodes during execution
const runningNodes = nodes.filter(
(node) => node.data.state === "running"
);
if (runningNodes.length > 0) {
const targetNode = runningNodes[0];
// Use fitView to smoothly focus on the running node with animation
fitView({
nodes: [targetNode],
duration: 800,
padding: 0.3,
minZoom: 0.8,
maxZoom: 1.5,
});
}
} else if (nodes.length > 0) {
// Zoom back out to show full workflow when execution completes
fitView({
duration: 1000,
padding: 0.2,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeUpdates, isStreaming, animateRun, nodes]);
return null; // This component doesn't render anything
}
export function WorkflowFlow({
workflowDump,
events,
isStreaming,
onNodeSelect,
className = "",
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
onToggleViewOption,
}: WorkflowFlowProps) {
// Create initial nodes and edges from workflow dump
const { initialNodes, initialEdges } = useMemo(() => {
if (!workflowDump) {
return { initialNodes: [], initialEdges: [] };
}
const nodes = convertWorkflowDumpToNodes(workflowDump, onNodeSelect);
const edges = convertWorkflowDumpToEdges(workflowDump);
// Apply auto-layout if we have nodes and edges
const layoutedNodes =
nodes.length > 0 ? applyDagreLayout(nodes, edges, "LR") : nodes;
return {
initialNodes: layoutedNodes,
initialEdges: edges,
};
}, [workflowDump, onNodeSelect]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<ExecutorNodeData>>(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
// Process events and update node/edge states
const nodeUpdates = useMemo(() => {
return processWorkflowEvents(events);
}, [events]);
// Update nodes and edges with real-time state from events
useMemo(() => {
if (Object.keys(nodeUpdates).length > 0) {
setNodes((currentNodes) =>
updateNodesWithEvents(currentNodes, nodeUpdates)
);
} else if (events.length === 0) {
// Reset all nodes to pending state when events are cleared
setNodes((currentNodes) =>
currentNodes.map((node) => ({
...node,
data: {
...node.data,
state: "pending" as const,
outputData: undefined,
error: undefined,
},
}))
);
}
}, [nodeUpdates, setNodes, events.length]);
// Update edges with sequence-based analysis (separate from nodeUpdates)
useMemo(() => {
if (events.length > 0) {
setEdges((currentEdges) => {
const updatedEdges = updateEdgesWithSequenceAnalysis(
currentEdges,
events
);
return updatedEdges;
});
} else {
// Reset all edges to default state when events are cleared
setEdges((currentEdges) =>
currentEdges.map((edge) => ({
...edge,
animated: false,
style: {
stroke: "#6b7280", // Gray
strokeWidth: 2,
},
}))
);
}
}, [events, setEdges]);
// Initialize nodes only when workflow structure changes (not on state updates)
useEffect(() => {
if (initialNodes.length > 0) {
setNodes(initialNodes);
setEdges(initialEdges);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowDump]); // Only re-initialize when workflowDump changes
const onNodeClick = useCallback(
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
event.stopPropagation();
onNodeSelect?.(node.data.executorId, node.data);
},
[onNodeSelect]
);
if (!workflowDump) {
return (
<div
className={`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${className}`}
>
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="text-lg font-medium mb-2">No Workflow Data</div>
<div className="text-sm">Workflow dump is not available.</div>
</div>
</div>
);
}
if (initialNodes.length === 0) {
return (
<div
className={`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${className}`}
>
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="text-lg font-medium mb-2">No Executors Found</div>
<div className="text-sm">
Could not extract executors from workflow dump.
</div>
<details className="mt-2 text-xs">
<summary className="cursor-pointer">Debug Info</summary>
<pre className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-left overflow-auto">
{JSON.stringify(workflowDump, null, 2)}
</pre>
</details>
</div>
</div>
);
}
return (
<div className={`h-full w-full ${className}`}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={1.5}
defaultEdgeOptions={{
type: "default",
animated: false,
style: { stroke: "#6b7280", strokeWidth: 2 },
}}
nodesDraggable={!isStreaming} // Disable dragging during execution
nodesConnectable={false} // Disable connecting nodes
elementsSelectable={true}
proOptions={{ hideAttribution: true }}
>
{viewOptions.showGrid && (
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#e5e7eb"
className="dark:opacity-30"
/>
)}
<Controls
position="bottom-left"
showInteractive={false}
style={{
backgroundColor: "rgba(255, 255, 255, 0.9)",
border: "1px solid #e5e7eb",
borderRadius: "3px",
}}
className="dark:!bg-gray-800/90 dark:!border-gray-600"
/>
{viewOptions.showMinimap && (
<MiniMap
nodeColor={(node: Node) => {
const data = node.data as ExecutorNodeData;
const state = data?.state;
switch (state) {
case "running":
return "#3b82f6";
case "completed":
return "#10b981";
case "failed":
return "#ef4444";
case "cancelled":
return "#f97316";
default:
return "#6b7280";
}
}}
maskColor="rgba(0, 0, 0, 0.1)"
position="bottom-right"
style={{
backgroundColor: "rgba(255, 255, 255, 0.9)",
border: "1px solid #e5e7eb",
borderRadius: "8px",
}}
className="dark:!bg-gray-800/90 dark:!border-gray-600"
/>
)}
<WorkflowAnimationHandler
nodes={nodes}
nodeUpdates={nodeUpdates}
isStreaming={isStreaming}
animateRun={viewOptions.animateRun}
/>
<ViewOptionsPanel
workflowDump={workflowDump}
onNodeSelect={onNodeSelect}
viewOptions={viewOptions}
onToggleViewOption={onToggleViewOption}
/>
</ReactFlow>
{/* CSS for custom edge animations and dark theme controls */}
<style>{`
.react-flow__edge-path {
transition: stroke 0.3s ease, stroke-width 0.3s ease;
}
.react-flow__edge.animated .react-flow__edge-path {
stroke-dasharray: 5 5;
animation: dash 1s linear infinite;
}
@keyframes dash {
0% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -10; }
}
/* Dark theme styles for React Flow controls */
.dark .react-flow__controls {
background-color: rgba(31, 41, 55, 0.9) !important;
border-color: rgb(75, 85, 99) !important;
}
.dark .react-flow__controls-button {
background-color: rgba(31, 41, 55, 0.9) !important;
border-color: rgb(75, 85, 99) !important;
color: rgb(229, 231, 235) !important;
}
.dark .react-flow__controls-button:hover {
background-color: rgba(55, 65, 81, 0.9) !important;
color: rgb(255, 255, 255) !important;
}
.dark .react-flow__controls-button svg {
fill: rgb(229, 231, 235) !important;
}
.dark .react-flow__controls-button:hover svg {
fill: rgb(255, 255, 255) !important;
}
`}</style>
</div>
);
}
@@ -0,0 +1,503 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
DialogFooter,
} from "@/components/ui/dialog";
import { Send } from "lucide-react";
import { cn } from "@/lib/utils";
import type { JSONSchemaProperty } from "@/types";
interface FormFieldProps {
name: string;
schema: JSONSchemaProperty;
value: unknown;
onChange: (value: unknown) => void;
}
function FormField({ name, schema, value, onChange }: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
// Determine if this field should span full width
const shouldSpanFullWidth =
schema.format === "textarea" ||
(description && description.length > 100) ||
type === "object" ||
type === "array";
const shouldSpanTwoColumns =
type === "object" ||
schema.format === "textarea" ||
(description && description.length > 50);
const fieldContent = (() => {
// Handle different field types based on JSON Schema
switch (type) {
case "string":
if (enumValues) {
// Enum select
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Select
value={
typeof value === "string" && value
? value
: typeof defaultValue === "string"
? defaultValue
: enumValues[0]
}
onValueChange={(val) => onChange(val)}
>
<SelectTrigger>
<SelectValue placeholder={`Select ${name}`} />
</SelectTrigger>
<SelectContent>
{enumValues.map((option: string) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
} else if (
schema.format === "textarea" ||
(description && description.length > 100)
) {
// Multi-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Textarea
id={name}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
rows={2}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
} else {
// Single-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Input
id={name}
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
case "number":
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Input
id={name}
type="number"
value={typeof value === "number" ? value : ""}
onChange={(e) => {
const val = parseFloat(e.target.value);
onChange(isNaN(val) ? "" : val);
}}
placeholder={
typeof defaultValue === "number"
? defaultValue.toString()
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "boolean":
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id={name}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked)}
/>
<Label htmlFor={name}>{name}</Label>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "array":
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Textarea
id={name}
value={
Array.isArray(value)
? value.join(", ")
: typeof value === "string"
? value
: ""
}
onChange={(e) => {
const arrayValue = e.target.value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
onChange(arrayValue);
}}
placeholder="Enter items separated by commas"
rows={2}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "object":
default:
// For complex objects or unknown types, use JSON textarea
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Textarea
id={name}
value={
typeof value === "object" && value !== null
? JSON.stringify(value, null, 2)
: typeof value === "string"
? value
: ""
}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
onChange(parsed);
} catch {
// Keep raw string value if not valid JSON
onChange(e.target.value);
}
}}
placeholder='{"key": "value"}'
rows={3}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
})();
// Return the field with appropriate grid column spanning
const getColumnSpan = () => {
if (shouldSpanFullWidth) return "md:col-span-3 xl:col-span-4";
if (shouldSpanTwoColumns) return "xl:col-span-2";
return "";
};
return <div className={getColumnSpan()}>{fieldContent}</div>;
}
interface WorkflowInputFormProps {
inputSchema: JSONSchemaProperty;
inputTypeName: string;
onSubmit: (formData: unknown) => void;
isSubmitting?: boolean;
className?: string;
}
export function WorkflowInputForm({
inputSchema,
inputTypeName,
onSubmit,
isSubmitting = false,
className,
}: WorkflowInputFormProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
// Check if we're in embedded mode (being used inside another modal)
const isEmbedded = className?.includes('embedded');
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(false);
// Determine field info
const properties = inputSchema.properties || {};
const fieldNames = Object.keys(properties);
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
const primaryField = isSimpleInput ? "value" : fieldNames[0];
const canSubmit = primaryField
? formData[primaryField] !== undefined && formData[primaryField] !== ""
: Object.keys(formData).length > 0;
// Initialize form data
useEffect(() => {
if (inputSchema.type === "string") {
setFormData({ value: inputSchema.default || "" });
} else if (inputSchema.type === "object" && inputSchema.properties) {
const initialData: Record<string, unknown> = {};
Object.entries(inputSchema.properties).forEach(([key, fieldSchema]) => {
if (fieldSchema.default !== undefined) {
initialData[key] = fieldSchema.default;
} else if (fieldSchema.enum && fieldSchema.enum.length > 0) {
initialData[key] = fieldSchema.enum[0];
}
});
setFormData(initialData);
}
}, [inputSchema]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Simplified submission logic
if (inputSchema.type === "string") {
onSubmit({ input: formData.value || "" });
} else if (inputSchema.type === "object") {
const properties = inputSchema.properties || {};
const fieldNames = Object.keys(properties);
if (fieldNames.length === 1) {
const fieldName = fieldNames[0];
onSubmit({ [fieldName]: formData[fieldName] || "" });
} else {
onSubmit(formData);
}
} else {
onSubmit(formData);
}
// Only close modal if not embedded
if (!isEmbedded) {
setIsModalOpen(false);
}
setLoading(false);
};
const updateField = (fieldName: string, value: unknown) => {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
};
// If embedded, just show the form directly
if (isEmbedded) {
return (
<form onSubmit={handleSubmit} className={className}>
<div className="grid grid-cols-1 gap-4">
{/* Simple input */}
{isSimpleInput && primaryField && (
<FormField
name="Input"
schema={inputSchema}
value={formData.value}
onChange={(value) => updateField("value", value)}
/>
)}
{/* Complex form fields */}
{!isSimpleInput && (
<>
{fieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
/>
))}
</>
)}
</div>
<div className="flex gap-2 mt-4 justify-end">
<Button
type="submit"
disabled={loading || !canSubmit}
size="default"
>
<Send className="h-4 w-4" />
{loading ? "Running..." : "Run Workflow"}
</Button>
</div>
</form>
);
}
return (
<>
{/* Sidebar Form Component */}
<div className={cn("flex flex-col", className)}>
{/* Header with Run Button */}
<div className="border-b border-border px-4 py-3 bg-muted">
<CardTitle className="text-sm mb-3">Run Workflow</CardTitle>
{/* Run Button - Opens Modal */}
<Button
onClick={() => setIsModalOpen(true)}
disabled={isSubmitting}
className="w-full"
size="default"
>
<Send className="h-4 w-4 mr-2" />
{isSubmitting ? "Running..." : "Run Workflow"}
</Button>
</div>
{/* Info Section */}
<div className="px-4 py-3">
<div className="text-sm text-muted-foreground">
<strong>Input Type:</strong>{" "}
<code className="bg-muted px-1 py-0.5 rounded">
{inputTypeName}
</code>
{inputSchema.type === "object" && inputSchema.properties && (
<span className="ml-2">
({Object.keys(inputSchema.properties).length} field
{Object.keys(inputSchema.properties).length !== 1 ? "s" : ""})
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-2">
Click "Run Workflow" to configure inputs and execute
</p>
</div>
</div>
{/* Modal with the actual form */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="w-full max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Run Workflow</DialogTitle>
<DialogClose onClose={() => setIsModalOpen(false)} />
</DialogHeader>
{/* Form Info */}
<div className="px-8 py-4 border-b flex-shrink-0">
<div className="text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span className="font-medium">Input Type:</span>
<code className="bg-muted px-3 py-1 text-xs font-mono">
{inputTypeName}
</code>
{inputSchema.type === "object" && (
<span className="text-xs text-muted-foreground">
{fieldNames.length} field
{fieldNames.length !== 1 ? "s" : ""}
</span>
)}
</div>
</div>
</div>
{/* Scrollable Form Content */}
<div className="px-8 py-6 overflow-y-auto flex-1 min-h-0">
<form id="workflow-modal-form" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-8 max-w-none">
{/* Simple input */}
{isSimpleInput && primaryField && (
<div className="md:col-span-3 xl:col-span-4">
<FormField
name="Input"
schema={inputSchema}
value={formData.value}
onChange={(value) => updateField("value", value)}
/>
{inputSchema.description && (
<p className="text-sm text-muted-foreground mt-2">
{inputSchema.description}
</p>
)}
</div>
)}
{/* Complex form fields - Show all */}
{!isSimpleInput && (
<>
{fieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
/>
))}
</>
)}
</div>
</form>
</div>
{/* Footer */}
<div className="px-8 py-4 border-t flex-shrink-0">
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
form="workflow-modal-form"
disabled={loading || !canSubmit}
>
<Send className="h-4 w-4 mr-2" />
{loading ? "Running..." : "Run Workflow"}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,839 @@
/**
* WorkflowView - Complete workflow execution interface
* Features: Workflow visualization, input forms, execution monitoring
*/
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import {
CheckCircle,
AlertCircle,
Loader2,
Play,
Settings,
RotateCcw,
ChevronDown,
} from "lucide-react";
import { LoadingState } from "@/components/ui/loading-state";
import { WorkflowInputForm } from "@/components/workflow/workflow-input-form";
import { Button } from "@/components/ui/button";
import { WorkflowFlow } from "@/components/workflow/workflow-flow";
import { useWorkflowEventCorrelation } from "@/hooks/useWorkflowEventCorrelation";
import { apiClient } from "@/services/api";
import type {
WorkflowInfo,
ExtendedResponseStreamEvent,
JSONSchemaProperty,
} from "@/types";
import type { ExecutorNodeData } from "@/components/workflow/executor-node";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
// Smart Run Workflow Button Component
interface RunWorkflowButtonProps {
inputSchema: JSONSchemaProperty;
onRun: (data: Record<string, unknown>) => void;
isSubmitting: boolean;
workflowState: "ready" | "running" | "completed" | "error";
executorHistory: Array<{
executorId: string;
message: string;
timestamp: string;
status: string;
}>;
workflowError?: string;
}
function RunWorkflowButton({
inputSchema,
onRun,
isSubmitting,
workflowState,
}: RunWorkflowButtonProps) {
const [showModal, setShowModal] = useState(false);
// Handle escape key to close modal
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && showModal) {
setShowModal(false);
}
};
if (showModal) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [showModal]);
// Analyze input requirements
const inputAnalysis = useMemo(() => {
if (!inputSchema)
return { needsInput: false, hasDefaults: false, fieldCount: 0 };
if (inputSchema.type === "string") {
return {
needsInput: !inputSchema.default,
hasDefaults: !!inputSchema.default,
fieldCount: 1,
canRunDirectly: !!inputSchema.default,
};
}
if (inputSchema.type === "object" && inputSchema.properties) {
const properties = inputSchema.properties;
const fields = Object.entries(properties);
const fieldsWithDefaults = fields.filter(
([, schema]: [string, JSONSchemaProperty]) =>
schema.default !== undefined ||
(schema.enum && schema.enum.length > 0)
);
return {
needsInput: fields.length > 0,
hasDefaults: fieldsWithDefaults.length > 0,
fieldCount: fields.length,
canRunDirectly: fieldsWithDefaults.length === fields.length, // All fields have defaults
};
}
return {
needsInput: false,
hasDefaults: false,
fieldCount: 0,
canRunDirectly: true,
};
}, [inputSchema]);
const handleDirectRun = () => {
if (inputAnalysis.canRunDirectly) {
// Build default data
const defaultData: Record<string, unknown> = {};
if (inputSchema.type === "string" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema.type === "object" && inputSchema.properties) {
Object.entries(inputSchema.properties).forEach(
([key, schema]: [string, JSONSchemaProperty]) => {
if (schema.default !== undefined) {
defaultData[key] = schema.default;
} else if (schema.enum && schema.enum.length > 0) {
defaultData[key] = schema.enum[0];
}
}
);
}
onRun(defaultData);
} else {
setShowModal(true);
}
};
const getButtonText = () => {
if (workflowState === "running") return "Running...";
if (workflowState === "completed") return "Run Again";
if (workflowState === "error") return "Retry";
if (inputAnalysis.fieldCount === 0) return "Run Workflow";
if (inputAnalysis.canRunDirectly) return "Run Workflow";
return "Configure & Run";
};
const getButtonIcon = () => {
if (workflowState === "running")
return <Loader2 className="w-4 h-4 animate-spin" />;
if (workflowState === "error") return <RotateCcw className="w-4 h-4" />;
if (inputAnalysis.needsInput && !inputAnalysis.canRunDirectly)
return <Settings className="w-4 h-4" />;
return <Play className="w-4 h-4" />;
};
const isButtonDisabled = workflowState === "running";
const buttonVariant = workflowState === "error" ? "destructive" : "primary";
return (
<>
<div className="flex items-center">
{/* Split button group using proper Button components */}
<div className="flex">
{/* Main button */}
<Button
onClick={handleDirectRun}
disabled={isButtonDisabled}
variant={
buttonVariant === "destructive" ? "destructive" : "default"
}
size="default"
className={inputAnalysis.needsInput ? "rounded-r-none" : ""}
>
{getButtonIcon()}
{getButtonText()}
</Button>
{/* Dropdown button - only show if inputs are available */}
{inputAnalysis.needsInput && (
<Button
onClick={() => setShowModal(true)}
disabled={isButtonDisabled}
variant={
buttonVariant === "destructive" ? "destructive" : "default"
}
size="icon"
className="rounded-l-none border-l-0 w-9"
title="Configure inputs"
>
<ChevronDown className="w-4 h-4" />
</Button>
)}
</div>
</div>
{/* Modal with proper Dialog component - matching WorkflowInputForm structure */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="w-full max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Configure Workflow Inputs</DialogTitle>
<DialogClose onClose={() => setShowModal(false)} />
</DialogHeader>
{/* Form Info - matching the structure from WorkflowInputForm */}
<div className="px-8 py-4 border-b flex-shrink-0">
<div className="text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span className="font-medium">Input Type:</span>
<code className="bg-muted px-3 py-1 text-xs font-mono">
{inputAnalysis.fieldCount === 0
? "No Input"
: inputSchema.type === "string"
? "String"
: "Object"}
</code>
{inputSchema.type === "object" && inputSchema.properties && (
<span className="text-xs text-muted-foreground">
{Object.keys(inputSchema.properties).length} field
{Object.keys(inputSchema.properties).length !== 1
? "s"
: ""}
</span>
)}
</div>
</div>
</div>
{/* Scrollable Form Content - matching padding and structure */}
<div className="px-8 py-6 overflow-y-auto flex-1 min-h-0">
<WorkflowInputForm
inputSchema={inputSchema}
inputTypeName="Input"
onSubmit={(data) => {
onRun(data as Record<string, unknown>);
setShowModal(false);
}}
isSubmitting={isSubmitting}
className="embedded"
/>
</div>
{/* Footer - no additional buttons needed since WorkflowInputForm embedded mode has its own */}
</DialogContent>
</Dialog>
</>
);
}
interface WorkflowViewProps {
selectedWorkflow: WorkflowInfo;
onDebugEvent: DebugEventHandler;
}
export function WorkflowView({
selectedWorkflow,
onDebugEvent,
}: WorkflowViewProps) {
const [workflowInfo, setWorkflowInfo] = useState<WorkflowInfo | null>(null);
const [workflowLoading, setWorkflowLoading] = useState(false);
const [openAIEvents, setOpenAIEvents] = useState<
ExtendedResponseStreamEvent[]
>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [selectedExecutor, setSelectedExecutor] =
useState<ExecutorNodeData | null>(null);
const [workflowResult, setWorkflowResult] = useState<string>("");
const [workflowError, setWorkflowError] = useState<string>("");
const accumulatedText = useRef<string>("");
// Panel resize state
const [bottomPanelHeight, setBottomPanelHeight] = useState(() => {
const savedHeight = localStorage.getItem("workflowBottomPanelHeight");
return savedHeight ? parseInt(savedHeight, 10) : 300;
});
const [isResizing, setIsResizing] = useState(false);
// View options state
const [viewOptions, setViewOptions] = useState(() => {
const saved = localStorage.getItem("workflowViewOptions");
return saved
? JSON.parse(saved)
: {
showMinimap: false,
showGrid: true,
animateRun: false,
};
});
const { selectExecutor, getExecutorData } = useWorkflowEventCorrelation(
openAIEvents,
isStreaming
);
// Save view options to localStorage
useEffect(() => {
localStorage.setItem("workflowViewOptions", JSON.stringify(viewOptions));
}, [viewOptions]);
// View option handlers
const toggleViewOption = (key: keyof typeof viewOptions) => {
setViewOptions((prev: typeof viewOptions) => ({
...prev,
[key]: !prev[key],
}));
};
// Load workflow info when selectedWorkflow changes
useEffect(() => {
const loadWorkflowInfo = async () => {
if (selectedWorkflow.type !== "workflow") return;
setWorkflowLoading(true);
try {
const info = await apiClient.getWorkflowInfo(selectedWorkflow.id);
setWorkflowInfo(info);
} catch (error) {
console.error("Failed to load workflow info:", error);
setWorkflowInfo(null);
} finally {
setWorkflowLoading(false);
}
};
// Clear state when workflow changes
setOpenAIEvents([]);
setIsStreaming(false);
setSelectedExecutor(null);
setWorkflowResult("");
setWorkflowError("");
accumulatedText.current = "";
loadWorkflowInfo();
}, [selectedWorkflow.id, selectedWorkflow.type]);
const handleNodeSelect = (executorId: string, data: ExecutorNodeData) => {
setSelectedExecutor(data);
selectExecutor(executorId);
};
// Extract workflow events from OpenAI events for executor tracking
const workflowEvents = useMemo(() => {
return openAIEvents.filter(
(event) => event.type === "response.workflow_event.complete"
);
}, [openAIEvents]);
// Extract executor history from workflow events
const executorHistory = useMemo(() => {
return workflowEvents.map((event) => {
if ("data" in event && event.data && typeof event.data === "object") {
const data = event.data as Record<string, unknown>;
return {
executorId: String(data.executor_id || "unknown"),
message: String(data.event_type || "Processing"),
timestamp: String(data.timestamp || new Date().toISOString()),
status: String(data.event_type || "").includes("Completed")
? ("completed" as const)
: String(data.event_type || "").includes("Error")
? ("error" as const)
: ("running" as const),
};
}
return {
executorId: "unknown",
message: "Processing",
timestamp: new Date().toISOString(),
status: "running" as const,
};
});
}, [workflowEvents]);
// Track active executors
const activeExecutors = useMemo(() => {
if (!isStreaming) return [];
const recent = executorHistory
.filter((h) => h.status === "running")
.slice(-2);
return recent.map((h) => h.executorId);
}, [executorHistory, isStreaming]);
// Save panel height to localStorage
useEffect(() => {
localStorage.setItem(
"workflowBottomPanelHeight",
bottomPanelHeight.toString()
);
}, [bottomPanelHeight]);
// Handle resize drag
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
const startY = e.clientY;
const startHeight = bottomPanelHeight;
const handleMouseMove = (e: MouseEvent) => {
const deltaY = startY - e.clientY;
const newHeight = Math.max(
200,
Math.min(window.innerHeight * 0.6, startHeight + deltaY)
);
setBottomPanelHeight(newHeight);
};
const handleMouseUp = () => {
setIsResizing(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[bottomPanelHeight]
);
// Handle workflow data sending (structured input)
const handleSendWorkflowData = useCallback(
async (inputData: Record<string, unknown>) => {
if (!selectedWorkflow || selectedWorkflow.type !== "workflow") return;
setIsStreaming(true);
setOpenAIEvents([]); // Clear previous OpenAI events for new execution
setWorkflowResult("");
setWorkflowError("");
accumulatedText.current = "";
// Clear debug panel events for new workflow run
onDebugEvent("clear");
try {
const request = { input_data: inputData };
// Use OpenAI-compatible API streaming - direct event handling
const streamGenerator = apiClient.streamWorkflowExecutionOpenAI(
selectedWorkflow.id,
request
);
for await (const openAIEvent of streamGenerator) {
// Store all events for processing
setOpenAIEvents((prev) => [...prev, openAIEvent]);
// Pass to debug panel
onDebugEvent(openAIEvent);
// Handle text output for workflow result
if (
openAIEvent.type === "response.output_text.delta" &&
"delta" in openAIEvent &&
openAIEvent.delta
) {
accumulatedText.current += openAIEvent.delta;
setWorkflowResult(accumulatedText.current);
}
// Handle workflow completion with final result
if (
openAIEvent.type === "response.workflow_event.complete" &&
"data" in openAIEvent &&
openAIEvent.data
) {
const data = openAIEvent.data as {
event_type?: string;
data?: unknown;
};
if (data.event_type === "WorkflowCompletedEvent" && data.data) {
setWorkflowResult(String(data.data));
}
}
// Handle errors
if (openAIEvent.type === "error") {
setWorkflowError(
"error" in openAIEvent
? String(openAIEvent.error)
: "Unknown error"
);
break;
}
}
// Stream ended
setIsStreaming(false);
} catch (error) {
console.error("Workflow execution failed:", error);
setWorkflowError(
error instanceof Error ? error.message : "Unknown error"
);
setIsStreaming(false);
}
},
[selectedWorkflow, onDebugEvent]
);
// Show loading state when workflow is being loaded
if (workflowLoading) {
return (
<LoadingState
message="Loading workflow..."
description="Fetching workflow structure and configuration"
/>
);
}
if (!workflowInfo?.workflow_dump && !executorHistory.length) {
return (
<LoadingState
message="Initializing workflow..."
description="Setting up workflow execution environment"
/>
);
}
return (
<div className="workflow-view flex flex-col h-full">
{/* Top Panel - Workflow Visualization */}
<div className="flex-1 min-h-0 p-4">
{/* Workflow Diagram Section */}
{workflowInfo?.workflow_dump && (
<div className="border border-border rounded bg-card shadow-sm h-full flex flex-col">
<div className="border-b border-border px-4 py-3 bg-muted rounded-t flex-shrink-0">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">
Workflow Visualization
</h3>
{/* Smart Run Workflow CTA - Show for all states */}
{workflowInfo && (
<div className="flex items-center gap-3">
<RunWorkflowButton
inputSchema={workflowInfo.input_schema}
onRun={handleSendWorkflowData}
isSubmitting={isStreaming}
workflowState={
isStreaming
? "running"
: workflowError
? "error"
: executorHistory.length > 0
? "completed"
: "ready"
}
executorHistory={executorHistory}
workflowError={workflowError}
/>
</div>
)}
{/* Status is now handled by the RunWorkflowButton component */}
</div>
</div>
<div className="flex-1 min-h-0">
<WorkflowFlow
workflowDump={workflowInfo.workflow_dump}
events={openAIEvents}
isStreaming={isStreaming}
onNodeSelect={handleNodeSelect}
className="h-full"
viewOptions={viewOptions}
onToggleViewOption={toggleViewOption}
/>
</div>
</div>
)}
</div>
{/* Resize Handle */}
<div
className={`h-1 cursor-row-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
}`}
onMouseDown={handleMouseDown}
>
<div className="absolute inset-x-0 -top-2 -bottom-2 flex items-center justify-center">
<div
className={`w-12 h-1 rounded-full transition-all duration-200 ease-in-out ${
isResizing
? "bg-primary shadow-lg shadow-primary/25"
: "bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20"
}`}
></div>
</div>
</div>
{/* Bottom Panel - Execution Details */}
<div
className="flex-shrink-0 border-t"
style={{ height: `${bottomPanelHeight}px` }}
>
{/* Full Width - Execution Details */}
<div className="flex-1 min-w-0 p-4 overflow-auto">
{selectedExecutor ||
activeExecutors.length > 0 ||
executorHistory.length > 0 ||
workflowResult ||
workflowError ? (
<div className="h-full space-y-4">
{/* Current/Last Executor Panel */}
{(selectedExecutor ||
activeExecutors.length > 0 ||
executorHistory.length > 0) && (
<div className="border border-border rounded bg-card shadow-sm">
<div className="border-b border-border px-4 py-3 bg-muted rounded-t">
<h4 className="text-sm font-medium text-foreground">
{selectedExecutor
? `Executor: ${
selectedExecutor.name || selectedExecutor.executorId
}`
: isStreaming && activeExecutors.length > 0
? "Current Executor"
: "Last Executor"}
</h4>
</div>
<div className="p-4">
{selectedExecutor ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${
selectedExecutor.state === "running"
? "bg-blue-500 dark:bg-blue-400 animate-pulse"
: selectedExecutor.state === "completed"
? "bg-green-500 dark:bg-green-400"
: selectedExecutor.state === "failed"
? "bg-red-500 dark:bg-red-400"
: selectedExecutor.state === "cancelled"
? "bg-orange-500 dark:bg-orange-400"
: "bg-gray-400 dark:bg-gray-500"
}`}
/>
<span className="text-sm font-medium capitalize text-foreground">
{selectedExecutor.state}
</span>
{selectedExecutor.executorType && (
<span className="text-xs text-muted-foreground">
({selectedExecutor.executorType})
</span>
)}
</div>
{selectedExecutor.inputData !== undefined &&
selectedExecutor.inputData !== null && (
<div>
<h5 className="text-xs font-medium text-foreground mb-1">
Input Data:
</h5>
<pre className="text-xs bg-muted p-2 rounded overflow-x-auto max-h-24">
{String(
typeof selectedExecutor.inputData === "string"
? selectedExecutor.inputData
: (() => {
try {
return JSON.stringify(
selectedExecutor.inputData,
null,
2
);
} catch {
return "[Unable to display data]";
}
})()
)}
</pre>
</div>
)}
{selectedExecutor.outputData !== undefined &&
selectedExecutor.outputData !== null && (
<div>
<h5 className="text-xs font-medium text-foreground mb-1">
Output Data:
</h5>
<pre className="text-xs bg-muted p-2 rounded overflow-x-auto max-h-24">
{String(
typeof selectedExecutor.outputData ===
"string"
? selectedExecutor.outputData
: (() => {
try {
return JSON.stringify(
selectedExecutor.outputData,
null,
2
);
} catch {
return "[Unable to display data]";
}
})()
)}
</pre>
</div>
)}
{selectedExecutor.error && (
<div>
<h5 className="text-xs font-medium text-destructive mb-1">
Error:
</h5>
<pre className="text-xs bg-destructive/10 text-destructive p-2 rounded overflow-x-auto">
{selectedExecutor.error}
</pre>
</div>
)}
<button
onClick={() => setSelectedExecutor(null)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Back to current executor
</button>
</div>
) : (
(() => {
const currentExecutorId =
isStreaming && activeExecutors.length > 0
? activeExecutors[activeExecutors.length - 1]
: executorHistory.length > 0
? executorHistory[executorHistory.length - 1]
.executorId
: null;
if (!currentExecutorId) return null;
const executorData = getExecutorData(currentExecutorId);
const historyItem = executorHistory.find(
(h) => h.executorId === currentExecutorId
);
return (
<div
className="space-y-3 cursor-pointer hover:bg-muted/30 p-2 rounded transition-colors"
onClick={() => {
if (executorData) {
setSelectedExecutor({
executorId: executorData.executorId,
state: executorData.state,
inputData: executorData.inputData,
outputData: executorData.outputData,
error: executorData.error,
name: undefined,
executorType: undefined,
isSelected: true,
isStartNode: false,
onNodeClick: undefined,
});
}
}}
>
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${
isStreaming
? "bg-blue-500 dark:bg-blue-400 animate-pulse"
: historyItem?.status === "completed"
? "bg-green-500 dark:bg-green-400"
: historyItem?.status === "error"
? "bg-red-500 dark:bg-red-400"
: "bg-gray-400 dark:bg-gray-500"
}`}
/>
<span className="text-sm font-medium text-foreground">
{currentExecutorId}
</span>
{historyItem && (
<span className="text-xs text-muted-foreground">
{new Date(
historyItem.timestamp
).toLocaleTimeString()}
</span>
)}
</div>
{historyItem && (
<p className="text-sm text-muted-foreground">
{isStreaming
? "Processing..."
: historyItem.message}
</p>
)}
</div>
);
})()
)}
</div>
</div>
)}
{/* Enhanced Result Display */}
{workflowResult && (
<div className="border-2 border-emerald-300 dark:border-emerald-600 rounded bg-emerald-50 dark:bg-emerald-950/50 shadow">
<div className="border-b border-emerald-300 dark:border-emerald-600 px-4 py-3 bg-emerald-100 dark:bg-emerald-900/50 rounded-t">
<div className="flex items-center gap-3">
<CheckCircle className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
<h4 className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
Workflow Complete
</h4>
</div>
</div>
<div className="p-4">
<div className="text-emerald-700 dark:text-emerald-300 whitespace-pre-wrap break-words text-sm">
{workflowResult}
</div>
</div>
</div>
)}
{/* Enhanced Error Display */}
{workflowError && (
<div className="border-2 border-destructive/70 rounded bg-destructive/5 shadow">
<div className="border-b border-destructive/70 px-4 py-3 bg-destructive/10 rounded-t">
<div className="flex items-center gap-3">
<AlertCircle className="w-4 h-4 text-destructive" />
<h4 className="text-sm font-semibold text-destructive">
Workflow Failed
</h4>
</div>
</div>
<div className="p-4">
<div className="text-destructive whitespace-pre-wrap break-words text-sm">
{workflowError}
</div>
</div>
</div>
)}
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
<p>Select a workflow to see execution details</p>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,126 @@
import { useMemo, useState, useCallback } from "react";
import type { ExtendedResponseStreamEvent } from "@/types";
// import type { ExecutorNodeData } from "@/components/workflow/executor-node";
// Type for executor input/output data - can be various types based on workflow events
export type ExecutorData =
| string
| number
| boolean
| Record<string, unknown>
| null;
// State tracking for a specific executor
interface ExecutorState {
executorId: string;
state: "pending" | "running" | "completed" | "failed" | "cancelled";
inputData?: ExecutorData;
outputData?: ExecutorData;
error?: string;
timestamp: string;
}
interface WorkflowEventCorrelationResult {
// State access
isWorkflowRunning: boolean;
selectedExecutorId: string | null;
recentlyActive: string[];
// Actions
selectExecutor: (executorId: string) => void;
getExecutorData: (executorId: string) => ExecutorState | null;
getExecutorEvents: (executorId: string) => ExtendedResponseStreamEvent[];
}
// Hook for correlating workflow events with executor states
export function useWorkflowEventCorrelation(
events: ExtendedResponseStreamEvent[],
isStreaming: boolean
): WorkflowEventCorrelationResult {
const [selectedExecutorId, setSelectedExecutorId] = useState<string | null>(null);
// Process events into executor states
const { executors, recentlyActive, isWorkflowRunning } = useMemo(() => {
const executorMap: Record<string, ExecutorState> = {};
const activeExecutors: string[] = [];
let workflowActive = isStreaming;
// Process workflow events
events.forEach((event) => {
if (event.type === "response.workflow_event.complete" && "data" in event && event.data) {
const data = event.data as any;
const executorId = data.executor_id;
if (!executorId) return;
// Initialize executor if not exists
if (!executorMap[executorId]) {
executorMap[executorId] = {
executorId,
state: "pending",
timestamp: new Date().toISOString(),
};
}
const executor = executorMap[executorId];
const eventType = data.event_type;
// Update state based on event type
if (eventType === "ExecutorInvokedEvent") {
executor.state = "running";
executor.inputData = data.data;
if (!activeExecutors.includes(executorId)) {
activeExecutors.push(executorId);
}
} else if (eventType === "ExecutorCompletedEvent") {
executor.state = "completed";
executor.outputData = data.data;
} else if (eventType?.includes("Error") || eventType?.includes("Failed")) {
executor.state = "failed";
executor.error = typeof data.data === "string" ? data.data : "Execution failed";
} else if (eventType?.includes("Cancel")) {
executor.state = "cancelled";
}
executor.timestamp = new Date().toISOString();
}
});
return {
executors: executorMap,
recentlyActive: activeExecutors.slice(-3), // Keep last 3 active executors
isWorkflowRunning: workflowActive,
};
}, [events, isStreaming]);
const selectExecutor = useCallback((executorId: string) => {
setSelectedExecutorId(executorId);
}, []);
const getExecutorData = useCallback((executorId: string): ExecutorState | null => {
return executors[executorId] || null;
}, [executors]);
const getExecutorEvents = useCallback(
(executorId: string): ExtendedResponseStreamEvent[] => {
return events.filter((event) => {
if (event.type === "response.workflow_event.complete" && "data" in event && event.data) {
const data = event.data as any;
return data.executor_id === executorId;
}
return false;
});
},
[events]
);
return {
isWorkflowRunning,
selectedExecutorId,
recentlyActive,
selectExecutor,
getExecutorData,
getExecutorEvents,
};
}
@@ -0,0 +1,147 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Mermaid diagram styles removed - visualization coming soon */
/* Style workflow completion/error states */
.workflow-chat-view .border-green-200 {
@apply border-emerald-200;
}
.workflow-chat-view .bg-green-50 {
@apply bg-emerald-50;
}
.workflow-chat-view .bg-green-100 {
@apply bg-emerald-100;
}
.workflow-chat-view .text-green-600 {
@apply text-emerald-600;
}
.workflow-chat-view .text-green-700 {
@apply text-emerald-700;
}
.workflow-chat-view .text-green-800 {
@apply text-emerald-800;
}
@@ -0,0 +1,18 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ThemeProvider } from "./components/theme-provider"
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<App />
</ThemeProvider>
</StrictMode>,
)
@@ -0,0 +1,447 @@
/**
* API client for DevUI backend
* Handles agents, workflows, streaming, and session management
*/
import type {
AgentInfo,
HealthResponse,
RunAgentRequest,
RunWorkflowRequest,
ThreadInfo,
} from "@/types";
import type { AgentFrameworkRequest } from "@/types/agent-framework";
import type { ExtendedResponseStreamEvent } from "@/types/openai";
// Backend API response types to match Python Pydantic models
interface EntityInfo {
id: string;
type: "agent" | "workflow";
name: string;
description?: string;
framework: string;
tools?: (string | Record<string, unknown>)[];
metadata: Record<string, unknown>;
executors?: string[];
workflow_dump?: Record<string, unknown>;
input_schema?: Record<string, unknown>;
input_type_name?: string;
start_executor_id?: string;
}
interface DiscoveryResponse {
entities: EntityInfo[];
}
interface ThreadApiResponse {
id: string;
object: "thread";
created_at: number;
metadata: { agent_id: string };
}
interface ThreadListResponse {
object: "list";
data: ThreadApiObject[];
}
interface ThreadApiObject {
id: string;
object: "thread";
agent_id: string;
created_at?: string;
}
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL !== undefined
? import.meta.env.VITE_API_BASE_URL
: "http://localhost:8080";
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string = API_BASE_URL) {
this.baseUrl = baseUrl;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
...options.headers,
},
...options,
});
if (!response.ok) {
throw new Error(
`API request failed: ${response.status} ${response.statusText}`
);
}
return response.json();
}
// Health check
async getHealth(): Promise<HealthResponse> {
return this.request<HealthResponse>("/health");
}
// Entity discovery using new unified endpoint
async getEntities(): Promise<{
entities: (AgentInfo | import("@/types").WorkflowInfo)[];
agents: AgentInfo[];
workflows: import("@/types").WorkflowInfo[];
}> {
const response = await this.request<DiscoveryResponse>("/v1/entities");
// Separate agents and workflows
const agents: AgentInfo[] = [];
const workflows: import("@/types").WorkflowInfo[] = [];
response.entities.forEach((entity) => {
if (entity.type === "agent") {
agents.push({
id: entity.id,
name: entity.name,
description: entity.description,
type: "agent",
source: "directory", // Default source
tools: (entity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
),
has_env: false, // Default value
module_path:
typeof entity.metadata?.module_path === "string"
? entity.metadata.module_path
: undefined,
});
} else if (entity.type === "workflow") {
const firstTool = entity.tools?.[0];
const startExecutorId = typeof firstTool === "string" ? firstTool : "";
workflows.push({
id: entity.id,
name: entity.name,
description: entity.description,
type: "workflow",
source: "directory",
executors: (entity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
),
has_env: false,
module_path:
typeof entity.metadata?.module_path === "string"
? entity.metadata.module_path
: undefined,
input_schema:
(entity.input_schema as unknown as import("@/types").JSONSchema) || {
type: "string",
}, // Default schema
input_type_name: entity.input_type_name || "Input",
start_executor_id: startExecutorId,
});
}
});
return { entities: [...agents, ...workflows], agents, workflows };
}
// Legacy methods for compatibility
async getAgents(): Promise<AgentInfo[]> {
const { agents } = await this.getEntities();
return agents;
}
async getWorkflows(): Promise<import("@/types").WorkflowInfo[]> {
const { workflows } = await this.getEntities();
return workflows;
}
async getAgentInfo(agentId: string): Promise<AgentInfo> {
// Get detailed entity info from unified endpoint
return this.request<AgentInfo>(`/v1/entities/${agentId}/info`);
}
async getWorkflowInfo(
workflowId: string
): Promise<import("@/types").WorkflowInfo> {
// Get detailed entity info from unified endpoint
return this.request<import("@/types").WorkflowInfo>(
`/v1/entities/${workflowId}/info`
);
}
// Thread management using real /v1/threads endpoints
async createThread(agentId: string): Promise<ThreadInfo> {
const response = await this.request<ThreadApiResponse>("/v1/threads", {
method: "POST",
body: JSON.stringify({ agent_id: agentId }),
});
return {
id: response.id,
agent_id: agentId,
created_at: new Date(response.created_at * 1000).toISOString(),
message_count: 0,
};
}
async getThreads(agentId: string): Promise<ThreadInfo[]> {
const response = await this.request<ThreadListResponse>(
`/v1/threads?agent_id=${agentId}`
);
return response.data.map((thread: ThreadApiObject) => ({
id: thread.id,
agent_id: thread.agent_id,
created_at: thread.created_at || new Date().toISOString(),
message_count: 0, // We don't track this yet
}));
}
async deleteThread(threadId: string): Promise<boolean> {
try {
await this.request(`/v1/threads/${threadId}`, {
method: "DELETE",
});
return true;
} catch {
return false;
}
}
async getThreadMessages(
threadId: string
): Promise<import("@/types").ChatMessage[]> {
try {
const response = await this.request<{ data: unknown[] }>(
`/v1/threads/${threadId}/messages`
);
// Convert API messages to ChatMessage format, handling missing fields
return response.data.map((msg: unknown, index: number) => {
const msgObj = msg as Record<string, unknown>;
const role = msgObj.role as string;
return {
id: (msgObj.message_id as string) || `restored-${index}`,
role:
role === "user" ||
role === "assistant" ||
role === "system" ||
role === "tool"
? role
: "user",
contents:
(msgObj.contents as import("@/types/agent-framework").Contents[]) ||
[],
timestamp: (msgObj.timestamp as string) || new Date().toISOString(),
author_name: msgObj.author_name as string | undefined,
message_id: msgObj.message_id as string | undefined,
};
});
} catch (error) {
console.error("Failed to get thread messages:", error);
return [];
}
}
// OpenAI-compatible streaming methods using /v1/responses endpoint
// Stream agent execution using pure OpenAI format
async *streamAgentExecutionOpenAI(
agentId: string,
request: RunAgentRequest
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
const openAIRequest: AgentFrameworkRequest = {
model: "agent-framework",
input: request.input, // Direct OpenAI ResponseInputParam
stream: true,
extra_body: {
entity_id: agentId,
thread_id: request.thread_id,
},
};
return yield* this.streamAgentExecutionOpenAIDirect(agentId, openAIRequest);
}
// Stream agent execution using direct OpenAI format
async *streamAgentExecutionOpenAIDirect(
_agentId: string,
openAIRequest: AgentFrameworkRequest
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
const response = await fetch(`${this.baseUrl}/v1/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(openAIRequest),
});
if (!response.ok) {
throw new Error(`OpenAI streaming request failed: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
// Parse SSE events
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith("data: ")) {
const dataStr = line.slice(6);
// Handle [DONE] signal
if (dataStr === "[DONE]") {
return;
}
try {
const openAIEvent: ExtendedResponseStreamEvent =
JSON.parse(dataStr);
yield openAIEvent; // Direct pass-through - no conversion!
} catch (e) {
console.error("Failed to parse OpenAI SSE event:", e);
}
}
}
}
} finally {
reader.releaseLock();
}
}
// Stream workflow execution using OpenAI format - direct event pass-through
async *streamWorkflowExecutionOpenAI(
workflowId: string,
request: RunWorkflowRequest
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Convert to OpenAI format
const openAIRequest: AgentFrameworkRequest = {
model: "agent-framework", // Placeholder model name
input: "", // Empty string for workflows - actual data is in extra_body.input_data
stream: true,
extra_body: {
entity_id: workflowId,
input_data: request.input_data, // Preserve structured data
},
};
const response = await fetch(`${this.baseUrl}/v1/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(openAIRequest),
});
if (!response.ok) {
throw new Error(`OpenAI streaming request failed: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
// Parse SSE events
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith("data: ")) {
const dataStr = line.slice(6);
// Handle [DONE] signal
if (dataStr === "[DONE]") {
return;
}
try {
const openAIEvent: ExtendedResponseStreamEvent =
JSON.parse(dataStr);
yield openAIEvent; // Direct pass-through - no conversion!
} catch (e) {
console.error("Failed to parse OpenAI SSE event:", e);
}
}
}
}
} finally {
reader.releaseLock();
}
}
// REMOVED: Legacy streaming methods - use streamAgentExecutionOpenAI and streamWorkflowExecutionOpenAI instead
// Non-streaming execution (for testing)
async runAgent(
agentId: string,
request: RunAgentRequest
): Promise<{
thread_id: string;
result: unknown[];
message_count: number;
}> {
return this.request(`/agents/${agentId}/run`, {
method: "POST",
body: JSON.stringify(request),
});
}
async runWorkflow(
workflowId: string,
request: RunWorkflowRequest
): Promise<{
result: string;
events: number;
message_count: number;
}> {
return this.request(`/workflows/${workflowId}/run`, {
method: "POST",
body: JSON.stringify(request),
});
}
}
// Export singleton instance
export const apiClient = new ApiClient();
export { ApiClient };
@@ -0,0 +1,312 @@
/**
* TypeScript interfaces matching OpenAI Responses API and Agent Framework Python types
* Generated from OpenAI SDK and Agent Framework _types.py, _threads.py, and _events.py
*/
// OpenAI Responses API Types - EXACT match to OpenAI SDK
export interface ResponseInputTextParam {
text: string;
/** The type of the input item. Always `input_text`. */
type: "input_text";
}
export interface ResponseInputImageParam {
/** The detail level of the image to be sent to the model. One of `high`, `low`, or `auto`. Defaults to `auto`. */
detail: "low" | "high" | "auto";
/** The type of the input item. Always `input_image`. */
type: "input_image";
/** The ID of the file to be sent to the model. */
file_id?: string;
/** The URL of the image to be sent to the model. A fully qualified URL or base64 encoded image in a data URL. */
image_url?: string;
}
export interface ResponseInputFileParam {
/** The type of the input item. Always `input_file`. */
type: "input_file";
/** The content of the file to be sent to the model. */
file_data: string;
/** The ID of the file to be sent to the model. */
file_id?: string;
/** The URL of the file to be sent to the model. */
file_url: string;
/** The name of the file to be sent to the model. */
filename: string;
}
export type ResponseInputContent =
| ResponseInputTextParam
| ResponseInputImageParam
| ResponseInputFileParam;
export interface EasyInputMessage {
type?: "message";
role: "user" | "assistant" | "system" | "developer";
content: string | ResponseInputContent[];
}
export type ResponseInputItem = EasyInputMessage;
export type ResponseInputParam = ResponseInputItem[];
// Agent Framework extension fields (matches backend AgentFrameworkExtraBody)
export interface AgentFrameworkExtraBody {
entity_id: string;
thread_id?: string;
input_data?: Record<string, unknown>;
}
// Agent Framework Request - OpenAI ResponseCreateParams with extensions
export interface AgentFrameworkRequest {
model: string;
input: string | ResponseInputParam; // Union type matching OpenAI
stream?: boolean;
// Common OpenAI optional fields
instructions?: string;
metadata?: Record<string, unknown>;
temperature?: number;
max_output_tokens?: number;
tools?: Record<string, unknown>[];
// Agent Framework extension - strongly typed
extra_body?: AgentFrameworkExtraBody;
entity_id?: string; // Allow entity_id as top-level field too
}
// Base types
export type Role = "system" | "user" | "assistant" | "tool";
export type FinishReason = "content_filter" | "length" | "stop" | "tool_calls";
export type CreatedAtT = string; // ISO timestamp
// Content type discriminator
export type ContentType =
| "text"
| "function_call"
| "function_result"
| "text_reasoning"
| "data"
| "uri"
| "error"
| "usage"
| "hosted_file"
| "hosted_vector_store";
// Base content interface
export interface BaseContent {
type: ContentType;
annotations?: unknown[];
additional_properties?: Record<string, unknown>;
raw_representation?: unknown;
}
// Specific content types
export interface TextContent extends BaseContent {
type: "text";
text: string;
}
export interface FunctionCallContent extends BaseContent {
type: "function_call";
call_id: string;
name: string;
arguments?: string | Record<string, unknown>;
exception?: unknown;
}
export interface FunctionResultContent extends BaseContent {
type: "function_result";
call_id: string;
result?: unknown;
exception?: unknown;
}
export interface TextReasoningContent extends BaseContent {
type: "text_reasoning";
text: string;
reasoning: string;
}
export interface DataContent extends BaseContent {
type: "data";
uri: string;
media_type?: string;
}
export interface UriContent extends BaseContent {
type: "uri";
uri: string;
media_type?: string;
}
export interface ErrorContent extends BaseContent {
type: "error";
error: string;
error_code?: string;
}
export interface UsageContent extends BaseContent {
type: "usage";
usage_data: unknown;
}
export interface HostedFileContent extends BaseContent {
type: "hosted_file";
file_id: string;
}
export interface HostedVectorStoreContent extends BaseContent {
type: "hosted_vector_store";
vector_store_id: string;
}
// Union type for all content
export type Contents =
| TextContent
| FunctionCallContent
| FunctionResultContent
| TextReasoningContent
| DataContent
| UriContent
| ErrorContent
| UsageContent
| HostedFileContent
| HostedVectorStoreContent;
// Usage details
export interface UsageDetails {
completion_tokens?: number;
prompt_tokens?: number;
total_tokens?: number;
additional_properties?: Record<string, unknown>;
}
// Agent run response update (streaming)
export interface AgentRunResponseUpdate {
contents: Contents[];
role?: Role;
author_name?: string;
response_id?: string;
message_id?: string;
created_at?: CreatedAtT;
additional_properties?: Record<string, unknown>;
raw_representation?: unknown;
// Additional property that may be present (concatenated text from all TextContent)
text?: string;
}
// Agent run response (final)
export interface AgentRunResponse {
messages: ChatMessage[];
response_id?: string;
created_at?: CreatedAtT;
usage_details?: UsageDetails;
raw_representation?: unknown;
additional_properties?: Record<string, unknown>;
}
// Chat message
export interface ChatMessage {
contents: Contents[];
role?: Role;
author_name?: string;
message_id?: string;
created_at?: CreatedAtT;
additional_properties?: Record<string, unknown>;
raw_representation?: unknown;
}
// Chat response update (model client streaming)
export interface ChatResponseUpdate {
contents: Contents[];
role?: Role;
author_name?: string;
response_id?: string;
message_id?: string;
conversation_id?: string;
ai_model_id?: string;
created_at?: CreatedAtT;
finish_reason?: FinishReason;
additional_properties?: Record<string, unknown>;
raw_representation?: unknown;
}
// Agent thread
export interface AgentThread {
service_thread_id?: string;
message_store?: unknown; // ChatMessageStore - could be typed further if needed
}
// Workflow events
export interface WorkflowEvent {
type?: string; // Event class name like "WorkflowCompletedEvent", "ExecutorInvokedEvent", etc.
data?: unknown;
executor_id?: string; // Present for executor-related events
}
export interface WorkflowStartedEvent extends WorkflowEvent {
// Event-specific data for workflow start
readonly event_type: "workflow_started";
}
export interface WorkflowCompletedEvent extends WorkflowEvent {
// Event-specific data for workflow completion
readonly event_type: "workflow_completed";
}
export interface WorkflowWarningEvent extends WorkflowEvent {
data: string; // Warning message
}
export interface WorkflowErrorEvent extends WorkflowEvent {
data: Error; // Exception
}
export interface ExecutorEvent extends WorkflowEvent {
executor_id: string;
}
export interface AgentRunUpdateEvent extends ExecutorEvent {
data?: AgentRunResponseUpdate;
}
export interface AgentRunEvent extends ExecutorEvent {
data?: AgentRunResponse;
}
// Span event structure (from OpenTelemetry)
export interface SpanEvent {
name: string;
timestamp: number;
attributes: Record<string, unknown>;
}
// Trace span for streaming
export interface TraceSpan {
span_id: string;
parent_span_id?: string;
operation_name: string;
start_time: number;
end_time?: number;
duration_ms?: number;
attributes: Record<string, unknown>;
events: SpanEvent[];
status: string;
raw_span?: Record<string, unknown>;
}
// Helper type guards for Agent Framework content types
export function isTextContent(content: Contents): content is TextContent {
return content.type === "text";
}
export function isFunctionCallContent(
content: Contents
): content is FunctionCallContent {
return content.type === "function_call";
}
export function isFunctionResultContent(
content: Contents
): content is FunctionResultContent {
return content.type === "function_result";
}
@@ -0,0 +1,144 @@
/**
* Core TypeScript types for DevUI Frontend
* Matches backend API models for strict type safety
*/
export type AgentType = "agent" | "workflow";
export type AgentSource = "directory" | "in_memory";
export type StreamEventType =
| "agent_run_update"
| "workflow_event"
| "workflow_structure"
| "completion"
| "error"
| "debug_trace"
| "trace_span";
export interface AgentInfo {
id: string;
name?: string;
description?: string;
type: AgentType;
source: AgentSource;
tools: string[];
has_env: boolean;
module_path?: string;
}
// JSON Schema types for workflow input
export interface JSONSchemaProperty {
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
description?: string;
default?: unknown;
enum?: string[];
format?: string;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
items?: JSONSchemaProperty;
}
export interface JSONSchema {
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
description?: string;
default?: unknown;
enum?: string[];
format?: string;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
items?: JSONSchemaProperty;
}
export interface WorkflowInfo extends Omit<AgentInfo, "tools"> {
executors: string[]; // List of executor IDs in this workflow
workflow_dump?: import("./workflow").Workflow; // Typed workflow structure
mermaid_diagram?: string;
// Input specification for dynamic form generation
input_schema: JSONSchema; // JSON Schema for workflow input
input_type_name: string; // Human-readable input type name
start_executor_id: string; // Entry point executor ID
}
export interface ThreadInfo {
id: string;
agent_id: string;
created_at: string;
message_count: number;
}
export interface SessionInfo {
thread_id: string;
agent_id: string;
created_at: string;
messages: Array<Record<string, unknown>>;
metadata: Record<string, unknown>;
}
export interface RunAgentRequest {
input: import("./agent-framework").ResponseInputParam;
thread_id?: string;
}
export interface RunWorkflowRequest {
input_data: Record<string, unknown>;
}
// Legacy types - DEPRECATED - use new structured events from openai.ts instead
// Re-export OpenAI types
export type {
ResponseStreamEvent,
ResponseTextDeltaEvent,
OpenAIResponse,
OpenAIError,
// New structured event types
ExtendedResponseStreamEvent,
ResponseWorkflowEventComplete,
ResponseFunctionResultComplete,
ResponseTraceEventComplete,
ResponseUsageEventComplete,
StructuredEvent,
} from "./openai";
// Re-export Agent Framework types
export type {
AgentFrameworkRequest,
AgentFrameworkExtraBody,
ResponseInputParam,
ResponseInputTextParam,
ResponseInputImageParam,
ResponseInputFileParam,
} from "./agent-framework";
export interface HealthResponse {
status: "healthy";
agents_dir?: string;
version: string;
}
// Chat message types matching Agent Framework
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system" | "tool";
contents: import("./agent-framework").Contents[];
timestamp: string;
streaming?: boolean;
author_name?: string;
message_id?: string;
error?: boolean; // Flag to indicate this is an error message
}
// UI State types
export interface AppState {
selectedAgent?: AgentInfo | WorkflowInfo;
currentThread?: ThreadInfo;
agents: AgentInfo[];
workflows: WorkflowInfo[];
isLoading: boolean;
error?: string;
}
export interface ChatState {
messages: ChatMessage[];
isStreaming: boolean;
// streamEvents removed - use OpenAI events directly instead
}
@@ -0,0 +1,228 @@
/**
* OpenAI Response API types for Agent Framework Server
* Based on OpenAI's official response types
*/
// Core OpenAI Response Stream Event
export interface ResponseStreamEvent {
type: string;
item_id?: string;
output_index?: number;
content_index?: number;
sequence_number?: number;
// Different event types
delta?: string; // For text delta events
logprobs?: Record<string, unknown>[];
// Meta info
id?: string;
object?: string;
created_at?: number;
}
// Custom Agent Framework OpenAI event types with structured data
export interface ResponseWorkflowEventComplete {
type: "response.workflow_event.complete";
data: {
event_type: string;
data?: Record<string, unknown>;
executor_id?: string;
timestamp: string;
};
executor_id?: string;
item_id: string;
output_index: number;
sequence_number: number;
}
export interface ResponseFunctionResultComplete {
type: "response.function_result.complete";
data: {
call_id: string;
result: unknown;
status: "completed" | "failed";
exception?: string;
timestamp: string;
};
call_id: string;
item_id: string;
output_index: number;
sequence_number: number;
}
// Removed - using ResponseTraceEventComplete defined below
export interface ResponseUsageEventComplete {
type: "response.usage.complete";
data: {
usage_data: Record<string, unknown>;
total_tokens: number;
completion_tokens: number;
prompt_tokens: number;
timestamp: string;
};
item_id: string;
output_index: number;
sequence_number: number;
}
// Function call event types - matching actual backend output
export interface ResponseFunctionCallComplete {
type: "response.function_call.complete";
data: {
name: string;
arguments: string | object;
call_id: string;
};
item_id?: string;
output_index?: number;
sequence_number?: number;
}
export interface ResponseFunctionCallDelta {
type: "response.function_call.delta";
data: {
name?: string;
call_id?: string;
};
item_id?: string;
output_index?: number;
sequence_number?: number;
}
export interface ResponseFunctionCallArgumentsDelta {
type: "response.function_call_arguments.delta";
delta: string;
data?: {
call_id?: string;
arguments?: string;
};
item_id?: string;
output_index?: number;
sequence_number?: number;
}
// Trace event - matching actual backend output
export interface ResponseTraceEventComplete {
type: "response.trace_event.complete";
data: {
operation_name?: string;
duration_ms?: number;
status?: string;
attributes?: Record<string, unknown>;
timestamp: string;
};
item_id?: string;
output_index?: number;
sequence_number?: number;
}
// New trace event format from backend
export interface ResponseTraceComplete {
type: "response.trace.complete";
data: {
type?: string;
span_id?: string;
trace_id?: string;
parent_span_id?: string | null;
operation_name?: string;
start_time?: number;
end_time?: number;
duration_ms?: number;
attributes?: Record<string, unknown>;
status?: string;
session_id?: string | null;
entity_id?: string;
timestamp?: string;
};
item_id?: string;
output_index?: number;
sequence_number?: number;
}
// Error event - matching backend ResponseErrorEvent
export interface ResponseErrorEvent extends ResponseStreamEvent {
type: "error";
message: string;
code?: string;
param?: string;
sequence_number: number;
}
// Union type for all structured events
export type StructuredEvent =
| ResponseWorkflowEventComplete
| ResponseFunctionResultComplete
| ResponseTraceEventComplete
| ResponseTraceComplete
| ResponseUsageEventComplete
| ResponseFunctionCallComplete
| ResponseFunctionCallDelta
| ResponseFunctionCallArgumentsDelta
| ResponseErrorEvent;
// Extended stream event that includes our structured events
export type ExtendedResponseStreamEvent = ResponseStreamEvent | StructuredEvent;
// Text delta event - the main one we'll use
export interface ResponseTextDeltaEvent extends ResponseStreamEvent {
type: "response.output_text.delta";
delta: string;
item_id: string;
output_index: number;
content_index: number;
sequence_number: number;
logprobs: Record<string, unknown>[];
}
// OpenAI Response for non-streaming
export interface OpenAIResponse {
id: string;
object: "response";
created_at: number;
model: string;
output: ResponseOutputMessage[];
usage: ResponseUsage;
parallel_tool_calls: boolean;
tool_choice: string;
tools: Record<string, unknown>[];
}
export interface ResponseOutputMessage {
type: "message";
role: "assistant";
content: ResponseOutputText[];
id: string;
status: "completed" | "failed" | "in_progress";
}
export interface ResponseOutputText {
type: "output_text";
text: string;
annotations: Record<string, unknown>[];
}
export interface ResponseUsage {
input_tokens: number;
output_tokens: number;
total_tokens: number;
input_tokens_details: {
cached_tokens: number;
};
output_tokens_details: {
reasoning_tokens: number;
};
}
// Request format for Agent Framework
// AgentFrameworkRequest moved to agent-framework.ts to avoid conflicts
// Error response
export interface OpenAIError {
error: {
message: string;
type: string;
code?: string;
};
}
@@ -0,0 +1,159 @@
// TypeScript types that mirror the agent_framework_workflow structure
// for better type safety and consistency with the backend
/**
* Base executor interface that mirrors agent_framework_workflow._executor.Executor
*/
export interface Executor {
id: string;
type: string; // The executor class name (AgentExecutor, FunctionExecutor, etc.)
[key: string]: unknown; // Additional executor-specific properties
}
/**
* Specific executor types that extend the base Executor
*/
export interface AgentExecutor extends Executor {
type: "AgentExecutor";
agent_protocol?: unknown; // The wrapped agent
streaming: boolean;
}
export interface FunctionExecutor extends Executor {
type: "FunctionExecutor";
function_name?: string;
}
export interface RequestInfoExecutor extends Executor {
type: "RequestInfoExecutor";
}
export interface WorkflowExecutor extends Executor {
type: "WorkflowExecutor";
workflow: Workflow; // Nested workflow
}
/**
* Edge interface that mirrors agent_framework_workflow._edge.Edge
*/
export interface Edge {
source_id: string;
target_id: string;
condition_name?: string; // Name of condition function for serialization
}
/**
* Base edge group interface that mirrors agent_framework_workflow._edge.EdgeGroup
*/
export interface EdgeGroup {
id: string;
type: string; // The edge group class name
edges: Edge[];
}
/**
* Specific edge group types
*/
export interface SingleEdgeGroup extends EdgeGroup {
type: "SingleEdgeGroup";
}
export interface FanOutEdgeGroup extends EdgeGroup {
type: "FanOutEdgeGroup";
selection_func_name?: string; // Name of selection function
}
export interface FanInEdgeGroup extends EdgeGroup {
type: "FanInEdgeGroup";
}
export interface SwitchCaseEdgeGroup extends EdgeGroup {
type: "SwitchCaseEdgeGroup";
cases: Array<{
condition_name?: string;
target_id: string;
}>;
}
/**
* Main Workflow interface that mirrors agent_framework_workflow._workflow.Workflow
* This provides strong typing for the workflow_dump field
*/
export interface Workflow {
id: string;
edge_groups: EdgeGroup[];
executors: Record<string, Executor>;
start_executor_id: string;
max_iterations: number;
}
/**
* Type guards for runtime type checking
*/
export function isWorkflow(obj: unknown): obj is Workflow {
return (
typeof obj === "object" &&
obj !== null &&
"id" in obj &&
"edge_groups" in obj &&
"executors" in obj &&
"start_executor_id" in obj &&
"max_iterations" in obj &&
typeof (obj as any).id === "string" &&
Array.isArray((obj as any).edge_groups) &&
typeof (obj as any).executors === "object" &&
typeof (obj as any).start_executor_id === "string" &&
typeof (obj as any).max_iterations === "number"
);
}
export function isExecutor(obj: unknown): obj is Executor {
return (
typeof obj === "object" &&
obj !== null &&
"id" in obj &&
"type" in obj &&
typeof (obj as any).id === "string" &&
typeof (obj as any).type === "string"
);
}
export function isEdge(obj: unknown): obj is Edge {
return (
typeof obj === "object" &&
obj !== null &&
"source_id" in obj &&
"target_id" in obj &&
typeof (obj as any).source_id === "string" &&
typeof (obj as any).target_id === "string"
);
}
export function isEdgeGroup(obj: unknown): obj is EdgeGroup {
return (
typeof obj === "object" &&
obj !== null &&
"id" in obj &&
"type" in obj &&
"edges" in obj &&
typeof (obj as any).id === "string" &&
typeof (obj as any).type === "string" &&
Array.isArray((obj as any).edges)
);
}
/**
* Utility type for workflow dump that can be either a properly typed Workflow
* or a generic object (for backwards compatibility during transition)
*/
export type WorkflowDump = Workflow | Record<string, unknown>;
/**
* Helper function to safely access workflow dump as a typed Workflow
*/
export function getTypedWorkflow(workflowDump: WorkflowDump): Workflow | null {
if (isWorkflow(workflowDump)) {
return workflowDump;
}
return null;
}
@@ -0,0 +1,139 @@
import type { Node, Edge } from "@xyflow/react";
import type { ExecutorNodeData } from "@/components/workflow/executor-node";
/**
* Lightweight auto-layout algorithm to replace dagre
* Handles fan-out nodes properly by spacing siblings
*/
export function applySimpleLayout(
nodes: Node<ExecutorNodeData>[],
edges: Edge[],
direction: "TB" | "LR" = "LR"
): Node<ExecutorNodeData>[] {
if (nodes.length === 0) return nodes;
if (nodes.length === 1) {
return nodes.map((node) => ({
...node,
position: { x: 0, y: 0 },
}));
}
// Create adjacency maps
const outgoingEdges = new Map<string, string[]>();
const incomingEdges = new Map<string, string[]>();
nodes.forEach((node) => {
outgoingEdges.set(node.id, []);
incomingEdges.set(node.id, []);
});
edges.forEach((edge) => {
outgoingEdges.get(edge.source)?.push(edge.target);
incomingEdges.get(edge.target)?.push(edge.source);
});
// Find root nodes (nodes with no incoming edges)
const rootNodes = nodes.filter(
(node) => (incomingEdges.get(node.id) || []).length === 0
);
if (rootNodes.length === 0) {
// Fallback: use first node as root if no clear root
rootNodes.push(nodes[0]);
}
// Constants for spacing
const NODE_WIDTH = 220;
const NODE_HEIGHT = 120;
const HORIZONTAL_SPACING = direction === "LR" ? 350 : 200;
const VERTICAL_SPACING = direction === "TB" ? 250 : 180;
// Track positioned nodes and level information
const positioned = new Map<string, { x: number; y: number; level: number }>();
const levelGroups = new Map<number, string[]>();
// Build level groups using BFS
const queue: Array<{ nodeId: string; level: number }> = [];
const visited = new Set<string>();
// Start with root nodes at level 0
rootNodes.forEach((node) => {
queue.push({ nodeId: node.id, level: 0 });
});
// BFS to assign levels
while (queue.length > 0) {
const { nodeId, level } = queue.shift()!;
if (visited.has(nodeId)) continue;
visited.add(nodeId);
// Add to level group
if (!levelGroups.has(level)) {
levelGroups.set(level, []);
}
levelGroups.get(level)!.push(nodeId);
// Add children to next level
const children = outgoingEdges.get(nodeId) || [];
children.forEach((childId) => {
if (!visited.has(childId)) {
queue.push({ nodeId: childId, level: level + 1 });
}
});
}
// Handle orphaned nodes (not connected to root)
nodes.forEach((node) => {
if (!visited.has(node.id)) {
const maxLevel = Math.max(...Array.from(levelGroups.keys()), -1);
const orphanLevel = maxLevel + 1;
if (!levelGroups.has(orphanLevel)) {
levelGroups.set(orphanLevel, []);
}
levelGroups.get(orphanLevel)!.push(node.id);
}
});
// Position nodes level by level
levelGroups.forEach((nodeIds, level) => {
const nodeCount = nodeIds.length;
nodeIds.forEach((nodeId, index) => {
let x: number, y: number;
if (direction === "LR") {
// Horizontal layout: X increases with level, Y centers siblings
x = level * HORIZONTAL_SPACING;
// Center siblings vertically
const totalHeight = (nodeCount - 1) * VERTICAL_SPACING;
const startY = -totalHeight / 2;
y = startY + index * VERTICAL_SPACING;
} else {
// Vertical layout: Y increases with level, X centers siblings
y = level * VERTICAL_SPACING;
// Center siblings horizontally
const totalWidth = (nodeCount - 1) * HORIZONTAL_SPACING;
const startX = -totalWidth / 2;
x = startX + index * HORIZONTAL_SPACING;
}
positioned.set(nodeId, { x, y, level });
});
});
// Apply positions to nodes (centering them on their calculated positions)
return nodes.map((node) => {
const pos = positioned.get(node.id) || { x: 0, y: 0 };
return {
...node,
position: {
x: pos.x - NODE_WIDTH / 2, // Center the node
y: pos.y - NODE_HEIGHT / 2,
},
};
});
}
@@ -0,0 +1,515 @@
import { applySimpleLayout } from "./simple-layout";
import type { Node, Edge } from "@xyflow/react";
import type {
ExecutorNodeData,
ExecutorState,
} from "@/components/workflow/executor-node";
import type {
ExtendedResponseStreamEvent,
ResponseWorkflowEventComplete,
} from "@/types";
import type { Workflow } from "@/types/workflow";
import { getTypedWorkflow } from "@/types/workflow";
export interface WorkflowDumpExecutor {
id: string;
type: string;
name?: string;
description?: string;
config?: Record<string, unknown>;
}
interface RawExecutorData {
type_?: string;
type?: string;
name?: string;
description?: string;
config?: Record<string, unknown>;
}
export interface WorkflowDumpConnection {
source: string;
target: string;
condition?: string;
}
export interface WorkflowDump {
executors?: WorkflowDumpExecutor[];
connections?: WorkflowDumpConnection[];
start_executor?: string;
end_executors?: string[];
[key: string]: unknown; // Allow for additional properties
}
export interface NodeUpdate {
nodeId: string;
state: ExecutorState;
data?: unknown;
error?: string;
timestamp: string;
}
/**
* Convert workflow dump data to React Flow nodes
*/
export function convertWorkflowDumpToNodes(
workflowDump: Workflow | Record<string, unknown> | undefined,
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void
): Node<ExecutorNodeData>[] {
if (!workflowDump) {
console.warn("convertWorkflowDumpToNodes: workflowDump is undefined");
return [];
}
// Try to get typed workflow first, then fall back to generic handling
const typedWorkflow = getTypedWorkflow(workflowDump);
let executors: WorkflowDumpExecutor[];
let startExecutorId: string | undefined;
if (typedWorkflow) {
// Use typed workflow structure
executors = Object.values(typedWorkflow.executors).map((executor) => ({
id: executor.id,
type: executor.type,
name:
((executor as Record<string, unknown>).name as string) || executor.id,
description: (executor as Record<string, unknown>).description as string,
config: (executor as Record<string, unknown>).config as Record<
string,
unknown
>,
}));
startExecutorId = typedWorkflow.start_executor_id;
} else {
// Fall back to generic handling for backwards compatibility
executors = getExecutorsFromDump(workflowDump as Record<string, unknown>);
const workflowDumpRecord = workflowDump as Record<string, unknown>;
startExecutorId = workflowDumpRecord?.start_executor_id as
| string
| undefined;
}
if (!executors || !Array.isArray(executors) || executors.length === 0) {
console.warn(
"No executors found in workflow dump. Available keys:",
Object.keys(workflowDump)
);
return [];
}
const nodes = executors.map((executor) => ({
id: executor.id,
type: "executor",
position: { x: 0, y: 0 }, // Will be set by layout algorithm
data: {
executorId: executor.id,
executorType: executor.type,
name: executor.name || executor.id,
state: "pending" as ExecutorState,
isStartNode: executor.id === startExecutorId,
onNodeClick,
},
}));
return nodes;
}
/**
* Convert workflow dump data to React Flow edges
*/
export function convertWorkflowDumpToEdges(
workflowDump: Workflow | Record<string, unknown> | undefined
): Edge[] {
if (!workflowDump) {
console.warn("convertWorkflowDumpToEdges: workflowDump is undefined");
return [];
}
// Try to get typed workflow first, then fall back to generic handling
const typedWorkflow = getTypedWorkflow(workflowDump);
let connections: WorkflowDumpConnection[];
if (typedWorkflow) {
// Use typed workflow structure to extract connections from edge_groups
connections = [];
typedWorkflow.edge_groups.forEach((group) => {
group.edges.forEach((edge) => {
connections.push({
source: edge.source_id,
target: edge.target_id,
condition: edge.condition_name,
});
});
});
} else {
// Fall back to generic handling for backwards compatibility
connections = getConnectionsFromDump(
workflowDump as Record<string, unknown>
);
}
if (!connections || !Array.isArray(connections) || connections.length === 0) {
console.warn(
"No connections found in workflow dump. Available keys:",
Object.keys(workflowDump)
);
return [];
}
const edges = connections.map((connection) => ({
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
type: "default",
animated: false,
style: {
stroke: "#6b7280",
strokeWidth: 2,
},
}));
return edges;
}
/**
* Extract executors from workflow dump - handles different possible structures
*/
function getExecutorsFromDump(
workflowDump: Record<string, unknown>
): WorkflowDumpExecutor[] {
// First check if executors is an object (like in the actual dump structure)
if (
workflowDump.executors &&
typeof workflowDump.executors === "object" &&
!Array.isArray(workflowDump.executors)
) {
const executorsObj = workflowDump.executors as Record<
string,
RawExecutorData
>;
return Object.entries(executorsObj).map(([id, executor]) => ({
id,
type: executor.type_ || executor.type || "executor",
name: executor.name || id,
description: executor.description,
config: executor.config,
}));
}
// Try different possible keys where executors might be stored as arrays
const possibleKeys = ["executors", "agents", "steps", "nodes"];
for (const key of possibleKeys) {
if (workflowDump[key] && Array.isArray(workflowDump[key])) {
return workflowDump[key] as WorkflowDumpExecutor[];
}
}
// If no direct array, try to extract from nested structures
if (workflowDump.config && typeof workflowDump.config === "object") {
return getExecutorsFromDump(workflowDump.config as Record<string, unknown>);
}
// Fallback: create executors from any object keys that look like executor IDs
const executors: WorkflowDumpExecutor[] = [];
Object.entries(workflowDump).forEach(([key, value]) => {
if (
typeof value === "object" &&
value !== null &&
("type" in value || "type_" in value)
) {
const rawExecutor = value as RawExecutorData;
executors.push({
id: key,
type: rawExecutor.type_ || rawExecutor.type || "executor",
name: rawExecutor.name || key,
description: rawExecutor.description,
config: rawExecutor.config,
});
}
});
return executors;
}
/**
* Extract connections from workflow dump - handles different possible structures
*/
function getConnectionsFromDump(
workflowDump: Record<string, unknown>
): WorkflowDumpConnection[] {
// Handle edge_groups structure (actual dump format)
if (workflowDump.edge_groups && Array.isArray(workflowDump.edge_groups)) {
const connections: WorkflowDumpConnection[] = [];
workflowDump.edge_groups.forEach((group: unknown) => {
if (typeof group === "object" && group !== null && "edges" in group) {
const edges = (group as { edges: unknown }).edges;
if (Array.isArray(edges)) {
edges.forEach((edge: unknown) => {
if (
typeof edge === "object" &&
edge !== null &&
"source_id" in edge &&
"target_id" in edge
) {
const edgeObj = edge as {
source_id: string;
target_id: string;
condition_name?: string;
};
connections.push({
source: edgeObj.source_id,
target: edgeObj.target_id,
condition: edgeObj.condition_name || undefined,
});
}
});
}
}
});
return connections;
}
// Try different possible keys where connections might be stored
const possibleKeys = ["connections", "edges", "transitions", "links"];
for (const key of possibleKeys) {
if (workflowDump[key] && Array.isArray(workflowDump[key])) {
return workflowDump[key] as WorkflowDumpConnection[];
}
}
// If no direct array, try to extract from nested structures
if (workflowDump.config && typeof workflowDump.config === "object") {
return getConnectionsFromDump(
workflowDump.config as Record<string, unknown>
);
}
return [];
}
/**
* Apply auto-layout to nodes using a lightweight algorithm
* Replaces dagre to eliminate 4.88MB lodash dependency
*/
export function applyDagreLayout(
nodes: Node<ExecutorNodeData>[],
edges: Edge[],
direction: "TB" | "LR" = "LR"
): Node<ExecutorNodeData>[] {
return applySimpleLayout(nodes, edges, direction);
}
/**
* Process workflow events and extract node updates
*/
export function processWorkflowEvents(
events: ExtendedResponseStreamEvent[]
): Record<string, NodeUpdate> {
const nodeUpdates: Record<string, NodeUpdate> = {};
events.forEach((event) => {
if (
event.type === "response.workflow_event.complete" &&
"data" in event &&
event.data
) {
const workflowEvent = event as ResponseWorkflowEventComplete;
const data = workflowEvent.data;
const executorId = data.executor_id;
const eventType = data.event_type;
const eventData = data.data;
let state: ExecutorState = "pending";
let error: string | undefined;
// Map event types to executor states
if (eventType === "ExecutorInvokedEvent") {
state = "running";
} else if (eventType === "ExecutorCompletedEvent") {
state = "completed";
} else if (
eventType?.includes("Error") ||
eventType?.includes("Failed")
) {
state = "failed";
error = typeof eventData === "string" ? eventData : "Execution failed";
} else if (eventType?.includes("Cancel")) {
state = "cancelled";
} else if (eventType === "WorkflowCompletedEvent") {
state = "completed";
}
// Update the node state (keep most recent update per executor)
if (executorId) {
nodeUpdates[executorId] = {
nodeId: executorId,
state,
data: eventData,
error,
timestamp: new Date().toISOString(),
};
}
}
});
return nodeUpdates;
}
/**
* Update node states based on event processing
*/
export function updateNodesWithEvents(
nodes: Node<ExecutorNodeData>[],
nodeUpdates: Record<string, NodeUpdate>
): Node<ExecutorNodeData>[] {
return nodes.map((node) => {
const update = nodeUpdates[node.id];
if (update) {
return {
...node,
data: {
...node.data,
state: update.state,
outputData: update.data,
error: update.error,
},
};
}
return node;
});
}
/**
* Get executors that are currently in execution (invoked but not yet completed)
*/
export function getCurrentlyExecutingExecutors(
events: ExtendedResponseStreamEvent[]
): string[] {
const executorTimeline: Record<
string,
{ lastEvent: string; timestamp: string }
> = {};
// Process events to find the most recent event for each executor
events.forEach((event) => {
if (
event.type === "response.workflow_event.complete" &&
"data" in event &&
event.data
) {
const workflowEvent = event as ResponseWorkflowEventComplete;
const data = workflowEvent.data;
const executorId = data.executor_id;
const eventType = data.event_type;
if (
executorId &&
(eventType === "ExecutorInvokedEvent" ||
eventType === "ExecutorCompletedEvent")
) {
executorTimeline[executorId] = {
lastEvent: eventType,
timestamp: new Date().toISOString(),
};
}
}
});
// Find executors that were invoked but haven't completed yet
const currentlyExecuting = Object.entries(executorTimeline)
.filter(([, timeline]) => timeline.lastEvent === "ExecutorInvokedEvent")
.map(([executorId]) => executorId);
return currentlyExecuting;
}
/**
* Update edges with sequence-based animation
*/
export function updateEdgesWithSequenceAnalysis(
edges: Edge[],
events: ExtendedResponseStreamEvent[]
): Edge[] {
const currentlyExecuting = getCurrentlyExecutingExecutors(events);
// Build simple state tracking for each executor
const executorStates: Record<
string,
{ completed: boolean; invoked: boolean }
> = {};
events.forEach((event) => {
if (
event.type === "response.workflow_event.complete" &&
"data" in event &&
event.data
) {
const workflowEvent = event as ResponseWorkflowEventComplete;
const data = workflowEvent.data;
const executorId = data.executor_id;
const eventType = data.event_type;
if (executorId && eventType) {
if (!executorStates[executorId]) {
executorStates[executorId] = { completed: false, invoked: false };
}
if (eventType === "ExecutorInvokedEvent") {
executorStates[executorId].invoked = true;
} else if (eventType === "ExecutorCompletedEvent") {
executorStates[executorId].completed = true;
}
}
}
});
return edges.map((edge) => {
const sourceState = executorStates[edge.source];
const targetState = executorStates[edge.target];
const targetIsExecuting = currentlyExecuting.includes(edge.target);
let style = { ...edge.style };
let animated = false;
// Active edge: source completed and target is currently executing
if (sourceState?.completed && targetIsExecuting) {
style = {
stroke: "#3b82f6", // Blue
strokeWidth: 3,
strokeDasharray: "5,5",
};
animated = true;
}
// Completed edge: both source and target have completed
else if (sourceState?.completed && targetState?.completed) {
style = {
stroke: "#10b981", // Green
strokeWidth: 2,
};
}
// Invoked edge: source completed and target invoked (but not necessarily executing)
else if (sourceState?.completed && targetState?.invoked) {
style = {
stroke: "#f59e0b", // Orange
strokeWidth: 2,
};
}
// Default: Not traversed
else {
style = {
stroke: "#6b7280", // Gray
strokeWidth: 2,
};
}
return {
...edge,
style,
animated,
};
});
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,34 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "../agent_framework_devui/ui",
emptyOutDir: true,
rollupOptions: {
output: {
// Minimize to just 2 files: main app + CSS
manualChunks: undefined,
// Ensure everything goes into a single JS file
inlineDynamicImports: true,
},
},
},
// Ensure proper tree-shaking
optimizeDeps: {
include: ["lucide-react", "@xyflow/react"],
},
// Enable aggressive tree-shaking
esbuild: {
treeShaking: true,
},
});
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
[project]
name = "agent-framework-devui"
description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API server."
authors = [{ name = "Microsoft", email = "SK-Support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "0.1.0b1"
license-files = ["LICENSE"]
urls.homepage = "https://learn.microsoft.com/en-us/semantic-kernel/overview/"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Framework :: Pydantic :: 2",
"Typing :: Typed",
]
dependencies = [
"agent-framework",
"fastapi>=0.104.0",
"uvicorn[standard]>=0.24.0",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
dev = ["pytest>=7.0.0", "watchdog>=3.0.0"]
all = ["pytest>=7.0.0", "watchdog>=3.0.0"]
[project.scripts]
devui = "agent_framework_devui:main"
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extend = "../../pyproject.toml"
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
disallow_any_unimported = true
[tool.bandit]
targets = ["agent_framework_devui"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_devui"
test = "pytest --cov=agent_framework_devui --cov-report=term-missing:skip-covered tests"
[tool.uv.build-backend]
module-name = "agent_framework_devui"
module-root = ""
[build-system]
requires = ["uv_build>=0.8.2,<0.9.0"]
build-backend = "uv_build"
@@ -0,0 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
"""Examples package for Agent Framework DevUI."""
@@ -0,0 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
"""Fanout workflow example."""
@@ -0,0 +1,698 @@
# Copyright (c) Microsoft. All rights reserved.
"""Complex Fan-In/Fan-Out Data Processing Workflow.
This workflow demonstrates a sophisticated data processing pipeline with multiple stages:
1. Data Ingestion - Simulates loading data from multiple sources
2. Data Validation - Multiple validators run in parallel to check data quality
3. Data Transformation - Fan-out to different transformation processors
4. Quality Assurance - Multiple QA checks run in parallel
5. Data Aggregation - Fan-in to combine processed results
6. Final Processing - Generate reports and complete workflow
The workflow includes realistic delays to simulate actual processing time and
shows complex fan-in/fan-out patterns with conditional processing.
"""
import asyncio
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Literal
from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
from pydantic import BaseModel, Field
class DataType(Enum):
"""Types of data being processed."""
CUSTOMER = "customer"
TRANSACTION = "transaction"
PRODUCT = "product"
ANALYTICS = "analytics"
class ValidationResult(Enum):
"""Results of data validation."""
VALID = "valid"
WARNING = "warning"
ERROR = "error"
class ProcessingRequest(BaseModel):
"""Complex input structure for data processing workflow."""
# Basic information
data_source: Literal["database", "api", "file_upload", "streaming"] = Field(
description="The source of the data to be processed", default="database"
)
data_type: Literal["customer", "transaction", "product", "analytics"] = Field(
description="Type of data being processed", default="customer"
)
processing_priority: Literal["low", "normal", "high", "critical"] = Field(
description="Processing priority level", default="normal"
)
# Processing configuration
batch_size: int = Field(description="Number of records to process in each batch", default=500, ge=100, le=10000)
quality_threshold: float = Field(
description="Minimum quality score required (0.0-1.0)", default=0.8, ge=0.0, le=1.0
)
# Validation settings
enable_schema_validation: bool = Field(description="Enable schema validation checks", default=True)
enable_security_validation: bool = Field(description="Enable security validation checks", default=True)
enable_quality_validation: bool = Field(description="Enable data quality validation checks", default=True)
# Transformation options
transformations: list[Literal["normalize", "enrich", "aggregate"]] = Field(
description="List of transformations to apply", default=["normalize", "enrich"]
)
# Optional description
description: str | None = Field(description="Optional description of the processing request", default=None)
# Test failure scenarios
force_validation_failure: bool = Field(
description="Force validation failure for testing (demo purposes)", default=False
)
force_transformation_failure: bool = Field(
description="Force transformation failure for testing (demo purposes)", default=False
)
@dataclass
class DataBatch:
"""Represents a batch of data being processed."""
batch_id: str
data_type: DataType
size: int
content: str
source: str = "unknown"
timestamp: float = 0.0
@dataclass
class ValidationReport:
"""Report from data validation."""
batch_id: str
validator_id: str
result: ValidationResult
issues_found: int
processing_time: float
details: str
@dataclass
class TransformationResult:
"""Result from data transformation."""
batch_id: str
transformer_id: str
original_size: int
processed_size: int
transformation_type: str
processing_time: float
success: bool
@dataclass
class QualityAssessment:
"""Quality assessment result."""
batch_id: str
assessor_id: str
quality_score: float
recommendations: list[str]
processing_time: float
@dataclass
class ProcessingSummary:
"""Summary of all processing stages."""
batch_id: str
total_processing_time: float
validation_reports: list[ValidationReport]
transformation_results: list[TransformationResult]
quality_assessments: list[QualityAssessment]
final_status: str
# Data Ingestion Stage
class DataIngestion(Executor):
"""Simulates ingesting data from multiple sources with delays."""
@handler
async def ingest_data(self, request: ProcessingRequest, ctx: WorkflowContext[DataBatch]) -> None:
"""Simulate data ingestion with realistic delays based on input configuration."""
# Simulate network delay based on data source
delay_map = {"database": 1.5, "api": 3.0, "file_upload": 4.0, "streaming": 1.0}
delay = delay_map.get(request.data_source, 3.0)
await asyncio.sleep(delay) # Fixed delay for demo
# Simulate data size based on priority and configuration
base_size = request.batch_size
if request.processing_priority == "critical":
size_multiplier = 1.7 # Critical priority gets the largest batches
elif request.processing_priority == "high":
size_multiplier = 1.3 # High priority gets larger batches
elif request.processing_priority == "low":
size_multiplier = 0.6 # Low priority gets smaller batches
else: # normal
size_multiplier = 1.0 # Normal priority uses base size
actual_size = int(base_size * size_multiplier)
batch = DataBatch(
batch_id=f"batch_{5555}", # Fixed batch ID for demo
data_type=DataType(request.data_type),
size=actual_size,
content=f"Processing {request.data_type} data from {request.data_source}",
source=request.data_source,
timestamp=asyncio.get_event_loop().time(),
)
# Store both batch data and original request in shared state
await ctx.set_shared_state(f"batch_{batch.batch_id}", batch)
await ctx.set_shared_state(f"request_{batch.batch_id}", request)
await ctx.send_message(batch)
# Validation Stage (Fan-out)
class SchemaValidator(Executor):
"""Validates data schema and structure."""
@handler
async def validate_schema(self, batch: DataBatch, ctx: WorkflowContext[ValidationReport]) -> None:
"""Perform schema validation with processing delay."""
# Check if schema validation is enabled
request = await ctx.get_shared_state(f"request_{batch.batch_id}")
if not request or not request.enable_schema_validation:
return
# Simulate schema validation processing
processing_time = 2.0 # Fixed processing time
await asyncio.sleep(processing_time)
# Simulate validation results - consider force failure flag
issues = 4 if request.force_validation_failure else 2 # Fixed issue counts
result = (
ValidationResult.VALID
if issues <= 1
else (ValidationResult.WARNING if issues <= 2 else ValidationResult.ERROR)
)
report = ValidationReport(
batch_id=batch.batch_id,
validator_id=self.id,
result=result,
issues_found=issues,
processing_time=processing_time,
details=f"Schema validation found {issues} issues in {batch.data_type.value} data from {batch.source}",
)
await ctx.send_message(report)
class DataQualityValidator(Executor):
"""Validates data quality and completeness."""
@handler
async def validate_quality(self, batch: DataBatch, ctx: WorkflowContext[ValidationReport]) -> None:
"""Perform data quality validation."""
# Check if quality validation is enabled
request = await ctx.get_shared_state(f"request_{batch.batch_id}")
if not request or not request.enable_quality_validation:
return
processing_time = 2.5 # Fixed processing time
await asyncio.sleep(processing_time)
# Quality checks are stricter for higher priority data
issues = (
2 # Fixed issue count for high priority
if request.processing_priority in ["critical", "high"]
else 3 # Fixed issue count for normal priority
)
if request.force_validation_failure:
issues = max(issues, 4) # Ensure failure
result = (
ValidationResult.VALID
if issues <= 1
else (ValidationResult.WARNING if issues <= 3 else ValidationResult.ERROR)
)
report = ValidationReport(
batch_id=batch.batch_id,
validator_id=self.id,
result=result,
issues_found=issues,
processing_time=processing_time,
details=f"Quality check found {issues} data quality issues (priority: {request.processing_priority})",
)
await ctx.send_message(report)
class SecurityValidator(Executor):
"""Validates data for security and compliance issues."""
@handler
async def validate_security(self, batch: DataBatch, ctx: WorkflowContext[ValidationReport]) -> None:
"""Perform security validation."""
# Check if security validation is enabled
request = await ctx.get_shared_state(f"request_{batch.batch_id}")
if not request or not request.enable_security_validation:
return
processing_time = 3.0 # Fixed processing time
await asyncio.sleep(processing_time)
# Security is more stringent for customer/transaction data
issues = 1 if batch.data_type in [DataType.CUSTOMER, DataType.TRANSACTION] else 2
if request.force_validation_failure:
issues = max(issues, 1) # Force at least one security issue
# Security errors are more serious - less tolerance
result = ValidationResult.VALID if issues == 0 else ValidationResult.ERROR
report = ValidationReport(
batch_id=batch.batch_id,
validator_id=self.id,
result=result,
issues_found=issues,
processing_time=processing_time,
details=f"Security scan found {issues} security issues in {batch.data_type.value} data",
)
await ctx.send_message(report)
# Validation Aggregator (Fan-in)
class ValidationAggregator(Executor):
"""Aggregates validation results and decides on next steps."""
@handler
async def aggregate_validations(self, reports: list[ValidationReport], ctx: WorkflowContext[DataBatch]) -> None:
"""Aggregate all validation reports and make processing decision."""
if not reports:
return
batch_id = reports[0].batch_id
request = await ctx.get_shared_state(f"request_{batch_id}")
await asyncio.sleep(1) # Aggregation processing time
total_issues = sum(report.issues_found for report in reports)
has_errors = any(report.result == ValidationResult.ERROR for report in reports)
# Calculate quality score (0.0 to 1.0)
max_possible_issues = len(reports) * 5 # Assume max 5 issues per validator
quality_score = max(0.0, 1.0 - (total_issues / max_possible_issues))
# Decision logic: fail if errors OR quality below threshold
should_fail = has_errors or (quality_score < request.quality_threshold)
if should_fail:
failure_reason = []
if has_errors:
failure_reason.append("validation errors detected")
if quality_score < request.quality_threshold:
failure_reason.append(
f"quality score {quality_score:.2f} below threshold {request.quality_threshold:.2f}"
)
reason = " and ".join(failure_reason)
await ctx.add_event(
WorkflowCompletedEvent(
f"Batch {batch_id} failed validation: {reason}. "
f"Total issues: {total_issues}, Quality score: {quality_score:.2f}"
)
)
return
# Retrieve original batch from shared state
batch_data = await ctx.get_shared_state(f"batch_{batch_id}")
if batch_data:
await ctx.send_message(batch_data)
else:
# Fallback: create a simplified batch
batch = DataBatch(
batch_id=batch_id,
data_type=DataType.ANALYTICS,
size=500,
content="Validated data ready for transformation",
)
await ctx.send_message(batch)
# Transformation Stage (Fan-out)
class DataNormalizer(Executor):
"""Normalizes and cleans data."""
@handler
async def normalize_data(self, batch: DataBatch, ctx: WorkflowContext[TransformationResult]) -> None:
"""Perform data normalization."""
request = await ctx.get_shared_state(f"request_{batch.batch_id}")
# Check if normalization is enabled
if not request or "normalize" not in request.transformations:
# Send a "skipped" result
result = TransformationResult(
batch_id=batch.batch_id,
transformer_id=self.id,
original_size=batch.size,
processed_size=batch.size,
transformation_type="normalization",
processing_time=0.1,
success=True, # Consider skipped as successful
)
await ctx.send_message(result)
return
processing_time = 4.0 # Fixed processing time
await asyncio.sleep(processing_time)
# Simulate data size change during normalization
processed_size = int(batch.size * 1.0) # No size change for demo
# Consider force failure flag
success = not request.force_transformation_failure # 75% success rate simplified to always success
result = TransformationResult(
batch_id=batch.batch_id,
transformer_id=self.id,
original_size=batch.size,
processed_size=processed_size,
transformation_type="normalization",
processing_time=processing_time,
success=success,
)
await ctx.send_message(result)
class DataEnrichment(Executor):
"""Enriches data with additional information."""
@handler
async def enrich_data(self, batch: DataBatch, ctx: WorkflowContext[TransformationResult]) -> None:
"""Perform data enrichment."""
request = await ctx.get_shared_state(f"request_{batch.batch_id}")
# Check if enrichment is enabled
if not request or "enrich" not in request.transformations:
# Send a "skipped" result
result = TransformationResult(
batch_id=batch.batch_id,
transformer_id=self.id,
original_size=batch.size,
processed_size=batch.size,
transformation_type="enrichment",
processing_time=0.1,
success=True, # Consider skipped as successful
)
await ctx.send_message(result)
return
processing_time = 5.0 # Fixed processing time
await asyncio.sleep(processing_time)
processed_size = int(batch.size * 1.3) # Enrichment increases data
# Consider force failure flag
success = not request.force_transformation_failure # 67% success rate simplified to always success
result = TransformationResult(
batch_id=batch.batch_id,
transformer_id=self.id,
original_size=batch.size,
processed_size=processed_size,
transformation_type="enrichment",
processing_time=processing_time,
success=success,
)
await ctx.send_message(result)
class DataAggregator(Executor):
"""Aggregates and summarizes data."""
@handler
async def aggregate_data(self, batch: DataBatch, ctx: WorkflowContext[TransformationResult]) -> None:
"""Perform data aggregation."""
request = await ctx.get_shared_state(f"request_{batch.batch_id}")
# Check if aggregation is enabled
if not request or "aggregate" not in request.transformations:
# Send a "skipped" result
result = TransformationResult(
batch_id=batch.batch_id,
transformer_id=self.id,
original_size=batch.size,
processed_size=batch.size,
transformation_type="aggregation",
processing_time=0.1,
success=True, # Consider skipped as successful
)
await ctx.send_message(result)
return
processing_time = 2.5 # Fixed processing time
await asyncio.sleep(processing_time)
processed_size = int(batch.size * 0.5) # Aggregation reduces data
# Consider force failure flag
success = not request.force_transformation_failure # 80% success rate simplified to always success
result = TransformationResult(
batch_id=batch.batch_id,
transformer_id=self.id,
original_size=batch.size,
processed_size=processed_size,
transformation_type="aggregation",
processing_time=processing_time,
success=success,
)
await ctx.send_message(result)
# Quality Assurance Stage (Fan-out)
class PerformanceAssessor(Executor):
"""Assesses performance characteristics of processed data."""
@handler
async def assess_performance(
self, results: list[TransformationResult], ctx: WorkflowContext[QualityAssessment]
) -> None:
"""Assess performance of transformations."""
if not results:
return
batch_id = results[0].batch_id
processing_time = 2.0 # Fixed processing time
await asyncio.sleep(processing_time)
avg_processing_time = sum(r.processing_time for r in results) / len(results)
success_rate = sum(1 for r in results if r.success) / len(results)
quality_score = (success_rate * 0.7 + (1 - min(avg_processing_time / 10, 1)) * 0.3) * 100
recommendations = []
if success_rate < 0.8:
recommendations.append("Consider improving transformation reliability")
if avg_processing_time > 5:
recommendations.append("Optimize processing performance")
if quality_score < 70:
recommendations.append("Review overall data pipeline efficiency")
assessment = QualityAssessment(
batch_id=batch_id,
assessor_id=self.id,
quality_score=quality_score,
recommendations=recommendations,
processing_time=processing_time,
)
await ctx.send_message(assessment)
class AccuracyAssessor(Executor):
"""Assesses accuracy and correctness of processed data."""
@handler
async def assess_accuracy(
self, results: list[TransformationResult], ctx: WorkflowContext[QualityAssessment]
) -> None:
"""Assess accuracy of transformations."""
if not results:
return
batch_id = results[0].batch_id
processing_time = 3.0 # Fixed processing time
await asyncio.sleep(processing_time)
# Simulate accuracy analysis
accuracy_score = 85.0 # Fixed accuracy score
recommendations = []
if accuracy_score < 85:
recommendations.append("Review data transformation algorithms")
if accuracy_score < 80:
recommendations.append("Implement additional validation steps")
assessment = QualityAssessment(
batch_id=batch_id,
assessor_id=self.id,
quality_score=accuracy_score,
recommendations=recommendations,
processing_time=processing_time,
)
await ctx.send_message(assessment)
# Final Processing and Completion
class FinalProcessor(Executor):
"""Final processing stage that combines all results."""
@handler
async def process_final_results(self, assessments: list[QualityAssessment], ctx: WorkflowContext[None]) -> None:
"""Generate final processing summary and complete workflow."""
if not assessments:
await ctx.add_event(WorkflowCompletedEvent("No quality assessments received"))
return
batch_id = assessments[0].batch_id
# Simulate final processing delay
await asyncio.sleep(2)
# Calculate overall metrics
avg_quality_score = sum(a.quality_score for a in assessments) / len(assessments)
total_recommendations = sum(len(a.recommendations) for a in assessments)
total_processing_time = sum(a.processing_time for a in assessments)
# Determine final status
if avg_quality_score >= 85:
final_status = "EXCELLENT"
elif avg_quality_score >= 75:
final_status = "GOOD"
elif avg_quality_score >= 65:
final_status = "ACCEPTABLE"
else:
final_status = "NEEDS_IMPROVEMENT"
completion_message = (
f"Batch {batch_id} processing completed!\n"
f"📊 Overall Quality Score: {avg_quality_score:.1f}%\n"
f"⏱️ Total Processing Time: {total_processing_time:.1f}s\n"
f"💡 Total Recommendations: {total_recommendations}\n"
f"🎖️ Final Status: {final_status}"
)
await ctx.add_event(WorkflowCompletedEvent(completion_message))
# Workflow Builder Helper
class WorkflowSetupHelper:
"""Helper class to set up the complex workflow with shared state management."""
@staticmethod
async def store_batch_data(batch: DataBatch, ctx: WorkflowContext) -> None:
"""Store batch data in shared state for later retrieval."""
await ctx.set_shared_state(f"batch_{batch.batch_id}", batch)
# Create the workflow instance
def create_complex_workflow():
"""Create the complex fan-in/fan-out workflow."""
# Create all executors
data_ingestion = DataIngestion(id="data_ingestion")
# Validation stage (fan-out)
schema_validator = SchemaValidator(id="schema_validator")
quality_validator = DataQualityValidator(id="quality_validator")
security_validator = SecurityValidator(id="security_validator")
validation_aggregator = ValidationAggregator(id="validation_aggregator")
# Transformation stage (fan-out)
data_normalizer = DataNormalizer(id="data_normalizer")
data_enrichment = DataEnrichment(id="data_enrichment")
data_aggregator_exec = DataAggregator(id="data_aggregator")
# Quality assurance stage (fan-out)
performance_assessor = PerformanceAssessor(id="performance_assessor")
accuracy_assessor = AccuracyAssessor(id="accuracy_assessor")
# Final processing
final_processor = FinalProcessor(id="final_processor")
# Build the workflow with complex fan-in/fan-out patterns
return (
WorkflowBuilder()
.set_start_executor(data_ingestion)
# Fan-out to validation stage
.add_fan_out_edges(data_ingestion, [schema_validator, quality_validator, security_validator])
# Fan-in from validation to aggregator
.add_fan_in_edges([schema_validator, quality_validator, security_validator], validation_aggregator)
# Fan-out to transformation stage
.add_fan_out_edges(validation_aggregator, [data_normalizer, data_enrichment, data_aggregator_exec])
# Fan-in to quality assurance stage (both assessors receive all transformation results)
.add_fan_in_edges([data_normalizer, data_enrichment, data_aggregator_exec], performance_assessor)
.add_fan_in_edges([data_normalizer, data_enrichment, data_aggregator_exec], accuracy_assessor)
# Fan-in to final processor
.add_fan_in_edges([performance_assessor, accuracy_assessor], final_processor)
.build()
)
# Export the workflow for DevUI discovery
workflow = create_complex_workflow()
def main():
"""Launch the fanout workflow in DevUI."""
from agent_framework.devui import serve
# Setup logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
logger.info("Starting Complex Fan-In/Fan-Out Data Processing Workflow")
logger.info("Available at: http://localhost:8090")
logger.info("Entity ID: workflow_complex_workflow")
# Launch server with the workflow
serve(entities=[workflow], port=8090, auto_open=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,71 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example of using Agent Framework DevUI with in-memory agent registration.
This demonstrates the simplest way to serve agents as OpenAI-compatible API endpoints.
"""
import logging
from typing import Annotated
from agent_framework import ChatAgent
from agent_framework.devui import serve
from agent_framework.openai import OpenAIChatClient
def get_weather(
location: Annotated[str, "The location to get the weather for."],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
temperature = 53
return f"The weather in {location} is {conditions[0]} with a high of {temperature}°C."
def get_time(
timezone: Annotated[str, "The timezone to get time for."] = "UTC",
) -> str:
"""Get current time for a timezone."""
from datetime import datetime
# Simplified for example
return f"Current time in {timezone}: {datetime.now().strftime('%H:%M:%S')}"
def main():
"""Main function demonstrating in-memory agent registration."""
# Setup logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
# Create agents in code
weather_agent = ChatAgent(
name="weather-assistant",
description="Provides weather information and time",
instructions=(
"You are a helpful weather and time assistant. Use the available tools to "
"provide accurate weather information and current time for any location."
),
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-mini"),
tools=[get_weather, get_time],
)
simple_agent = ChatAgent(
name="general-assistant",
description="A simple conversational agent",
instructions="You are a helpful assistant.",
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-mini"),
)
# Collect entities for serving
entities = [weather_agent, simple_agent]
logger.info("Starting DevUI on http://localhost:8090")
logger.info("Entity IDs: agent_weather-assistant, agent_general-assistant")
# Launch server with auto-generated entity IDs
serve(entities=entities, port=8090, auto_open=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
"""Spam detection workflow sample for DevUI testing."""
from .workflow import workflow
__all__ = ["workflow"]
@@ -0,0 +1,333 @@
# Copyright (c) Microsoft. All rights reserved.
"""Spam Detection Workflow Sample for DevUI.
The following sample demonstrates a comprehensive 5-step workflow with multiple executors
that process, analyze, detect spam, and handle email messages. This workflow illustrates
complex branching logic and realistic processing delays to demonstrate the workflow framework.
Workflow Steps:
1. Email Preprocessor - Cleans and prepares the email
2. Content Analyzer - Analyzes email content and structure
3. Spam Detector - Determines if the message is spam
4a. Spam Handler - Processes spam messages (quarantine, log, remove)
4b. Message Responder - Handles legitimate messages (validate, respond)
5. Final Processor - Completes the workflow with logging and cleanup
"""
import asyncio
import logging
from dataclasses import dataclass
from agent_framework import (
Case,
Default,
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
from pydantic import BaseModel, Field
@dataclass
class EmailContent:
"""A data class to hold the processed email content."""
original_message: str
cleaned_message: str
word_count: int
has_suspicious_patterns: bool = False
@dataclass
class ContentAnalysis:
"""A data class to hold content analysis results."""
email_content: EmailContent
sentiment_score: float
contains_links: bool
has_attachments: bool
risk_indicators: list[str]
@dataclass
class SpamDetectorResponse:
"""A data class to hold the spam detection results."""
analysis: ContentAnalysis
is_spam: bool = False
confidence_score: float = 0.0
spam_reasons: list[str] | None = None
def __post_init__(self):
"""Initialize spam_reasons list if None."""
if self.spam_reasons is None:
self.spam_reasons = []
@dataclass
class ProcessingResult:
"""A data class to hold the final processing result."""
original_message: str
action_taken: str
processing_time: float
status: str
is_spam: bool
confidence_score: float
spam_reasons: list[str]
class EmailRequest(BaseModel):
"""Request model for email processing."""
email: str = Field(
description="The email message to be processed.",
default="Hi there, are you interested in our new urgent offer today? Click here!",
)
class EmailPreprocessor(Executor):
"""Step 1: An executor that preprocesses and cleans email content."""
@handler
async def handle_email(self, email: EmailRequest, ctx: WorkflowContext[EmailContent]) -> None:
"""Clean and preprocess the email message."""
await asyncio.sleep(1.5) # Simulate preprocessing time
# Simulate email cleaning
cleaned = email.email.strip().lower()
word_count = len(email.email.split())
# Check for suspicious patterns
suspicious_patterns = ["urgent", "limited time", "act now", "free money"]
has_suspicious = any(pattern in cleaned for pattern in suspicious_patterns)
result = EmailContent(
original_message=email.email,
cleaned_message=cleaned,
word_count=word_count,
has_suspicious_patterns=has_suspicious,
)
await ctx.send_message(result)
class ContentAnalyzer(Executor):
"""Step 2: An executor that analyzes email content and structure."""
@handler
async def handle_email_content(self, email_content: EmailContent, ctx: WorkflowContext[ContentAnalysis]) -> None:
"""Analyze the email content for various indicators."""
await asyncio.sleep(2.0) # Simulate analysis time
# Simulate content analysis
sentiment_score = 0.5 if email_content.has_suspicious_patterns else 0.8
contains_links = "http" in email_content.cleaned_message or "www" in email_content.cleaned_message
has_attachments = "attachment" in email_content.cleaned_message
# Build risk indicators
risk_indicators = []
if email_content.has_suspicious_patterns:
risk_indicators.append("suspicious_language")
if contains_links:
risk_indicators.append("contains_links")
if has_attachments:
risk_indicators.append("has_attachments")
if email_content.word_count < 10:
risk_indicators.append("too_short")
analysis = ContentAnalysis(
email_content=email_content,
sentiment_score=sentiment_score,
contains_links=contains_links,
has_attachments=has_attachments,
risk_indicators=risk_indicators,
)
await ctx.send_message(analysis)
class SpamDetector(Executor):
"""Step 3: An executor that determines if a message is spam based on analysis."""
def __init__(self, spam_keywords: list[str], id: str):
"""Initialize the executor with spam keywords."""
super().__init__(id=id)
self._spam_keywords = spam_keywords
@handler
async def handle_analysis(self, analysis: ContentAnalysis, ctx: WorkflowContext[SpamDetectorResponse]) -> None:
"""Determine if the message is spam based on content analysis."""
await asyncio.sleep(1.8) # Simulate detection time
# Check for spam keywords
email_text = analysis.email_content.cleaned_message
keyword_matches = [kw for kw in self._spam_keywords if kw in email_text]
# Calculate spam probability
spam_score = 0.0
spam_reasons = []
if keyword_matches:
spam_score += 0.4
spam_reasons.append(f"spam_keywords: {keyword_matches}")
if analysis.email_content.has_suspicious_patterns:
spam_score += 0.3
spam_reasons.append("suspicious_patterns")
if len(analysis.risk_indicators) >= 3:
spam_score += 0.2
spam_reasons.append("high_risk_indicators")
if analysis.sentiment_score < 0.4:
spam_score += 0.1
spam_reasons.append("negative_sentiment")
is_spam = spam_score >= 0.5
result = SpamDetectorResponse(
analysis=analysis, is_spam=is_spam, confidence_score=spam_score, spam_reasons=spam_reasons
)
await ctx.send_message(result)
class SpamHandler(Executor):
"""Step 4a: An executor that handles spam messages with quarantine and logging."""
@handler
async def handle_spam_detection(
self,
spam_result: SpamDetectorResponse,
ctx: WorkflowContext[ProcessingResult],
) -> None:
"""Handle spam messages by quarantining and logging."""
if not spam_result.is_spam:
raise RuntimeError("Message is not spam, cannot process with spam handler.")
await asyncio.sleep(2.2) # Simulate spam handling time
result = ProcessingResult(
original_message=spam_result.analysis.email_content.original_message,
action_taken="quarantined_and_logged",
processing_time=2.2,
status="spam_handled",
is_spam=spam_result.is_spam,
confidence_score=spam_result.confidence_score,
spam_reasons=spam_result.spam_reasons or [],
)
await ctx.send_message(result)
class MessageResponder(Executor):
"""Step 4b: An executor that responds to legitimate messages."""
@handler
async def handle_spam_detection(
self,
spam_result: SpamDetectorResponse,
ctx: WorkflowContext[ProcessingResult],
) -> None:
"""Respond to legitimate messages."""
if spam_result.is_spam:
raise RuntimeError("Message is spam, cannot respond with message responder.")
await asyncio.sleep(2.5) # Simulate response time
result = ProcessingResult(
original_message=spam_result.analysis.email_content.original_message,
action_taken="responded_and_filed",
processing_time=2.5,
status="message_processed",
is_spam=spam_result.is_spam,
confidence_score=spam_result.confidence_score,
spam_reasons=spam_result.spam_reasons or [],
)
await ctx.send_message(result)
class FinalProcessor(Executor):
"""Step 5: An executor that completes the workflow with final logging and cleanup."""
@handler
async def handle_processing_result(
self,
result: ProcessingResult,
ctx: WorkflowContext[None],
) -> None:
"""Complete the workflow with final processing and logging."""
await asyncio.sleep(1.5) # Simulate final processing time
total_time = result.processing_time + 1.5
# Include classification details in completion message
classification = "SPAM" if result.is_spam else "LEGITIMATE"
reasons = ", ".join(result.spam_reasons) if result.spam_reasons else "none"
completion_message = (
f"Email classified as {classification} (confidence: {result.confidence_score:.2f}). "
f"Reasons: {reasons}. "
f"Action: {result.action_taken}, "
f"Status: {result.status}, "
f"Total time: {total_time:.1f}s"
)
await ctx.add_event(WorkflowCompletedEvent(completion_message))
# Create the workflow instance that DevUI can discover
spam_keywords = ["spam", "advertisement", "offer", "click here", "winner", "congratulations", "urgent"]
# Create all the executors for the 5-step workflow
email_preprocessor = EmailPreprocessor(id="email_preprocessor")
content_analyzer = ContentAnalyzer(id="content_analyzer")
spam_detector = SpamDetector(spam_keywords, id="spam_detector")
spam_handler = SpamHandler(id="spam_handler")
message_responder = MessageResponder(id="message_responder")
final_processor = FinalProcessor(id="final_processor")
# Build the comprehensive 5-step workflow with branching logic
workflow = (
WorkflowBuilder()
.set_start_executor(email_preprocessor)
.add_edge(email_preprocessor, content_analyzer)
.add_edge(content_analyzer, spam_detector)
.add_switch_case_edge_group(
spam_detector,
[
Case(condition=lambda x: x.is_spam, target=spam_handler),
Default(target=message_responder),
],
)
.add_edge(spam_handler, final_processor)
.add_edge(message_responder, final_processor)
.build()
)
# Note: Workflow metadata is determined by executors and graph structure
def main():
"""Launch the spam detection workflow in DevUI."""
from agent_framework.devui import serve
# Setup logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
logger.info("Starting Spam Detection Workflow")
logger.info("Available at: http://localhost:8090")
logger.info("Entity ID: workflow_spam_detection")
# Launch server with the workflow
serve(entities=[workflow], port=8090, auto_open=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
"""Weather agent sample for DevUI testing."""
from .agent import agent
__all__ = ["agent"]
@@ -0,0 +1,69 @@
# Copyright (c) Microsoft. All rights reserved.
"""Sample weather agent for Agent Framework Debug UI."""
import os
from typing import Annotated
from agent_framework import ChatAgent
from agent_framework.openai import OpenAIChatClient
def get_weather(
location: Annotated[str, "The location to get the weather for."],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
temperature = 53
return f"The weather in {location} is {conditions[0]} with a high of {temperature}°C."
def get_forecast(
location: Annotated[str, "The location to get the forecast for."],
days: Annotated[int, "Number of days for forecast"] = 3,
) -> str:
"""Get weather forecast for multiple days."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
forecast = []
for day in range(1, days + 1):
condition = conditions[0]
temp = 53
forecast.append(f"Day {day}: {condition}, {temp}°C")
return f"Weather forecast for {location}:\n" + "\n".join(forecast)
# Agent instance following Agent Framework conventions
agent = ChatAgent(
name="WeatherAgent",
description="A helpful agent that provides weather information and forecasts",
instructions="""
You are a weather assistant. You can provide current weather information
and forecasts for any location. Always be helpful and provide detailed
weather information when asked.
""",
chat_client=OpenAIChatClient(ai_model_id=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4o")),
tools=[get_weather, get_forecast],
)
def main():
"""Launch the weather agent in DevUI."""
import logging
from agent_framework.devui import serve
# Setup logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
logger.info("Starting Weather Agent")
logger.info("Available at: http://localhost:8090")
logger.info("Entity ID: agent_WeatherAgent")
# Launch server with the agent
serve(entities=[agent], port=8090, auto_open=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
"""Weather agent sample for DevUI testing."""
from .agent import agent
__all__ = ["agent"]
@@ -0,0 +1,71 @@
# Copyright (c) Microsoft. All rights reserved.
"""Sample weather agent for Agent Framework Debug UI."""
import os
from typing import Annotated
from agent_framework import ChatAgent
from agent_framework.azure import AzureChatClient
def get_weather(
location: Annotated[str, "The location to get the weather for."],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
temperature = 53
return f"The weather in {location} is {conditions[0]} with a high of {temperature}°C."
def get_forecast(
location: Annotated[str, "The location to get the forecast for."],
days: Annotated[int, "Number of days for forecast"] = 3,
) -> str:
"""Get weather forecast for multiple days."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
forecast = []
for day in range(1, days + 1):
condition = conditions[0]
temp = 53
forecast.append(f"Day {day}: {condition}, {temp}°C")
return f"Weather forecast for {location}:\n" + "\n".join(forecast)
# Agent instance following Agent Framework conventions
agent = ChatAgent(
name="AzureWeatherAgent",
description="A helpful agent that provides weather information and forecasts",
instructions="""
You are a weather assistant. You can provide current weather information
and forecasts for any location. Always be helpful and provide detailed
weather information when asked.
""",
chat_client=AzureChatClient(
api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""),
),
tools=[get_weather, get_forecast],
)
def main():
"""Launch the Azure weather agent in DevUI."""
import logging
from agent_framework.devui import serve
# Setup logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
logger.info("Starting Azure Weather Agent")
logger.info("Available at: http://localhost:8090")
logger.info("Entity ID: agent_AzureWeatherAgent")
# Launch server with the agent
serve(entities=[agent], port=8090, auto_open=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,284 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Message Capture Script - Debug message flow
- This script is intended to provide a reference for the types of events
that are emitted by the server when agents and workflows are executed
"""
import asyncio
import contextlib
import http.client
import json
import threading
import time
from pathlib import Path
from typing import Any
import uvicorn
from openai import OpenAI
from agent_framework_devui import DevServer
def start_server() -> tuple[str, Any]:
"""Start server with samples directory."""
# Get samples directory
current_dir = Path(__file__).parent
samples_dir = current_dir.parent / "samples"
# Create and start server with simplified parameters
server = DevServer(
entities_dir=str(samples_dir.resolve()),
host="127.0.0.1",
port=8085, # Use different port
ui_enabled=False,
)
app = server.get_app()
server_config = uvicorn.Config(
app=app,
host="127.0.0.1",
port=8085,
log_level="info", # More verbose to see tracing setup
)
server_instance = uvicorn.Server(server_config)
def run_server():
asyncio.run(server_instance.serve())
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Wait for server to start
time.sleep(5) # Increased wait time
# Verify server is running with retries
max_retries = 10
for attempt in range(max_retries):
try:
conn = http.client.HTTPConnection("127.0.0.1", 8085, timeout=5)
try:
conn.request("GET", "/health")
response = conn.getresponse()
if response.status == 200:
break
finally:
conn.close()
except Exception as e:
if attempt < max_retries - 1:
time.sleep(2)
else:
raise RuntimeError(f"Server failed to start after {max_retries} attempts: {e}") from e
return "http://127.0.0.1:8085", server_instance
def capture_agent_stream_with_tracing(client: OpenAI, agent_id: str, scenario: str = "success") -> list[dict[str, Any]]:
"""Capture agent streaming events."""
try:
stream = client.responses.create(
model="agent-framework",
input="Tell me about the weather in Tokyo. I want details.",
stream=True,
extra_body={"entity_id": agent_id},
)
events = []
for event in stream:
# Serialize the entire event object
try:
event_dict = json.loads(event.model_dump_json())
except Exception:
# Fallback to dict conversion if model_dump_json fails
event_dict = event.__dict__ if hasattr(event, "__dict__") else str(event)
events.append(event_dict)
# Just capture everything as-is
if len(events) >= 200: # Increased limit
break
return events
except Exception as e:
# Return error information as events
error_event = {
"type": "error",
"scenario": scenario,
"error_message": str(e),
"error_type": type(e).__name__,
"timestamp": time.time(),
}
return [error_event]
def capture_workflow_stream_with_tracing(
client: OpenAI, workflow_id: str, scenario: str = "success"
) -> list[dict[str, Any]]:
"""Capture workflow streaming events."""
try:
stream = client.responses.create(
model="agent-framework",
input=(
"Process this spam detection workflow with multiple emails: "
"'Buy now!', 'Hello mom', 'URGENT: Click here!'"
),
stream=True,
extra_body={"entity_id": workflow_id},
)
events = []
for event in stream:
# Serialize the entire event object
try:
event_dict = json.loads(event.model_dump_json())
except Exception:
# Fallback to dict conversion if model_dump_json fails
event_dict = event.__dict__ if hasattr(event, "__dict__") else str(event)
events.append(event_dict)
# Just capture everything as-is
if len(events) >= 200: # Increased limit
break
return events
except Exception as e:
# Return error information as events
error_event = {
"type": "error",
"scenario": scenario,
"error_message": str(e),
"error_type": type(e).__name__,
"timestamp": time.time(),
"entity_type": "workflow",
}
return [error_event]
def capture_agent_with_bad_config(base_url: str, agent_id: str) -> list[dict[str, Any]]:
"""Capture agent events with intentionally bad configuration to test error handling."""
# Test with invalid API key
bad_client = OpenAI(base_url=f"{base_url}/v1", api_key="invalid-api-key-123")
try:
return capture_agent_stream_with_tracing(bad_client, agent_id, "bad_api_key")
except Exception as e:
return [
{
"type": "error",
"scenario": "bad_api_key",
"error_message": str(e),
"error_type": type(e).__name__,
"timestamp": time.time(),
}
]
def capture_agent_with_wrong_model(base_url: str, agent_id: str) -> list[dict[str, Any]]:
"""Capture agent events with wrong model name to test error handling."""
client = OpenAI(
base_url=f"{base_url}/v1",
api_key="dummy-key", # Use the same key as success case
)
try:
stream = client.responses.create(
model="gpt-4-nonexistent-model", # Wrong model name
input="Tell me about the weather in Tokyo. I want details.",
stream=True,
extra_body={"entity_id": agent_id},
)
events = []
for event in stream:
# Serialize the entire event object
try:
event_dict = json.loads(event.model_dump_json())
except Exception:
# Fallback to dict conversion if model_dump_json fails
event_dict = event.__dict__ if hasattr(event, "__dict__") else str(event)
events.append(event_dict)
if len(events) >= 200:
break
return events
except Exception as e:
return [
{
"type": "error",
"scenario": "wrong_model",
"error_message": str(e),
"error_type": type(e).__name__,
"timestamp": time.time(),
}
]
def main():
"""Main capture script - testing both success and failure scenarios."""
# Setup
output_dir = Path(__file__).parent / "captured_messages"
output_dir.mkdir(exist_ok=True)
# Start server
base_url, server_instance = start_server()
try:
# Create OpenAI client for success scenario
client = OpenAI(base_url=f"{base_url}/v1", api_key="dummy-key")
# Discover entities
conn = http.client.HTTPConnection("127.0.0.1", 8085, timeout=10)
try:
conn.request("GET", "/v1/entities")
response = conn.getresponse()
response_data = response.read().decode("utf-8")
entities = json.loads(response_data)["entities"]
finally:
conn.close()
all_results = {}
# Test each entity
for entity in entities:
entity_type = entity["type"]
entity_id = entity["id"]
if entity_type == "agent":
events = capture_agent_stream_with_tracing(client, entity_id, "success")
elif entity_type == "workflow":
events = capture_workflow_stream_with_tracing(client, entity_id, "success")
else:
continue
all_results[f"{entity_type}_{entity_id}"] = {"entity_info": entity, "events": events}
# Save results
file_path = output_dir / "entities_stream_events.json"
with open(file_path, "w") as f:
json.dump(
{"timestamp": time.time(), "server_type": "DevServer", "entities_tested": all_results},
f,
indent=2,
default=str,
)
finally:
# Cleanup server
with contextlib.suppress(Exception):
server_instance.should_exit = True
if __name__ == "__main__":
main()
@@ -0,0 +1,101 @@
# Copyright (c) Microsoft. All rights reserved.
"""Focused tests for entity discovery functionality."""
import asyncio
import tempfile
from pathlib import Path
import pytest
from agent_framework_devui._discovery import EntityDiscovery
@pytest.fixture
def test_entities_dir():
"""Use the samples directory which has proper entity structure."""
# Get the samples directory relative to the current test file
current_dir = Path(__file__).parent
samples_dir = current_dir.parent / "samples"
return str(samples_dir.resolve())
@pytest.mark.asyncio
async def test_discover_agents(test_entities_dir):
"""Test that agent discovery works and returns valid agent entities."""
discovery = EntityDiscovery(test_entities_dir)
entities = await discovery.discover_entities()
agents = [e for e in entities if e.type == "agent"]
# Test that we can discover agents (not specific count)
assert len(agents) > 0, "Should discover at least one agent"
# Test agent structure/properties
for agent in agents:
assert agent.id, "Agent should have an ID"
assert agent.name, "Agent should have a name"
assert agent.type == "agent", "Should be identified as agent type"
assert hasattr(agent, "description"), "Agent should have description attribute"
@pytest.mark.asyncio
async def test_discover_workflows(test_entities_dir):
"""Test that workflow discovery works and returns valid workflow entities."""
discovery = EntityDiscovery(test_entities_dir)
entities = await discovery.discover_entities()
workflows = [e for e in entities if e.type == "workflow"]
# Test that we can discover workflows (not specific count)
assert len(workflows) > 0, "Should discover at least one workflow"
# Test workflow structure/properties
for workflow in workflows:
assert workflow.id, "Workflow should have an ID"
assert workflow.name, "Workflow should have a name"
assert workflow.type == "workflow", "Should be identified as workflow type"
assert hasattr(workflow, "description"), "Workflow should have description attribute"
@pytest.mark.asyncio
async def test_empty_directory():
"""Test discovery with empty directory."""
with tempfile.TemporaryDirectory() as temp_dir:
discovery = EntityDiscovery(temp_dir)
entities = await discovery.discover_entities()
assert len(entities) == 0
if __name__ == "__main__":
# Simple test runner
async def run_tests():
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create test files
agent_file = temp_path / "test_agent.py"
agent_file.write_text("""
class WeatherAgent:
name = "Weather Agent"
description = "Gets weather information"
def run_stream(self, input_str):
return f"Weather in {input_str}"
""")
workflow_file = temp_path / "test_workflow.py"
workflow_file.write_text("""
class DataWorkflow:
name = "Data Processing Workflow"
description = "Processes data"
def run(self, data):
return f"Processed {data}"
""")
discovery = EntityDiscovery(str(temp_path))
await discovery.discover_entities()
asyncio.run(run_tests())
@@ -0,0 +1,189 @@
# Copyright (c) Microsoft. All rights reserved.
"""Focused tests for execution flow functionality."""
import asyncio
import os
import tempfile
from pathlib import Path
import pytest
from agent_framework_devui._discovery import EntityDiscovery
from agent_framework_devui._executor import AgentFrameworkExecutor, EntityNotFoundError
from agent_framework_devui._mapper import MessageMapper
from agent_framework_devui.models._openai_custom import AgentFrameworkExtraBody, AgentFrameworkRequest
@pytest.fixture
def test_entities_dir():
"""Use the samples directory which has proper entity structure."""
current_dir = Path(__file__).parent
samples_dir = current_dir.parent / "samples"
return str(samples_dir.resolve())
@pytest.fixture
async def executor(test_entities_dir):
"""Create configured executor."""
discovery = EntityDiscovery(test_entities_dir)
mapper = MessageMapper()
executor = AgentFrameworkExecutor(discovery, mapper)
# Discover entities
await executor.discover_entities()
return executor
@pytest.mark.asyncio
async def test_executor_entity_discovery(executor):
"""Test executor entity discovery."""
entities = await executor.discover_entities()
# Should find entities from samples directory
assert len(entities) > 0, "Should discover at least one entity"
entity_types = [e.type for e in entities]
assert "agent" in entity_types, "Should find at least one agent"
assert "workflow" in entity_types, "Should find at least one workflow"
# Test entity structure
for entity in entities:
assert entity.id, "Entity should have an ID"
assert entity.name, "Entity should have a name"
assert entity.type in ["agent", "workflow"], "Entity should have valid type"
@pytest.mark.asyncio
async def test_executor_get_entity_info(executor):
"""Test getting entity info by ID."""
entities = await executor.discover_entities()
entity_id = entities[0].id
entity_info = executor.get_entity_info(entity_id)
assert entity_info is not None
assert entity_info.id == entity_id
assert entity_info.type in ["agent", "workflow"]
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="requires OpenAI API key")
@pytest.mark.asyncio
async def test_executor_sync_execution(executor):
"""Test synchronous execution."""
entities = await executor.discover_entities()
# Find an agent entity to test with
agents = [e for e in entities if e.type == "agent"]
assert len(agents) > 0, "No agent entities found for testing"
agent_id = agents[0].id
request = AgentFrameworkRequest(
model="agent-framework", input="test data", stream=False, extra_body=AgentFrameworkExtraBody(entity_id=agent_id)
)
response = await executor.execute_sync(request)
assert response.model == "agent-framework"
assert response.object == "response"
assert len(response.output) > 0
assert response.usage.total_tokens > 0
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="requires OpenAI API key")
@pytest.mark.asyncio
async def test_executor_streaming_execution(executor):
"""Test streaming execution."""
entities = await executor.discover_entities()
# Find an agent entity to test with
agents = [e for e in entities if e.type == "agent"]
assert len(agents) > 0, "No agent entities found for testing"
agent_id = agents[0].id
request = AgentFrameworkRequest(
model="agent-framework",
input="streaming test",
stream=True,
extra_body=AgentFrameworkExtraBody(entity_id=agent_id),
)
event_count = 0
text_events = []
async for event in executor.execute_streaming(request):
event_count += 1
if hasattr(event, "type") and event.type == "response.output_text.delta":
text_events.append(event.delta)
if event_count > 10: # Limit for testing
break
assert event_count > 0
assert len(text_events) > 0
@pytest.mark.asyncio
async def test_executor_invalid_entity_id(executor):
"""Test execution with invalid entity ID."""
with pytest.raises(EntityNotFoundError):
executor.get_entity_info("nonexistent_agent")
@pytest.mark.asyncio
async def test_executor_missing_entity_id(executor):
"""Test execution without entity ID."""
request = AgentFrameworkRequest(
model="agent-framework",
input="test",
stream=False,
extra_body=None, # Test case for missing entity_id
)
entity_id = request.get_entity_id()
assert entity_id is None
if __name__ == "__main__":
# Simple test runner
async def run_tests():
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create test agent
agent_file = temp_path / "streaming_agent.py"
agent_file.write_text("""
class StreamingAgent:
name = "Streaming Test Agent"
description = "Test agent for streaming"
async def run_stream(self, input_str):
for i, word in enumerate(f"Processing {input_str}".split()):
yield f"word_{i}: {word} "
""")
discovery = EntityDiscovery(str(temp_path))
mapper = MessageMapper()
executor = AgentFrameworkExecutor(discovery, mapper)
# Test discovery
entities = await executor.discover_entities()
if entities:
# Test sync execution
request = AgentFrameworkRequest(
model="agent-framework",
input="test input",
stream=False,
extra_body=AgentFrameworkExtraBody(entity_id=entities[0].id),
)
await executor.execute_sync(request)
# Test streaming execution
request.stream = True
event_count = 0
async for _event in executor.execute_streaming(request):
event_count += 1
if event_count > 5: # Limit for testing
break
asyncio.run(run_tests())
+192
View File
@@ -0,0 +1,192 @@
# Copyright (c) Microsoft. All rights reserved.
"""Clean focused tests for message mapping functionality."""
import asyncio
import sys
from pathlib import Path
from typing import Any
import pytest
# Add the main agent_framework package for real types
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "main"))
# Import Agent Framework types (assuming they are always available)
from agent_framework._types import AgentRunResponseUpdate, ErrorContent, FunctionCallContent, Role, TextContent
from agent_framework_devui._mapper import MessageMapper
from agent_framework_devui.models._openai_custom import AgentFrameworkExtraBody, AgentFrameworkRequest
def create_test_content(content_type: str, **kwargs: Any) -> Any:
"""Create test content objects."""
if content_type == "text":
return TextContent(text=kwargs.get("text", "Hello, world!"))
if content_type == "function_call":
return FunctionCallContent(
call_id=kwargs.get("call_id", "test_call_id"),
name=kwargs.get("name", "test_func"),
arguments=kwargs.get("arguments", {"param": "value"}),
)
if content_type == "error":
return ErrorContent(message=kwargs.get("message", "Test error"), error_code=kwargs.get("code", "test_error"))
raise ValueError(f"Unknown content type: {content_type}")
def create_test_agent_update(contents: list[Any]) -> Any:
"""Create test AgentRunResponseUpdate - NO fake attributes!"""
return AgentRunResponseUpdate(
contents=contents, role=Role.ASSISTANT, message_id="test_msg", response_id="test_resp"
)
@pytest.fixture
def mapper() -> MessageMapper:
return MessageMapper()
@pytest.fixture
def test_request() -> AgentFrameworkRequest:
return AgentFrameworkRequest(
model="agent-framework",
input="Test input",
stream=True,
extra_body=AgentFrameworkExtraBody(entity_id="test_agent"),
)
@pytest.mark.asyncio
async def test_critical_isinstance_bug_detection(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""CRITICAL: Test that would have caught the isinstance vs hasattr bug."""
content = create_test_content("text", text="Bug detection test")
update = create_test_agent_update([content])
# Key assertions that would have caught the bug
assert hasattr(update, "contents") # Real attribute ✅
assert not hasattr(update, "response") # Fake attribute should not exist ✅
# Test isinstance works with real types
assert isinstance(update, AgentRunResponseUpdate)
# Test mapper conversion - should NOT produce "Unknown event"
events = await mapper.convert_event(update, test_request)
assert len(events) > 0
assert all(hasattr(event, "type") for event in events)
# Should never get unknown events with proper types
assert all(event.type != "unknown" for event in events)
@pytest.mark.asyncio
async def test_text_content_mapping(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test TextContent mapping."""
content = create_test_content("text", text="Hello, clean test!")
update = create_test_agent_update([content])
events = await mapper.convert_event(update, test_request)
assert len(events) == 1
assert events[0].type == "response.output_text.delta"
assert events[0].delta == "Hello, clean test!"
@pytest.mark.asyncio
async def test_function_call_mapping(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test FunctionCallContent mapping."""
content = create_test_content("function_call", name="test_func", arguments={"location": "TestCity"})
update = create_test_agent_update([content])
events = await mapper.convert_event(update, test_request)
assert len(events) >= 1
assert all(event.type == "response.function_call_arguments.delta" for event in events)
# Check JSON is chunked
full_json = "".join(event.delta for event in events)
assert "TestCity" in full_json
@pytest.mark.asyncio
async def test_error_content_mapping(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test ErrorContent mapping."""
content = create_test_content("error", message="Test error", code="test_code")
update = create_test_agent_update([content])
events = await mapper.convert_event(update, test_request)
assert len(events) == 1
assert events[0].type == "error"
assert events[0].message == "Test error"
assert events[0].code == "test_code"
@pytest.mark.asyncio
async def test_mixed_content_types(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test multiple content types together."""
contents = [
create_test_content("text", text="Starting..."),
create_test_content("function_call", name="process", arguments={"data": "test"}),
create_test_content("text", text="Done!"),
]
update = create_test_agent_update(contents)
events = await mapper.convert_event(update, test_request)
assert len(events) >= 3
# Should have both types of events
event_types = {event.type for event in events}
assert "response.output_text.delta" in event_types
assert "response.function_call_arguments.delta" in event_types
@pytest.mark.asyncio
async def test_unknown_content_fallback(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test graceful handling of unknown content types."""
# Test the fallback path directly since we can't create invalid AgentRunResponseUpdate
# due to Pydantic validation. Instead, test the content mapper's unknown content handling.
class MockUnknownContent:
def __init__(self):
self.__class__.__name__ = "WeirdUnknownContent" # Not in content_mappers
# Test the content mapper directly
context = mapper._get_or_create_context(test_request)
unknown_content = MockUnknownContent()
# This should trigger the unknown content fallback in _convert_agent_update
event = await mapper._create_unknown_content_event(unknown_content, context)
assert event.type == "response.output_text.delta"
assert "Unknown content type" in event.delta
assert "WeirdUnknownContent" in event.delta
if __name__ == "__main__":
# Simple test runner
async def run_all_tests() -> None:
mapper = MessageMapper()
test_request = AgentFrameworkRequest(
model="agent-framework", input="Test", stream=True, extra_body=AgentFrameworkExtraBody(entity_id="test")
)
tests = [
("Critical isinstance bug detection", test_critical_isinstance_bug_detection),
("Text content mapping", test_text_content_mapping),
("Function call mapping", test_function_call_mapping),
("Error content mapping", test_error_content_mapping),
("Mixed content types", test_mixed_content_types),
("Unknown content fallback", test_unknown_content_fallback),
]
passed = 0
for _test_name, test_func in tests:
try:
await test_func(mapper, test_request)
passed += 1
except Exception:
pass
asyncio.run(run_all_tests())
+135
View File
@@ -0,0 +1,135 @@
# Copyright (c) Microsoft. All rights reserved.
"""Focused tests for server functionality."""
import asyncio
import tempfile
from pathlib import Path
import pytest
from agent_framework_devui import DevServer
from agent_framework_devui.models._openai_custom import AgentFrameworkExtraBody, AgentFrameworkRequest
@pytest.fixture
def test_entities_dir():
"""Use the samples directory which has proper entity structure."""
current_dir = Path(__file__).parent
samples_dir = current_dir.parent / "samples"
return str(samples_dir.resolve())
@pytest.mark.asyncio
async def test_server_health_endpoint(test_entities_dir):
"""Test /health endpoint."""
server = DevServer(entities_dir=test_entities_dir)
executor = await server._ensure_executor()
# Test entity count
entities = await executor.discover_entities()
assert len(entities) > 0
# Framework name is now hardcoded since we simplified to single framework
@pytest.mark.asyncio
async def test_server_entities_endpoint(test_entities_dir):
"""Test /v1/entities endpoint."""
server = DevServer(entities_dir=test_entities_dir)
executor = await server._ensure_executor()
entities = await executor.discover_entities()
assert len(entities) >= 1
# Should find at least the weather agent
agent_entities = [e for e in entities if e.type == "agent"]
assert len(agent_entities) >= 1
agent_names = [e.name for e in agent_entities]
assert "WeatherAgent" in agent_names
@pytest.mark.asyncio
async def test_server_execution_sync(test_entities_dir):
"""Test sync execution endpoint."""
server = DevServer(entities_dir=test_entities_dir)
executor = await server._ensure_executor()
entities = await executor.discover_entities()
agent_id = entities[0].id
request = AgentFrameworkRequest(
model="agent-framework",
input="San Francisco",
stream=False,
extra_body=AgentFrameworkExtraBody(entity_id=agent_id),
)
response = await executor.execute_sync(request)
assert response.model == "agent-framework"
assert len(response.output) > 0
@pytest.mark.asyncio
async def test_server_execution_streaming(test_entities_dir):
"""Test streaming execution endpoint."""
server = DevServer(entities_dir=test_entities_dir)
executor = await server._ensure_executor()
entities = await executor.discover_entities()
agent_id = entities[0].id
request = AgentFrameworkRequest(
model="agent-framework", input="New York", stream=True, extra_body=AgentFrameworkExtraBody(entity_id=agent_id)
)
event_count = 0
async for _event in executor.execute_streaming(request):
event_count += 1
if event_count > 5: # Limit for testing
break
assert event_count > 0
def test_configuration():
"""Test basic configuration."""
server = DevServer(entities_dir="test", port=9000, host="localhost")
assert server.port == 9000
assert server.host == "localhost"
assert server.entities_dir == "test"
assert server.cors_origins == ["*"]
assert server.ui_enabled
if __name__ == "__main__":
# Simple test runner
async def run_tests():
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create test agent
agent_file = temp_path / "weather_agent.py"
agent_file.write_text("""
class WeatherAgent:
name = "Weather Agent"
description = "Gets weather information"
def run_stream(self, input_str):
return f"Weather in {input_str} is sunny"
""")
server = DevServer(entities_dir=str(temp_path))
executor = await server._ensure_executor()
entities = await executor.discover_entities()
if entities:
request = AgentFrameworkRequest(
model="agent-framework",
input="test location",
stream=False,
extra_body=AgentFrameworkExtraBody(entity_id=entities[0].id),
)
await executor.execute_sync(request)
asyncio.run(run_tests())