mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
co-authored by
Chris
Eric Zhu
parent
adb6dcd2af
commit
1ef24d3e91
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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 |
@@ -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 |
@@ -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*
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user