mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: DevUI: Add OpenAI Responses API proxy support + HIL for Workflows (#1737)
* DevUI: Add OpenAI Responses API proxy support with enhanced UI features This commit adds support for proxying requests to OpenAI's Responses API, allowing DevUI to route conversations to OpenAI models when configured to enable testing. Backend changes: - Add OpenAI proxy executor with conversation routing logic - Enhance event mapper to support OpenAI Responses API format - Extend server endpoints to handle OpenAI proxy mode - Update models with OpenAI-specific response types - Remove emojis from logging and CLI output for cleaner text Frontend changes: - Add settings modal with OpenAI proxy configuration UI - Enhance agent and workflow views with improved state management - Add new UI components (separator, switch) for settings - Update debug panel with better event filtering - Improve message renderers for OpenAI content types - Update types and API client for OpenAI integration * update ui, settings modal and workflow input form, add register cleanup hooks. * add workflow HIL support, user mode, other fixes * feat(devui): add human-in-the-loop (HIL) support with dynamic response schemas Implement HIL workflow support allowing workflows to pause for user input with dynamically generated JSON schemas based on response handler type hints. Key Features: - Automatic response schema extraction from @response_handler decorators - Dynamic form generation in UI based on Pydantic/dataclass response types - Checkpoint-based conversation storage for HIL requests/responses - Resume workflow execution after user provides HIL response Backend Changes: - Add extract_response_type_from_executor() to introspect response handlers - Enrich RequestInfoEvent with response_schema via _enrich_request_info_event_with_response_schema() - Map RequestInfoEvent to response.input.requested OpenAI event format - Store HIL responses in conversation history and restore checkpoints Frontend Changes: - Add HILInputModal component with SchemaFormRenderer for dynamic forms - Support Pydantic BaseModel and dataclass response types - Render enum fields as dropdowns, strings as text/textarea, numbers, booleans, arrays, objects - Display original request context alongside response form Testing: - Add tests for checkpoint storage (test_checkpoints.py) - Add schema generation tests for all input types (test_schema_generation.py) - Validate end-to-end HIL flow with spam workflow sample This enables workflows to seamlessly pause execution and request structured user input with type-safe, validated forms generated automatically from response type annotations. * improve HIL support, improve workflow execution view * ui updates * ui updates * improve HIL for workflows, add auth and view modes * update workflow * security improvements , ui fixes * fix mypy error * update loading spinner in ui --------- Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
This commit is contained in:
co-authored by
Mark Wallace
parent
85484c0259
commit
94eae24082
@@ -62,6 +62,27 @@ serve(entities=[agent])
|
||||
|
||||
MCP tools use lazy initialization and connect automatically on first use. DevUI attempts to clean up connections on shutdown
|
||||
|
||||
## Resource Cleanup
|
||||
|
||||
Register cleanup hooks to properly close credentials and resources on shutdown:
|
||||
|
||||
```python
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework_devui import register_cleanup, serve
|
||||
|
||||
credential = DefaultAzureCredential()
|
||||
client = AzureOpenAIChatClient()
|
||||
agent = ChatAgent(name="MyAgent", chat_client=client)
|
||||
|
||||
# Register cleanup hook - credential will be closed on shutdown
|
||||
register_cleanup(agent, credential.close)
|
||||
serve(entities=[agent])
|
||||
```
|
||||
|
||||
Works with multiple resources and file-based discovery. See tests for more examples.
|
||||
|
||||
## 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`).
|
||||
@@ -150,6 +171,22 @@ response2 = client.responses.create(
|
||||
|
||||
**How it works:** DevUI automatically retrieves the conversation's message history from the stored thread and passes it to the agent. You don't need to manually manage message history - just provide the same `conversation` ID for follow-up requests.
|
||||
|
||||
### OpenAI Proxy Mode
|
||||
|
||||
DevUI provides an **OpenAI Proxy** feature for testing OpenAI models directly through the interface without creating custom agents. Enable via Settings → OpenAI Proxy tab.
|
||||
|
||||
**How it works:** The UI sends requests to the DevUI backend (with `X-Proxy-Backend: openai` header), which then proxies them to OpenAI's Responses API (and Conversations API for multi-turn chats). This proxy approach keeps your `OPENAI_API_KEY` secure on the server—never exposed in the browser or client-side code.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/v1/responses \
|
||||
-H "X-Proxy-Backend: openai" \
|
||||
-d '{"model": "gpt-4.1-mini", "input": "Hello"}'
|
||||
```
|
||||
|
||||
**Note:** Requires `OPENAI_API_KEY` environment variable configured on the backend.
|
||||
|
||||
## CLI Options
|
||||
|
||||
```bash
|
||||
@@ -162,6 +199,21 @@ Options:
|
||||
--config YAML config file
|
||||
--tracing none|framework|workflow|all
|
||||
--reload Enable auto-reload
|
||||
--mode developer|user (default: developer)
|
||||
--auth Enable Bearer token authentication
|
||||
```
|
||||
|
||||
### UI Modes
|
||||
|
||||
- **developer** (default): Full access - debug panel, entity details, hot reload, deployment
|
||||
- **user**: Simplified UI with restricted APIs - only chat and conversation management
|
||||
|
||||
```bash
|
||||
# Development
|
||||
devui ./agents
|
||||
|
||||
# Production (user-facing)
|
||||
devui ./agents --mode user --auth
|
||||
```
|
||||
|
||||
## Key Endpoints
|
||||
@@ -187,18 +239,23 @@ Given that DevUI offers an OpenAI Responses API, it internally maps messages and
|
||||
| `response.function_result.complete` | `FunctionResultContent` | DevUI |
|
||||
| `response.function_approval.requested` | `FunctionApprovalRequestContent` | DevUI |
|
||||
| `response.function_approval.responded` | `FunctionApprovalResponseContent` | DevUI |
|
||||
| `response.output_item.added` (ResponseOutputImage) | `DataContent` (images) | DevUI |
|
||||
| `response.output_item.added` (ResponseOutputFile) | `DataContent` (files) | DevUI |
|
||||
| `response.output_item.added` (ResponseOutputData) | `DataContent` (other) | DevUI |
|
||||
| `response.output_item.added` (ResponseOutputImage/File) | `UriContent` (images/files) | DevUI |
|
||||
| `error` | `ErrorContent` | OpenAI |
|
||||
| Final `Response.usage` field (not streamed) | `UsageContent` | OpenAI |
|
||||
| | **Workflow Events** | |
|
||||
| `response.output_item.added` (ExecutorActionItem)* | `ExecutorInvokedEvent` | OpenAI |
|
||||
| `response.output_item.done` (ExecutorActionItem)* | `ExecutorCompletedEvent` | OpenAI |
|
||||
| `response.output_item.done` (ExecutorActionItem with error)* | `ExecutorFailedEvent` | OpenAI |
|
||||
| `response.output_item.added` (ResponseOutputMessage) | `WorkflowOutputEvent` | OpenAI |
|
||||
| `response.workflow_event.complete` | `WorkflowEvent` (other) | DevUI |
|
||||
| `response.trace.complete` | `WorkflowStatusEvent` | DevUI |
|
||||
| `response.trace.complete` | `WorkflowWarningEvent` | DevUI |
|
||||
| | **Trace Content** | |
|
||||
| `response.trace.complete` | `DataContent` | DevUI |
|
||||
| `response.trace.complete` | `UriContent` | DevUI |
|
||||
| `response.trace.complete` | `DataContent` (no data/errors) | DevUI |
|
||||
| `response.trace.complete` | `UriContent` (unsupported MIME) | DevUI |
|
||||
| `response.trace.complete` | `HostedFileContent` | DevUI |
|
||||
| `response.trace.complete` | `HostedVectorStoreContent` | DevUI |
|
||||
|
||||
@@ -213,15 +270,19 @@ DevUI follows the OpenAI Responses API specification for maximum compatibility:
|
||||
|
||||
**OpenAI Standard Event Types Used:**
|
||||
|
||||
- `ResponseOutputItemAddedEvent` - Output item notifications (function calls and results)
|
||||
- `ResponseOutputItemAddedEvent` - Output item notifications (function calls, images, files, data)
|
||||
- `ResponseOutputItemDoneEvent` - Output item completion notifications
|
||||
- `Response.usage` - Token usage (in final response, not streamed)
|
||||
- All standard text, reasoning, and function call events
|
||||
|
||||
**Custom DevUI Extensions:**
|
||||
|
||||
- `response.output_item.added` with custom item types:
|
||||
- `ResponseOutputImage` - Agent-generated images (inline display)
|
||||
- `ResponseOutputFile` - Agent-generated files (inline display)
|
||||
- `ResponseOutputData` - Agent-generated structured data (inline display)
|
||||
- `response.function_approval.requested` - Function approval requests (for interactive approval workflows)
|
||||
- `response.function_approval.responded` - Function approval responses (user approval/rejection)
|
||||
- `response.function_result.complete` - Server-side function execution results
|
||||
- `response.workflow_event.complete` - Agent Framework workflow events
|
||||
- `response.trace.complete` - Execution traces and internal content (DataContent, UriContent, hosted files/stores)
|
||||
|
||||
@@ -254,18 +315,28 @@ These custom extensions are clearly namespaced and can be safely ignored by stan
|
||||
|
||||
## Security
|
||||
|
||||
DevUI is designed as a **sample application for local development** and should not be exposed to untrusted networks or used in production environments.
|
||||
DevUI is designed as a **sample application for local development** and should not be exposed to untrusted networks without proper authentication.
|
||||
|
||||
**For production deployments:**
|
||||
|
||||
```bash
|
||||
# User mode with authentication (recommended)
|
||||
devui ./agents --mode user --auth --host 0.0.0.0
|
||||
```
|
||||
|
||||
This restricts developer APIs (reload, deployment, entity details) and requires Bearer token authentication.
|
||||
|
||||
**Security features:**
|
||||
|
||||
- User mode restricts developer-facing APIs
|
||||
- Optional Bearer token authentication via `--auth`
|
||||
- Only loads entities from local directories or in-memory registration
|
||||
- No remote code execution capabilities
|
||||
- Binds to localhost (127.0.0.1) by default
|
||||
- All samples must be manually downloaded and reviewed before running
|
||||
|
||||
**Best practices:**
|
||||
|
||||
- Never expose DevUI to the internet
|
||||
- Use `--mode user --auth` for any deployment exposed to end users
|
||||
- Review all agent/workflow code before running
|
||||
- Only load entities from trusted sources
|
||||
- Use `.env` files for sensitive credentials (never commit them)
|
||||
|
||||
@@ -5,20 +5,87 @@
|
||||
import importlib.metadata
|
||||
import logging
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from ._conversations import CheckpointConversationManager
|
||||
from ._server import DevServer
|
||||
from .models import AgentFrameworkRequest, OpenAIError, OpenAIResponse, ResponseStreamEvent
|
||||
from .models._discovery_models import DiscoveryResponse, EntityInfo, EnvVarRequirement
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level cleanup registry (before serve() is called)
|
||||
_cleanup_registry: dict[int, list[Callable[[], Any]]] = {}
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
|
||||
def register_cleanup(entity: Any, *hooks: Callable[[], Any]) -> None:
|
||||
"""Register cleanup hook(s) for an entity.
|
||||
|
||||
Cleanup hooks execute during DevUI server shutdown, before entity
|
||||
clients are closed. Supports both synchronous and asynchronous callables.
|
||||
|
||||
Args:
|
||||
entity: Agent, workflow, or other entity object
|
||||
*hooks: One or more cleanup callables (sync or async)
|
||||
|
||||
Raises:
|
||||
ValueError: If no hooks provided
|
||||
|
||||
Examples:
|
||||
Single cleanup hook:
|
||||
>>> from agent_framework.devui import serve, register_cleanup
|
||||
>>> credential = DefaultAzureCredential()
|
||||
>>> agent = ChatAgent(...)
|
||||
>>> register_cleanup(agent, credential.close)
|
||||
>>> serve(entities=[agent])
|
||||
|
||||
Multiple cleanup hooks:
|
||||
>>> register_cleanup(agent, credential.close, session.close, db_pool.close)
|
||||
|
||||
Works with file-based discovery:
|
||||
>>> # In agents/my_agent/agent.py
|
||||
>>> from agent_framework.devui import register_cleanup
|
||||
>>> credential = DefaultAzureCredential()
|
||||
>>> agent = ChatAgent(...)
|
||||
>>> register_cleanup(agent, credential.close)
|
||||
>>> # Run: devui ./agents
|
||||
"""
|
||||
if not hooks:
|
||||
raise ValueError("At least one cleanup hook required")
|
||||
|
||||
# Use id() to track entity identity (works across modules)
|
||||
entity_id = id(entity)
|
||||
|
||||
if entity_id not in _cleanup_registry:
|
||||
_cleanup_registry[entity_id] = []
|
||||
|
||||
_cleanup_registry[entity_id].extend(hooks)
|
||||
|
||||
logger.debug(
|
||||
f"Registered {len(hooks)} cleanup hook(s) for {type(entity).__name__} "
|
||||
f"(id: {entity_id}, total: {len(_cleanup_registry[entity_id])})"
|
||||
)
|
||||
|
||||
|
||||
def _get_registered_cleanup_hooks(entity: Any) -> list[Callable[[], Any]]:
|
||||
"""Get cleanup hooks registered for an entity (internal use).
|
||||
|
||||
Args:
|
||||
entity: Entity object to get hooks for
|
||||
|
||||
Returns:
|
||||
List of cleanup hooks registered for the entity
|
||||
"""
|
||||
entity_id = id(entity)
|
||||
return _cleanup_registry.get(entity_id, [])
|
||||
|
||||
|
||||
def serve(
|
||||
entities: list[Any] | None = None,
|
||||
entities_dir: str | None = None,
|
||||
@@ -28,6 +95,9 @@ def serve(
|
||||
cors_origins: list[str] | None = None,
|
||||
ui_enabled: bool = True,
|
||||
tracing_enabled: bool = False,
|
||||
mode: str = "developer",
|
||||
auth_enabled: bool = False,
|
||||
auth_token: str | None = None,
|
||||
) -> None:
|
||||
"""Launch Agent Framework DevUI with simple API.
|
||||
|
||||
@@ -40,6 +110,9 @@ def serve(
|
||||
cors_origins: List of allowed CORS origins
|
||||
ui_enabled: Whether to enable the UI
|
||||
tracing_enabled: Whether to enable OpenTelemetry tracing
|
||||
mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)
|
||||
auth_enabled: Whether to enable Bearer token authentication
|
||||
auth_token: Custom authentication token (auto-generated if not provided with auth_enabled=True)
|
||||
"""
|
||||
import re
|
||||
|
||||
@@ -53,6 +126,52 @@ def serve(
|
||||
if not isinstance(port, int) or not (1 <= port <= 65535):
|
||||
raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535")
|
||||
|
||||
# Security check: Warn if network-exposed without authentication
|
||||
if host not in ("127.0.0.1", "localhost") and not auth_enabled:
|
||||
logger.warning("⚠️ WARNING: Exposing DevUI to network without authentication!")
|
||||
logger.warning("⚠️ This is INSECURE - anyone on your network can access your agents")
|
||||
logger.warning("💡 For network exposure, add --auth flag: devui --host 0.0.0.0 --auth")
|
||||
|
||||
# Handle authentication configuration
|
||||
if auth_enabled:
|
||||
import os
|
||||
import secrets
|
||||
|
||||
# Check if token is in environment variable first
|
||||
if not auth_token:
|
||||
auth_token = os.environ.get("DEVUI_AUTH_TOKEN")
|
||||
|
||||
# Auto-generate token if STILL not provided
|
||||
if not auth_token:
|
||||
# Check if we're in a production-like environment
|
||||
is_production = (
|
||||
host not in ("127.0.0.1", "localhost") # Exposed to network
|
||||
or os.environ.get("CI") == "true" # Running in CI
|
||||
or os.environ.get("KUBERNETES_SERVICE_HOST") # Running in k8s
|
||||
)
|
||||
|
||||
if is_production:
|
||||
# REFUSE to start without explicit token
|
||||
logger.error("❌ Authentication enabled but no token provided")
|
||||
logger.error("❌ Auto-generated tokens are NOT secure for network-exposed deployments")
|
||||
logger.error("💡 Set token: export DEVUI_AUTH_TOKEN=<your-secure-token>")
|
||||
logger.error("💡 Or pass: serve(entities=[...], auth_token='your-token')")
|
||||
raise ValueError("DEVUI_AUTH_TOKEN required when host is not localhost")
|
||||
|
||||
# Development mode: auto-generate and show
|
||||
auth_token = secrets.token_urlsafe(32)
|
||||
logger.info("🔒 Authentication enabled with auto-generated token")
|
||||
logger.info("\n" + "=" * 70)
|
||||
logger.info("🔑 DEV TOKEN (localhost only, shown once):")
|
||||
logger.info(f" {auth_token}")
|
||||
logger.info("=" * 70 + "\n")
|
||||
else:
|
||||
logger.info("🔒 Authentication enabled with provided token")
|
||||
|
||||
# Set environment variable for server to use
|
||||
os.environ["AUTH_REQUIRED"] = "true"
|
||||
os.environ["DEVUI_AUTH_TOKEN"] = auth_token
|
||||
|
||||
# Configure tracing environment variables if enabled
|
||||
if tracing_enabled:
|
||||
import os
|
||||
@@ -72,7 +191,12 @@ def serve(
|
||||
|
||||
# Create server with direct parameters
|
||||
server = DevServer(
|
||||
entities_dir=entities_dir, port=port, host=host, cors_origins=cors_origins, ui_enabled=ui_enabled
|
||||
entities_dir=entities_dir,
|
||||
port=port,
|
||||
host=host,
|
||||
cors_origins=cors_origins,
|
||||
ui_enabled=ui_enabled,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
# Register in-memory entities if provided
|
||||
@@ -139,6 +263,7 @@ def main() -> None:
|
||||
# Export main public API
|
||||
__all__ = [
|
||||
"AgentFrameworkRequest",
|
||||
"CheckpointConversationManager",
|
||||
"DevServer",
|
||||
"DiscoveryResponse",
|
||||
"EntityInfo",
|
||||
@@ -147,5 +272,6 @@ __all__ = [
|
||||
"OpenAIResponse",
|
||||
"ResponseStreamEvent",
|
||||
"main",
|
||||
"register_cleanup",
|
||||
"serve",
|
||||
]
|
||||
|
||||
@@ -55,6 +55,41 @@ Examples:
|
||||
|
||||
parser.add_argument("--tracing", action="store_true", help="Enable OpenTelemetry tracing for Agent Framework")
|
||||
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["developer", "user"],
|
||||
default=None,
|
||||
help="Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)",
|
||||
)
|
||||
|
||||
# Add --dev/--no-dev as a convenient alternative to --mode
|
||||
parser.add_argument(
|
||||
"--dev",
|
||||
dest="dev_mode",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Enable developer mode (shorthand for --mode developer)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-dev",
|
||||
dest="dev_mode",
|
||||
action="store_false",
|
||||
help="Disable developer mode (shorthand for --mode user)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--auth",
|
||||
action="store_true",
|
||||
help="Enable authentication via Bearer token (required for deployed environments)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--auth-token",
|
||||
type=str,
|
||||
help="Custom authentication token (auto-generated if not provided with --auth)",
|
||||
)
|
||||
|
||||
parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}")
|
||||
|
||||
return parser
|
||||
@@ -78,26 +113,35 @@ def validate_directory(directory: str) -> str:
|
||||
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
|
||||
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
|
||||
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:
|
||||
def print_startup_info(
|
||||
entities_dir: str, host: str, port: int, ui_enabled: bool, reload: bool, auth_token: str | None = None
|
||||
) -> None:
|
||||
"""Print startup information."""
|
||||
print("🤖 Agent Framework DevUI") # noqa: T201
|
||||
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(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
|
||||
|
||||
# Display auth token if authentication is enabled
|
||||
if auth_token:
|
||||
print("Authentication: Enabled") # noqa: T201
|
||||
print(f"Auth token: {auth_token}") # noqa: T201
|
||||
print("💡 Use this token in Authorization: Bearer <token> header") # noqa: T201
|
||||
|
||||
print("=" * 50) # noqa: T201
|
||||
print("🔍 Scanning for entities...") # noqa: T201
|
||||
print("Scanning for entities...") # noqa: T201
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -114,8 +158,19 @@ def main() -> None:
|
||||
# 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)
|
||||
# Determine mode from --mode or --dev/--no-dev flags
|
||||
if args.dev_mode is not None:
|
||||
# --dev or --no-dev was specified
|
||||
mode = "developer" if args.dev_mode else "user"
|
||||
elif args.mode is not None:
|
||||
# --mode was specified
|
||||
mode = args.mode
|
||||
else:
|
||||
# Default to developer mode
|
||||
mode = "developer"
|
||||
|
||||
# Print startup info (don't show token - serve() will handle it)
|
||||
print_startup_info(entities_dir, args.host, args.port, ui_enabled, args.reload, None)
|
||||
|
||||
# Import and start server
|
||||
try:
|
||||
@@ -128,14 +183,17 @@ def main() -> None:
|
||||
auto_open=not args.no_open,
|
||||
ui_enabled=ui_enabled,
|
||||
tracing_enabled=args.tracing,
|
||||
mode=mode,
|
||||
auth_enabled=args.auth,
|
||||
auth_token=args.auth_token, # Pass through explicit token only
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 Shutting down Agent Framework DevUI...") # noqa: T201
|
||||
print("\nShutting 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
|
||||
print(f"Error: {e}", file=sys.stderr) # noqa: T201
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from abc import ABC, abstractmethod
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from agent_framework import AgentThread, ChatMessage
|
||||
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
|
||||
from openai.types.conversations import Conversation, ConversationDeletedResource
|
||||
from openai.types.conversations.conversation_item import ConversationItem
|
||||
from openai.types.conversations.message import Message
|
||||
@@ -26,6 +27,10 @@ from openai.types.responses import (
|
||||
# Type alias for OpenAI Message role literals
|
||||
MessageRole = Literal["unknown", "user", "assistant", "system", "critic", "discriminator", "developer", "tool"]
|
||||
|
||||
# Checkpoint item type constants
|
||||
CONVERSATION_ITEM_TYPE_CHECKPOINT = "checkpoint"
|
||||
CONVERSATION_TYPE_CHECKPOINT_CONTAINER = "checkpoint_container"
|
||||
|
||||
|
||||
class ConversationStore(ABC):
|
||||
"""Abstract base class for conversation storage.
|
||||
@@ -35,14 +40,17 @@ class ConversationStore(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create_conversation(self, metadata: dict[str, str] | None = None) -> Conversation:
|
||||
def create_conversation(
|
||||
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
|
||||
) -> Conversation:
|
||||
"""Create a new conversation (wraps AgentThread creation).
|
||||
|
||||
Args:
|
||||
metadata: Optional metadata dict (e.g., {"agent_id": "weather_agent"})
|
||||
conversation_id: Optional conversation ID (if None, generates one)
|
||||
|
||||
Returns:
|
||||
Conversation object with generated ID
|
||||
Conversation object with generated or provided ID
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -127,7 +135,7 @@ class ConversationStore(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
|
||||
"""Get specific conversation item.
|
||||
"""Get a specific conversation item by ID.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
@@ -184,17 +192,23 @@ class InMemoryConversationStore(ConversationStore):
|
||||
# Item index for O(1) lookup: {conversation_id: {item_id: ConversationItem}}
|
||||
self._item_index: dict[str, dict[str, ConversationItem]] = {}
|
||||
|
||||
def create_conversation(self, metadata: dict[str, str] | None = None) -> Conversation:
|
||||
"""Create a new conversation with underlying AgentThread."""
|
||||
conv_id = f"conv_{uuid.uuid4().hex}"
|
||||
def create_conversation(
|
||||
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
|
||||
) -> Conversation:
|
||||
"""Create a new conversation with underlying AgentThread and checkpoint storage."""
|
||||
conv_id = conversation_id or f"conv_{uuid.uuid4().hex}"
|
||||
created_at = int(time.time())
|
||||
|
||||
# Create AgentThread with default ChatMessageStore
|
||||
thread = AgentThread()
|
||||
|
||||
# Create session-scoped checkpoint storage (one per conversation)
|
||||
checkpoint_storage = InMemoryCheckpointStorage()
|
||||
|
||||
self._conversations[conv_id] = {
|
||||
"id": conv_id,
|
||||
"thread": thread,
|
||||
"checkpoint_storage": checkpoint_storage, # Stored alongside thread
|
||||
"metadata": metadata or {},
|
||||
"created_at": created_at,
|
||||
"items": [],
|
||||
@@ -424,6 +438,23 @@ class InMemoryConversationStore(ConversationStore):
|
||||
# Add function result items
|
||||
items.extend(function_results)
|
||||
|
||||
# Include checkpoints from checkpoint storage as conversation items
|
||||
checkpoint_storage = conv_data.get("checkpoint_storage")
|
||||
if checkpoint_storage:
|
||||
# Get all checkpoints for this conversation
|
||||
checkpoints = await checkpoint_storage.list_checkpoints()
|
||||
for checkpoint in checkpoints:
|
||||
# Create a conversation item for each checkpoint
|
||||
checkpoint_item = {
|
||||
"id": f"checkpoint_{checkpoint.checkpoint_id}",
|
||||
"type": "checkpoint",
|
||||
"checkpoint_id": checkpoint.checkpoint_id,
|
||||
"workflow_id": checkpoint.workflow_id,
|
||||
"timestamp": checkpoint.timestamp,
|
||||
"status": "completed",
|
||||
}
|
||||
items.append(cast(ConversationItem, checkpoint_item))
|
||||
|
||||
# Apply pagination
|
||||
if order == "desc":
|
||||
items = items[::-1]
|
||||
@@ -442,12 +473,9 @@ class InMemoryConversationStore(ConversationStore):
|
||||
return paginated_items, has_more
|
||||
|
||||
def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
|
||||
"""Get specific conversation item - O(1) lookup via index."""
|
||||
# Use index for O(1) lookup instead of linear search
|
||||
conv_items = self._item_index.get(conversation_id)
|
||||
if not conv_items:
|
||||
return None
|
||||
|
||||
"""Get a specific conversation item by ID."""
|
||||
# Use the item index for O(1) lookup
|
||||
conv_items = self._item_index.get(conversation_id, {})
|
||||
return conv_items.get(item_id)
|
||||
|
||||
def get_thread(self, conversation_id: str) -> AgentThread | None:
|
||||
@@ -471,3 +499,42 @@ class InMemoryConversationStore(ConversationStore):
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
class CheckpointConversationManager:
|
||||
"""Manages checkpoint storage for workflow sessions - SESSION-SCOPED.
|
||||
|
||||
Simplified architecture: Each conversation has its own InMemoryCheckpointStorage
|
||||
stored in conv_data["checkpoint_storage"]. This manager just retrieves it.
|
||||
Session isolation comes from each conversation having a separate storage instance.
|
||||
"""
|
||||
|
||||
def __init__(self, conversation_store: ConversationStore):
|
||||
# Runtime validation since we need specific implementation details
|
||||
if not isinstance(conversation_store, InMemoryConversationStore):
|
||||
raise TypeError("CheckpointConversationManager currently requires InMemoryConversationStore")
|
||||
self._store: InMemoryConversationStore = conversation_store
|
||||
# Keep public reference for backward compatibility with tests
|
||||
self.conversation_store = conversation_store
|
||||
|
||||
def get_checkpoint_storage(self, conversation_id: str) -> InMemoryCheckpointStorage:
|
||||
"""Get the checkpoint storage for a specific conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
InMemoryCheckpointStorage instance for this conversation
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
# Access internal conversations dict (we know it's InMemoryConversationStore)
|
||||
conv_data = self._store._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
checkpoint_storage = conv_data["checkpoint_storage"]
|
||||
if not isinstance(checkpoint_storage, InMemoryCheckpointStorage):
|
||||
raise TypeError(f"Expected InMemoryCheckpointStorage but got {type(checkpoint_storage)}")
|
||||
return checkpoint_storage
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Azure Container Apps deployment manager for DevUI entities."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .models._discovery_models import Deployment, DeploymentConfig, DeploymentEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeploymentManager:
|
||||
"""Manages entity deployments to Azure Container Apps."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize deployment manager."""
|
||||
self._deployments: dict[str, Deployment] = {}
|
||||
|
||||
async def deploy(self, config: DeploymentConfig, entity_path: Path) -> AsyncGenerator[DeploymentEvent, None]:
|
||||
"""Deploy entity to Azure Container Apps with streaming events.
|
||||
|
||||
Args:
|
||||
config: Deployment configuration
|
||||
entity_path: Path to entity directory
|
||||
|
||||
Yields:
|
||||
DeploymentEvent objects for real-time progress updates
|
||||
|
||||
Raises:
|
||||
ValueError: If prerequisites not met or deployment fails
|
||||
"""
|
||||
deployment_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
# Step 1: Validate prerequisites
|
||||
yield DeploymentEvent(
|
||||
type="deploy.validating",
|
||||
message="Checking prerequisites (Azure CLI, Docker, authentication)...",
|
||||
)
|
||||
|
||||
await self._validate_prerequisites()
|
||||
|
||||
# Step 2: Generate Dockerfile
|
||||
yield DeploymentEvent(
|
||||
type="deploy.dockerfile",
|
||||
message="Generating Dockerfile with authentication enabled...",
|
||||
)
|
||||
|
||||
_ = await self._generate_dockerfile(entity_path, config)
|
||||
|
||||
# Step 3: Generate auth token
|
||||
yield DeploymentEvent(
|
||||
type="deploy.token",
|
||||
message="Generating secure authentication token...",
|
||||
)
|
||||
|
||||
auth_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Step 4: Discover existing Container App Environment
|
||||
yield DeploymentEvent(
|
||||
type="deploy.environment",
|
||||
message="Checking for existing Container App Environment...",
|
||||
)
|
||||
|
||||
# Step 5: Build and deploy with Azure CLI
|
||||
yield DeploymentEvent(
|
||||
type="deploy.building",
|
||||
message=f"Deploying to Azure Container Apps ({config.region})...",
|
||||
)
|
||||
|
||||
# Create a queue for streaming events from subprocess
|
||||
event_queue: asyncio.Queue[DeploymentEvent] = asyncio.Queue()
|
||||
|
||||
# Run deployment in background task with event queue
|
||||
deployment_task = asyncio.create_task(self._deploy_to_azure(config, entity_path, auth_token, event_queue))
|
||||
|
||||
# Stream events from queue while deployment runs
|
||||
while True:
|
||||
try:
|
||||
# Check if deployment task is done
|
||||
if deployment_task.done():
|
||||
# Get the result or exception
|
||||
deployment_url = await deployment_task
|
||||
break
|
||||
|
||||
# Get event from queue with short timeout
|
||||
event = await asyncio.wait_for(event_queue.get(), timeout=0.1)
|
||||
yield event
|
||||
except asyncio.TimeoutError:
|
||||
# No event in queue, continue waiting
|
||||
continue
|
||||
|
||||
# Step 5: Store deployment record
|
||||
deployment = Deployment(
|
||||
id=deployment_id,
|
||||
entity_id=config.entity_id,
|
||||
resource_group=config.resource_group,
|
||||
app_name=config.app_name,
|
||||
region=config.region,
|
||||
url=deployment_url,
|
||||
status="deployed",
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
self._deployments[deployment_id] = deployment
|
||||
|
||||
# Step 6: Success - return URL and token
|
||||
yield DeploymentEvent(
|
||||
type="deploy.completed",
|
||||
message=f"Deployment successful! URL: {deployment_url}",
|
||||
url=deployment_url,
|
||||
auth_token=auth_token, # Shown once to user
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Deployment failed: {e!s}"
|
||||
logger.exception(error_msg)
|
||||
|
||||
# Store failed deployment
|
||||
deployment = Deployment(
|
||||
id=deployment_id,
|
||||
entity_id=config.entity_id,
|
||||
resource_group=config.resource_group,
|
||||
app_name=config.app_name,
|
||||
region=config.region,
|
||||
url="",
|
||||
status="failed",
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
error=str(e),
|
||||
)
|
||||
self._deployments[deployment_id] = deployment
|
||||
|
||||
yield DeploymentEvent(
|
||||
type="deploy.failed",
|
||||
message=error_msg,
|
||||
)
|
||||
|
||||
async def _validate_prerequisites(self) -> None:
|
||||
"""Validate that Azure CLI, Docker, authentication, and resource providers are available.
|
||||
|
||||
Raises:
|
||||
ValueError: If prerequisites not met
|
||||
"""
|
||||
# Check Azure CLI
|
||||
az_check = await asyncio.create_subprocess_exec(
|
||||
"az", "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await az_check.communicate()
|
||||
if az_check.returncode != 0:
|
||||
raise ValueError(
|
||||
"Azure CLI not found. Install from: https://learn.microsoft.com/cli/azure/install-azure-cli"
|
||||
)
|
||||
|
||||
# Check Docker
|
||||
docker_check = await asyncio.create_subprocess_exec(
|
||||
"docker", "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await docker_check.communicate()
|
||||
if docker_check.returncode != 0:
|
||||
raise ValueError("Docker not found. Install from: https://www.docker.com/get-started")
|
||||
|
||||
# Check Azure authentication
|
||||
az_account_check = await asyncio.create_subprocess_exec(
|
||||
"az", "account", "show", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, _ = await az_account_check.communicate()
|
||||
if az_account_check.returncode != 0:
|
||||
raise ValueError("Not authenticated with Azure. Run: az login")
|
||||
|
||||
# Check required resource providers are registered
|
||||
required_providers = ["Microsoft.App", "Microsoft.ContainerRegistry", "Microsoft.OperationalInsights"]
|
||||
unregistered_providers = []
|
||||
|
||||
# Get list of registered providers
|
||||
provider_check = await asyncio.create_subprocess_exec(
|
||||
"az",
|
||||
"provider",
|
||||
"list",
|
||||
"--query",
|
||||
"[?registrationState=='Registered'].namespace",
|
||||
"--output",
|
||||
"json",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _stderr = await provider_check.communicate()
|
||||
|
||||
if provider_check.returncode == 0:
|
||||
import json
|
||||
|
||||
try:
|
||||
registered = json.loads(stdout.decode())
|
||||
for provider in required_providers:
|
||||
if provider not in registered:
|
||||
unregistered_providers.append(provider)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Could not parse provider list, skipping provider validation")
|
||||
else:
|
||||
logger.warning("Could not check provider registration status")
|
||||
|
||||
if unregistered_providers:
|
||||
commands = [f"az provider register -n {p} --wait" for p in unregistered_providers]
|
||||
raise ValueError(
|
||||
f"Required Azure resource providers not registered: {', '.join(unregistered_providers)}\n\n"
|
||||
f"Register them by running:\n" + "\n".join(commands) + "\n\n"
|
||||
"This is a one-time setup per Azure subscription."
|
||||
)
|
||||
|
||||
logger.info("All prerequisites validated successfully")
|
||||
|
||||
async def _generate_dockerfile(self, entity_path: Path, config: DeploymentConfig) -> Path:
|
||||
"""Generate Dockerfile for entity deployment.
|
||||
|
||||
Args:
|
||||
entity_path: Path to entity directory
|
||||
config: Deployment configuration
|
||||
|
||||
Returns:
|
||||
Path to generated Dockerfile
|
||||
"""
|
||||
# Validate ui_mode
|
||||
if config.ui_mode not in ["user", "developer"]:
|
||||
raise ValueError(f"Invalid ui_mode: {config.ui_mode}. Must be 'user' or 'developer'.")
|
||||
|
||||
# Check if requirements.txt exists in the entity directory
|
||||
has_requirements = (entity_path / "requirements.txt").exists()
|
||||
|
||||
requirements_section = ""
|
||||
if has_requirements:
|
||||
logger.info(f"Found requirements.txt in {entity_path}, will include in Dockerfile")
|
||||
requirements_section = """# Install entity dependencies
|
||||
COPY requirements.txt ./
|
||||
RUN pip install -r requirements.txt
|
||||
"""
|
||||
else:
|
||||
logger.info(f"No requirements.txt found in {entity_path}, skipping dependency installation")
|
||||
|
||||
dockerfile_content = f"""FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
{requirements_section}# Install DevUI from PyPI
|
||||
RUN pip install agent-framework-devui --pre
|
||||
|
||||
# Copy entity code
|
||||
COPY . /app/entity/
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
# Launch DevUI with auth enabled (token from environment variable)
|
||||
CMD ["devui", "/app/entity", "--mode", "{config.ui_mode}", "--host", "0.0.0.0", "--port", "8080", "--auth"]
|
||||
"""
|
||||
|
||||
dockerfile_path = entity_path / "Dockerfile"
|
||||
|
||||
# Warn if Dockerfile already exists
|
||||
if dockerfile_path.exists():
|
||||
logger.warning(f"Dockerfile already exists at {dockerfile_path}, overwriting...")
|
||||
|
||||
dockerfile_path.write_text(dockerfile_content)
|
||||
logger.info(f"Generated Dockerfile at {dockerfile_path}")
|
||||
|
||||
return dockerfile_path
|
||||
|
||||
async def _discover_container_app_environment(self, resource_group: str, region: str) -> str | None:
|
||||
"""Discover existing Container App Environment in resource group.
|
||||
|
||||
Args:
|
||||
resource_group: Resource group name
|
||||
region: Azure region (for filtering if needed)
|
||||
|
||||
Returns:
|
||||
Environment name if found, None otherwise
|
||||
"""
|
||||
cmd = [
|
||||
"az",
|
||||
"containerapp",
|
||||
"env",
|
||||
"list",
|
||||
"--resource-group",
|
||||
resource_group,
|
||||
"--query",
|
||||
"[0].name",
|
||||
"--output",
|
||||
"tsv",
|
||||
]
|
||||
|
||||
logger.info(f"Discovering existing Container App Environments in {resource_group}...")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
env_name = stdout.decode().strip()
|
||||
if env_name:
|
||||
logger.info(f"Found existing environment: {env_name}")
|
||||
return env_name
|
||||
logger.info("No existing environments found in resource group")
|
||||
return None
|
||||
logger.warning(f"Failed to query environments: {stderr.decode()}")
|
||||
return None
|
||||
|
||||
async def _deploy_to_azure(
|
||||
self, config: DeploymentConfig, entity_path: Path, auth_token: str, event_queue: asyncio.Queue[DeploymentEvent]
|
||||
) -> str:
|
||||
"""Deploy to Azure Container Apps, reusing existing environments.
|
||||
|
||||
Args:
|
||||
config: Deployment configuration
|
||||
entity_path: Path to entity directory
|
||||
auth_token: Authentication token to inject
|
||||
event_queue: Queue for streaming progress events
|
||||
|
||||
Returns:
|
||||
Deployment URL
|
||||
|
||||
Raises:
|
||||
ValueError: If deployment fails
|
||||
"""
|
||||
# Step 1: Try to discover existing Container App Environment
|
||||
existing_env = await self._discover_container_app_environment(config.resource_group, config.region)
|
||||
|
||||
if existing_env:
|
||||
# Use existing environment - avoids needing environment creation permissions
|
||||
logger.info(f"Reusing existing Container App Environment: {existing_env} (cost efficient, no side effects)")
|
||||
cmd = [
|
||||
"az",
|
||||
"containerapp",
|
||||
"up",
|
||||
"--name",
|
||||
config.app_name,
|
||||
"--resource-group",
|
||||
config.resource_group,
|
||||
"--environment",
|
||||
existing_env,
|
||||
"--source",
|
||||
str(entity_path),
|
||||
"--env-vars",
|
||||
f"DEVUI_AUTH_TOKEN={auth_token}",
|
||||
"--ingress",
|
||||
"external",
|
||||
"--target-port",
|
||||
"8080",
|
||||
]
|
||||
logger.info(f"Creating new Container App '{config.app_name}' in environment '{existing_env}'...")
|
||||
else:
|
||||
# No existing environment - try to create one (may fail if no permissions)
|
||||
logger.warning(
|
||||
"No existing Container App Environment found. "
|
||||
"Attempting to create new environment (requires Microsoft.App/managedEnvironments/write permission)..."
|
||||
)
|
||||
cmd = [
|
||||
"az",
|
||||
"containerapp",
|
||||
"up",
|
||||
"--name",
|
||||
config.app_name,
|
||||
"--resource-group",
|
||||
config.resource_group,
|
||||
"--location",
|
||||
config.region,
|
||||
"--source",
|
||||
str(entity_path),
|
||||
"--env-vars",
|
||||
f"DEVUI_AUTH_TOKEN={auth_token}",
|
||||
"--ingress",
|
||||
"external",
|
||||
"--target-port",
|
||||
"8080",
|
||||
]
|
||||
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
|
||||
)
|
||||
|
||||
# Stream output line by line
|
||||
output_lines = []
|
||||
try:
|
||||
if not process.stdout:
|
||||
raise ValueError("Failed to capture process output")
|
||||
|
||||
while True:
|
||||
# Read with timeout
|
||||
line = await asyncio.wait_for(process.stdout.readline(), timeout=600)
|
||||
if not line:
|
||||
break
|
||||
|
||||
line_text = line.decode().strip()
|
||||
if line_text:
|
||||
output_lines.append(line_text)
|
||||
|
||||
# Stream meaningful updates to user
|
||||
if "WARNING:" in line_text:
|
||||
# Parse and send user-friendly warnings
|
||||
if "Creating resource group" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress",
|
||||
message=f"Creating resource group '{config.resource_group}'...",
|
||||
)
|
||||
)
|
||||
elif "Creating ContainerAppEnvironment" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress",
|
||||
message="Setting up Container App Environment (this may take 2-3 minutes)...",
|
||||
)
|
||||
)
|
||||
elif "Registering resource provider" in line_text:
|
||||
provider = line_text.split("provider")[-1].strip()
|
||||
if provider.endswith("..."):
|
||||
provider = provider[:-3]
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress", message=f"Registering Azure provider{provider}..."
|
||||
)
|
||||
)
|
||||
elif "Creating Azure Container Registry" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress", message="Creating Container Registry for your images..."
|
||||
)
|
||||
)
|
||||
elif "No Log Analytics workspace" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress", message="Creating Log Analytics workspace for monitoring..."
|
||||
)
|
||||
)
|
||||
elif "Building image" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress",
|
||||
message="Building Docker image (this may take several minutes)...",
|
||||
)
|
||||
)
|
||||
elif "Pushing image" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress", message="Pushing image to Azure Container Registry..."
|
||||
)
|
||||
)
|
||||
elif "Creating Container App" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(type="deploy.progress", message="Creating your Container App...")
|
||||
)
|
||||
elif "Container app created" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(type="deploy.progress", message="Container app created successfully!")
|
||||
)
|
||||
elif "ERROR:" in line_text:
|
||||
# Stream errors immediately
|
||||
await event_queue.put(DeploymentEvent(type="deploy.error", message=line_text))
|
||||
elif "Step" in line_text and "/" in line_text:
|
||||
# Docker build steps
|
||||
await event_queue.put(
|
||||
DeploymentEvent(type="deploy.progress", message=f"Docker build: {line_text}")
|
||||
)
|
||||
elif "https://" in line_text and ".azurecontainerapps.io" in line_text:
|
||||
# Deployment URL detected
|
||||
await event_queue.put(
|
||||
DeploymentEvent(type="deploy.progress", message="Deployment URL generated!")
|
||||
)
|
||||
|
||||
# Wait for process to complete
|
||||
return_code = await process.wait()
|
||||
|
||||
if return_code != 0:
|
||||
error_output = "\n".join(output_lines[-10:]) # Last 10 lines for context
|
||||
raise ValueError(f"Azure deployment failed:\n{error_output}")
|
||||
|
||||
except asyncio.TimeoutError as e:
|
||||
process.kill()
|
||||
raise ValueError(
|
||||
"Azure deployment timed out after 10 minutes. Please check Azure portal for status."
|
||||
) from e
|
||||
|
||||
# Parse output to extract FQDN
|
||||
output = "\n".join(output_lines)
|
||||
logger.debug(f"Azure CLI output: {output}")
|
||||
|
||||
# Extract FQDN from output (az containerapp up returns it)
|
||||
# Format: https://<app-name>.<random-id>.<region>.azurecontainerapps.io
|
||||
deployment_url = self._extract_fqdn_from_output(output, config.app_name)
|
||||
|
||||
logger.info(f"Deployment successful: {deployment_url}")
|
||||
return deployment_url
|
||||
|
||||
def _extract_fqdn_from_output(self, output: str, app_name: str) -> str:
|
||||
"""Extract FQDN from Azure CLI output.
|
||||
|
||||
Args:
|
||||
output: Azure CLI command output
|
||||
app_name: Container app name
|
||||
|
||||
Returns:
|
||||
Full HTTPS URL to deployed app
|
||||
"""
|
||||
# Try to find FQDN in output
|
||||
for line in output.split("\n"):
|
||||
if "fqdn" in line.lower() or app_name in line:
|
||||
# Extract URL-like string
|
||||
match = re.search(r"https?://[\w\-\.]+\.azurecontainerapps\.io", line)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
# If we can't extract FQDN, fail explicitly rather than return a broken URL
|
||||
logger.error(f"Could not extract FQDN from Azure CLI output. Output:\n{output}")
|
||||
raise ValueError(
|
||||
"Could not extract deployment URL from Azure CLI output. "
|
||||
"The deployment may have succeeded - check the Azure portal for your container app URL."
|
||||
)
|
||||
|
||||
async def list_deployments(self, entity_id: str | None = None) -> list[Deployment]:
|
||||
"""List all deployments, optionally filtered by entity.
|
||||
|
||||
Args:
|
||||
entity_id: Optional entity ID to filter by
|
||||
|
||||
Returns:
|
||||
List of deployment records
|
||||
"""
|
||||
if entity_id:
|
||||
return [d for d in self._deployments.values() if d.entity_id == entity_id]
|
||||
return list(self._deployments.values())
|
||||
|
||||
async def get_deployment(self, deployment_id: str) -> Deployment | None:
|
||||
"""Get deployment by ID.
|
||||
|
||||
Args:
|
||||
deployment_id: Deployment ID
|
||||
|
||||
Returns:
|
||||
Deployment record or None if not found
|
||||
"""
|
||||
return self._deployments.get(deployment_id)
|
||||
|
||||
async def delete_deployment(self, deployment_id: str) -> None:
|
||||
"""Delete deployment from Azure Container Apps.
|
||||
|
||||
Args:
|
||||
deployment_id: Deployment ID to delete
|
||||
|
||||
Raises:
|
||||
ValueError: If deployment not found or deletion fails
|
||||
"""
|
||||
deployment = self._deployments.get(deployment_id)
|
||||
if not deployment:
|
||||
raise ValueError(f"Deployment {deployment_id} not found")
|
||||
|
||||
# Execute: az containerapp delete
|
||||
cmd = [
|
||||
"az",
|
||||
"containerapp",
|
||||
"delete",
|
||||
"--name",
|
||||
deployment.app_name,
|
||||
"--resource-group",
|
||||
deployment.resource_group,
|
||||
"--yes", # Skip confirmation
|
||||
]
|
||||
|
||||
logger.info(f"Deleting deployment: {' '.join(cmd)}")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_output = stderr.decode() if stderr else stdout.decode()
|
||||
raise ValueError(f"Deployment deletion failed: {error_output}")
|
||||
|
||||
# Remove from store
|
||||
del self._deployments[deployment_id]
|
||||
logger.info(f"Deployment {deployment_id} deleted successfully")
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import importlib.util
|
||||
import logging
|
||||
@@ -31,6 +32,7 @@ class EntityDiscovery:
|
||||
self.entities_dir = entities_dir
|
||||
self._entities: dict[str, EntityInfo] = {}
|
||||
self._loaded_objects: dict[str, Any] = {}
|
||||
self._cleanup_hooks: dict[str, list[Any]] = {}
|
||||
|
||||
async def discover_entities(self) -> list[EntityInfo]:
|
||||
"""Scan for Agent Framework entities.
|
||||
@@ -70,14 +72,15 @@ class EntityDiscovery:
|
||||
"""
|
||||
return self._loaded_objects.get(entity_id)
|
||||
|
||||
async def load_entity(self, entity_id: str) -> Any:
|
||||
"""Load entity on-demand (lazy loading).
|
||||
async def load_entity(self, entity_id: str, checkpoint_manager: Any = None) -> Any:
|
||||
"""Load entity on-demand and inject checkpoint storage for workflows.
|
||||
|
||||
This method implements lazy loading by importing the entity module only when needed.
|
||||
In-memory entities are returned from cache immediately.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
checkpoint_manager: Optional checkpoint manager for workflow storage injection
|
||||
|
||||
Returns:
|
||||
Loaded entity object
|
||||
@@ -107,9 +110,13 @@ class EntityDiscovery:
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported entity source: {entity_info.source}. "
|
||||
f"Only 'directory' and 'in_memory' sources are supported."
|
||||
f"Only 'directory' and 'in-memory' sources are supported."
|
||||
)
|
||||
|
||||
# Note: Checkpoint storage is now injected at runtime via run_stream() parameter,
|
||||
# not at load time. This provides cleaner architecture and explicit control flow.
|
||||
# See _executor.py _execute_workflow() for runtime checkpoint storage injection.
|
||||
|
||||
# Enrich metadata with actual entity data
|
||||
# Don't pass entity_type if it's "unknown" - let inference determine the real type
|
||||
enriched_info = await self.create_entity_info_from_object(
|
||||
@@ -122,11 +129,27 @@ class EntityDiscovery:
|
||||
# Preserve the original path from sparse metadata
|
||||
if "path" in entity_info.metadata:
|
||||
enriched_info.metadata["path"] = entity_info.metadata["path"]
|
||||
# Now that we have the path, properly check deployment support
|
||||
entity_path = Path(entity_info.metadata["path"])
|
||||
deployment_supported, deployment_reason = self._check_deployment_support(entity_path, entity_info.source)
|
||||
enriched_info.deployment_supported = deployment_supported
|
||||
enriched_info.deployment_reason = deployment_reason
|
||||
enriched_info.metadata["lazy_loaded"] = True
|
||||
self._entities[entity_id] = enriched_info
|
||||
|
||||
# Cache the loaded object
|
||||
self._loaded_objects[entity_id] = entity_obj
|
||||
|
||||
# Check module-level registry for cleanup hooks
|
||||
from . import _get_registered_cleanup_hooks
|
||||
|
||||
registered_hooks = _get_registered_cleanup_hooks(entity_obj)
|
||||
if registered_hooks:
|
||||
if entity_id not in self._cleanup_hooks:
|
||||
self._cleanup_hooks[entity_id] = []
|
||||
self._cleanup_hooks[entity_id].extend(registered_hooks)
|
||||
logger.debug(f"Discovered {len(registered_hooks)} registered cleanup hook(s) for: {entity_id}")
|
||||
|
||||
logger.info(f"Successfully loaded entity: {entity_id} (type: {enriched_info.type})")
|
||||
|
||||
return entity_obj
|
||||
@@ -187,6 +210,17 @@ class EntityDiscovery:
|
||||
"""
|
||||
return list(self._entities.values())
|
||||
|
||||
def get_cleanup_hooks(self, entity_id: str) -> list[Any]:
|
||||
"""Get cleanup hooks registered for an entity.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
|
||||
Returns:
|
||||
List of cleanup hooks for the entity
|
||||
"""
|
||||
return self._cleanup_hooks.get(entity_id, [])
|
||||
|
||||
def invalidate_entity(self, entity_id: str) -> None:
|
||||
"""Invalidate (clear cache for) an entity to enable hot reload.
|
||||
|
||||
@@ -239,6 +273,17 @@ class EntityDiscovery:
|
||||
"""
|
||||
self._entities[entity_id] = entity_info
|
||||
self._loaded_objects[entity_id] = entity_object
|
||||
|
||||
# Check module-level registry for cleanup hooks
|
||||
from . import _get_registered_cleanup_hooks
|
||||
|
||||
registered_hooks = _get_registered_cleanup_hooks(entity_object)
|
||||
if registered_hooks:
|
||||
if entity_id not in self._cleanup_hooks:
|
||||
self._cleanup_hooks[entity_id] = []
|
||||
self._cleanup_hooks[entity_id].extend(registered_hooks)
|
||||
logger.debug(f"Discovered {len(registered_hooks)} registered cleanup hook(s) for: {entity_id}")
|
||||
|
||||
logger.debug(f"Registered entity: {entity_id} ({entity_info.type})")
|
||||
|
||||
async def create_entity_info_from_object(
|
||||
@@ -305,6 +350,17 @@ class EntityDiscovery:
|
||||
elif not has_run_stream and not has_run:
|
||||
logger.warning(f"Agent '{entity_id}' lacks both run() and run_stream() methods. May not work.")
|
||||
|
||||
# Check deployment support based on source
|
||||
# For directory-based entities, we need the path to verify deployment support
|
||||
deployment_supported = False
|
||||
deployment_reason = "In-memory entities cannot be deployed (no source directory)"
|
||||
|
||||
if source == "directory":
|
||||
# Directory-based entity - will be checked properly after enrichment when path is available
|
||||
# For now, mark as potentially deployable - will be re-evaluated after enrichment
|
||||
deployment_supported = True
|
||||
deployment_reason = "Ready for deployment (pending path verification)"
|
||||
|
||||
# Create EntityInfo with Agent Framework specifics
|
||||
return EntityInfo(
|
||||
id=entity_id,
|
||||
@@ -321,6 +377,8 @@ class EntityDiscovery:
|
||||
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,
|
||||
deployment_supported=deployment_supported,
|
||||
deployment_reason=deployment_reason,
|
||||
metadata={
|
||||
"source": "agent_framework_object",
|
||||
"class_name": entity_object.__class__.__name__
|
||||
@@ -404,6 +462,31 @@ class EntityDiscovery:
|
||||
# Has __init__.py but no specific file
|
||||
return "unknown"
|
||||
|
||||
def _check_deployment_support(self, entity_path: Path, source: str) -> tuple[bool, str | None]:
|
||||
"""Check if entity can be deployed to Azure Container Apps.
|
||||
|
||||
Args:
|
||||
entity_path: Path to entity directory or file
|
||||
source: Entity source ("directory" or "in_memory")
|
||||
|
||||
Returns:
|
||||
Tuple of (supported, reason) explaining deployment eligibility
|
||||
"""
|
||||
# In-memory entities cannot be deployed
|
||||
if source == "in_memory":
|
||||
return False, "In-memory entities cannot be deployed (no source directory)"
|
||||
|
||||
# File-based entities need a directory structure for deployment
|
||||
if not entity_path.is_dir():
|
||||
return False, "Only directory-based entities can be deployed"
|
||||
|
||||
# Must have __init__.py
|
||||
if not (entity_path / "__init__.py").exists():
|
||||
return False, "Missing __init__.py file"
|
||||
|
||||
# Passed all checks
|
||||
return True, "Ready for deployment"
|
||||
|
||||
def _register_sparse_entity(self, dir_path: Path) -> None:
|
||||
"""Register entity with sparse metadata (no import).
|
||||
|
||||
@@ -413,6 +496,9 @@ class EntityDiscovery:
|
||||
entity_id = dir_path.name
|
||||
entity_type = self._detect_entity_type(dir_path)
|
||||
|
||||
# Check deployment support
|
||||
deployment_supported, deployment_reason = self._check_deployment_support(dir_path, "directory")
|
||||
|
||||
entity_info = EntityInfo(
|
||||
id=entity_id,
|
||||
name=entity_id.replace("_", " ").title(),
|
||||
@@ -421,6 +507,8 @@ class EntityDiscovery:
|
||||
tools=[], # Sparse - will be populated on load
|
||||
description="", # Sparse - will be populated on load
|
||||
source="directory",
|
||||
deployment_supported=deployment_supported,
|
||||
deployment_reason=deployment_reason,
|
||||
metadata={
|
||||
"path": str(dir_path),
|
||||
"discovered": True,
|
||||
@@ -431,14 +519,52 @@ class EntityDiscovery:
|
||||
self._entities[entity_id] = entity_info
|
||||
logger.debug(f"Registered sparse entity: {entity_id} (type: {entity_type})")
|
||||
|
||||
def _has_entity_exports(self, file_path: Path) -> bool:
|
||||
"""Check if a Python file has entity exports (agent or workflow) using AST parsing.
|
||||
|
||||
This safely checks for module-level assignments like:
|
||||
- agent = ChatAgent(...)
|
||||
- workflow = WorkflowBuilder()...
|
||||
|
||||
Args:
|
||||
file_path: Python file to check
|
||||
|
||||
Returns:
|
||||
True if file has 'agent' or 'workflow' exports
|
||||
"""
|
||||
try:
|
||||
# Read and parse the file's AST
|
||||
source = file_path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(file_path))
|
||||
|
||||
# Look for module-level assignments of 'agent' or 'workflow'
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id in ("agent", "workflow"):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not parse {file_path} for entity exports: {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def _register_sparse_file_entity(self, file_path: Path) -> None:
|
||||
"""Register file-based entity with sparse metadata (no import).
|
||||
|
||||
Args:
|
||||
file_path: Entity Python file
|
||||
"""
|
||||
# Check if file has valid entity exports using AST parsing
|
||||
if not self._has_entity_exports(file_path):
|
||||
logger.debug(f"Skipping {file_path.name} - no 'agent' or 'workflow' exports found")
|
||||
return
|
||||
|
||||
entity_id = file_path.stem
|
||||
|
||||
# Check deployment support (file-based entities cannot be deployed)
|
||||
deployment_supported, deployment_reason = self._check_deployment_support(file_path, "directory")
|
||||
|
||||
# File-based entities are typically agents, but we can't know for sure without importing
|
||||
entity_info = EntityInfo(
|
||||
id=entity_id,
|
||||
@@ -448,6 +574,8 @@ class EntityDiscovery:
|
||||
tools=[],
|
||||
description="",
|
||||
source="directory",
|
||||
deployment_supported=deployment_supported,
|
||||
deployment_reason=deployment_reason,
|
||||
metadata={
|
||||
"path": str(file_path),
|
||||
"discovered": True,
|
||||
|
||||
@@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentProtocol
|
||||
from agent_framework._workflows._events import RequestInfoEvent
|
||||
|
||||
from ._conversations import ConversationStore, InMemoryConversationStore
|
||||
from ._discovery import EntityDiscovery
|
||||
@@ -50,6 +51,11 @@ class AgentFrameworkExecutor:
|
||||
# Use provided conversation store or default to in-memory
|
||||
self.conversation_store = conversation_store or InMemoryConversationStore()
|
||||
|
||||
# Create checkpoint manager (wraps conversation store)
|
||||
from ._conversations import CheckpointConversationManager
|
||||
|
||||
self.checkpoint_manager = CheckpointConversationManager(self.conversation_store)
|
||||
|
||||
def _setup_tracing_provider(self) -> None:
|
||||
"""Set up our own TracerProvider so we can add processors."""
|
||||
try:
|
||||
@@ -79,10 +85,20 @@ class AgentFrameworkExecutor:
|
||||
# Configure Agent Framework tracing only if ENABLE_OTEL is set
|
||||
if os.environ.get("ENABLE_OTEL"):
|
||||
try:
|
||||
from agent_framework.observability import setup_observability
|
||||
from agent_framework.observability import OBSERVABILITY_SETTINGS, setup_observability
|
||||
|
||||
setup_observability(enable_sensitive_data=True)
|
||||
logger.info("Enabled Agent Framework observability")
|
||||
# Only configure if not already executed
|
||||
if not OBSERVABILITY_SETTINGS._executed_setup:
|
||||
# Get OTLP endpoint from either custom or standard env var
|
||||
# This handles the case where env vars are set after ObservabilitySettings was imported
|
||||
otlp_endpoint = os.environ.get("OTLP_ENDPOINT") or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
|
||||
# Pass the endpoint explicitly to setup_observability
|
||||
# This ensures OTLP exporters are created even if env vars were set late
|
||||
setup_observability(enable_sensitive_data=True, otlp_endpoint=otlp_endpoint)
|
||||
logger.info("Enabled Agent Framework observability")
|
||||
else:
|
||||
logger.debug("Agent Framework observability already configured")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to enable Agent Framework observability: {e}")
|
||||
else:
|
||||
@@ -173,7 +189,7 @@ class AgentFrameworkExecutor:
|
||||
entity_info = self.get_entity_info(entity_id)
|
||||
|
||||
# Trigger lazy loading (will return from cache if already loaded)
|
||||
entity_obj = await self.entity_discovery.load_entity(entity_id)
|
||||
entity_obj = await self.entity_discovery.load_entity(entity_id, checkpoint_manager=self.checkpoint_manager)
|
||||
|
||||
if not entity_obj:
|
||||
raise EntityNotFoundError(f"Entity object for '{entity_id}' not found")
|
||||
@@ -190,6 +206,15 @@ class AgentFrameworkExecutor:
|
||||
yield event
|
||||
elif entity_info.type == "workflow":
|
||||
async for event in self._execute_workflow(entity_obj, request, trace_collector):
|
||||
# Log RequestInfoEvent for debugging HIL flow
|
||||
event_class = event.__class__.__name__ if hasattr(event, "__class__") else type(event).__name__
|
||||
if event_class == "RequestInfoEvent":
|
||||
logger.info("🔔 [EXECUTOR] RequestInfoEvent detected from workflow!")
|
||||
logger.info(f" request_id: {getattr(event, 'request_id', 'N/A')}")
|
||||
logger.info(f" source_executor_id: {getattr(event, 'source_executor_id', 'N/A')}")
|
||||
logger.info(f" request_type: {getattr(event, 'request_type', 'N/A')}")
|
||||
data = getattr(event, "data", None)
|
||||
logger.info(f" data type: {type(data).__name__ if data else 'None'}")
|
||||
yield event
|
||||
else:
|
||||
raise ValueError(f"Unsupported entity type: {entity_info.type}")
|
||||
@@ -289,7 +314,7 @@ class AgentFrameworkExecutor:
|
||||
async def _execute_workflow(
|
||||
self, workflow: Any, request: AgentFrameworkRequest, trace_collector: Any
|
||||
) -> AsyncGenerator[Any, None]:
|
||||
"""Execute Agent Framework workflow with trace collection.
|
||||
"""Execute Agent Framework workflow with checkpoint support via conversation items.
|
||||
|
||||
Args:
|
||||
workflow: Workflow object to execute
|
||||
@@ -300,23 +325,199 @@ class AgentFrameworkExecutor:
|
||||
Workflow events and trace events
|
||||
"""
|
||||
try:
|
||||
# Get input data directly from request.input field
|
||||
input_data = request.input
|
||||
logger.debug(f"Using input field: {type(input_data)}")
|
||||
entity_id = request.get_entity_id() or "unknown"
|
||||
|
||||
# Parse input based on workflow's expected input type
|
||||
parsed_input = await self._parse_workflow_input(workflow, input_data)
|
||||
# Get or create session conversation for checkpoint storage
|
||||
conversation_id = request.get_conversation_id()
|
||||
if not conversation_id:
|
||||
# Create default session if not provided
|
||||
import time
|
||||
import uuid
|
||||
|
||||
logger.debug(f"Executing workflow with parsed input type: {type(parsed_input)}")
|
||||
conversation_id = f"session_{entity_id}_{uuid.uuid4().hex[:8]}"
|
||||
logger.info(f"Created new workflow session: {conversation_id}")
|
||||
|
||||
# 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
|
||||
# Create conversation in store
|
||||
self.conversation_store.create_conversation(
|
||||
metadata={
|
||||
"entity_id": entity_id,
|
||||
"type": "workflow_session",
|
||||
"created_at": str(int(time.time())),
|
||||
},
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
else:
|
||||
# Validate conversation exists, create if missing (handles deleted conversations)
|
||||
import time
|
||||
|
||||
# Then yield the workflow event
|
||||
yield event
|
||||
existing = self.conversation_store.get_conversation(conversation_id)
|
||||
if not existing:
|
||||
logger.warning(f"Conversation {conversation_id} not found (may have been deleted), recreating")
|
||||
self.conversation_store.create_conversation(
|
||||
metadata={
|
||||
"entity_id": entity_id,
|
||||
"type": "workflow_session",
|
||||
"created_at": str(int(time.time())),
|
||||
},
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
# Get session-scoped checkpoint storage (InMemoryCheckpointStorage from conv_data)
|
||||
# Each conversation has its own storage instance, providing automatic session isolation.
|
||||
# This storage is passed to workflow.run_stream() which sets it as runtime override,
|
||||
# ensuring all checkpoint operations (save/load) use THIS conversation's storage.
|
||||
# The framework guarantees runtime storage takes precedence over build-time storage.
|
||||
checkpoint_storage = self.checkpoint_manager.get_checkpoint_storage(conversation_id)
|
||||
|
||||
# Check for HIL responses first
|
||||
hil_responses = self._extract_workflow_hil_responses(request.input)
|
||||
|
||||
# Determine checkpoint_id (explicit or auto-latest for HIL responses)
|
||||
checkpoint_id = None
|
||||
if request.extra_body and "checkpoint_id" in request.extra_body:
|
||||
checkpoint_id = request.extra_body["checkpoint_id"]
|
||||
logger.debug(f"Using explicit checkpoint_id from request: {checkpoint_id}")
|
||||
elif hil_responses:
|
||||
# Only auto-resume from latest checkpoint when we have HIL responses
|
||||
# Regular "Run" clicks should start fresh, not resume from checkpoints
|
||||
checkpoints = await checkpoint_storage.list_checkpoints() # No workflow_id filter needed!
|
||||
if checkpoints:
|
||||
latest = max(checkpoints, key=lambda cp: cp.timestamp)
|
||||
checkpoint_id = latest.checkpoint_id
|
||||
logger.info(f"Auto-resuming from latest checkpoint in session {conversation_id}: {checkpoint_id}")
|
||||
else:
|
||||
logger.warning(f"HIL responses received but no checkpoints in session {conversation_id}")
|
||||
|
||||
if hil_responses:
|
||||
# HIL continuation mode requires checkpointing
|
||||
if not checkpoint_id:
|
||||
error_msg = (
|
||||
"Cannot process HIL responses without a checkpoint. "
|
||||
"Workflows using HIL must be configured with .with_checkpointing() "
|
||||
"and a checkpoint must exist before sending responses."
|
||||
)
|
||||
logger.error(error_msg)
|
||||
yield {"type": "error", "message": error_msg}
|
||||
return
|
||||
|
||||
logger.info(f"Resuming workflow with HIL responses for {len(hil_responses)} request(s)")
|
||||
|
||||
# Unwrap primitive responses if they're wrapped in {response: value} format
|
||||
from ._utils import parse_input_for_type
|
||||
|
||||
unwrapped_responses = {}
|
||||
for request_id, response_value in hil_responses.items():
|
||||
if isinstance(response_value, dict) and "response" in response_value:
|
||||
response_value = response_value["response"]
|
||||
unwrapped_responses[request_id] = response_value
|
||||
|
||||
hil_responses = unwrapped_responses
|
||||
|
||||
# NOTE: Two-step approach for stateless HTTP (framework limitation):
|
||||
# 1. Restore checkpoint to load pending requests into workflow's in-memory state
|
||||
# 2. Then send responses using send_responses_streaming
|
||||
# Future: Framework should support run_stream(checkpoint_id, responses) in single call
|
||||
# (checkpoint_id is guaranteed to exist due to earlier validation)
|
||||
logger.debug(f"Restoring checkpoint {checkpoint_id} then sending HIL responses")
|
||||
|
||||
try:
|
||||
# Step 1: Restore checkpoint to populate workflow's in-memory pending requests
|
||||
restored = False
|
||||
async for _event in workflow.run_stream(
|
||||
checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage
|
||||
):
|
||||
restored = True
|
||||
break # Stop immediately after restoration, don't process events
|
||||
|
||||
if not restored:
|
||||
raise RuntimeError("Checkpoint restoration did not yield any events")
|
||||
|
||||
# Reset running flags so we can call send_responses_streaming
|
||||
if hasattr(workflow, "_is_running"):
|
||||
workflow._is_running = False
|
||||
if hasattr(workflow, "_runner") and hasattr(workflow._runner, "_running"):
|
||||
workflow._runner._running = False
|
||||
|
||||
# Extract response types from restored workflow and convert responses to proper types
|
||||
try:
|
||||
if hasattr(workflow, "_runner") and hasattr(workflow._runner, "context"):
|
||||
runner_context = workflow._runner.context
|
||||
pending_requests_dict = await runner_context.get_pending_request_info_events()
|
||||
|
||||
converted_responses = {}
|
||||
for request_id, response_value in hil_responses.items():
|
||||
if request_id in pending_requests_dict:
|
||||
pending_request = pending_requests_dict[request_id]
|
||||
if hasattr(pending_request, "response_type"):
|
||||
response_type = pending_request.response_type
|
||||
try:
|
||||
response_value = parse_input_for_type(response_value, response_type)
|
||||
logger.debug(
|
||||
f"Converted HIL response for {request_id} to {type(response_value)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to convert HIL response for {request_id}: {e}")
|
||||
|
||||
converted_responses[request_id] = response_value
|
||||
|
||||
hil_responses = converted_responses
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not convert HIL responses to proper types: {e}")
|
||||
|
||||
# Step 2: Now send responses to the in-memory workflow
|
||||
async for event in workflow.send_responses_streaming(hil_responses):
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
yield trace_event
|
||||
yield event
|
||||
|
||||
except (AttributeError, ValueError, RuntimeError) as e:
|
||||
error_msg = f"Failed to send HIL responses: {e}"
|
||||
logger.error(error_msg)
|
||||
yield {"type": "error", "message": error_msg}
|
||||
|
||||
elif checkpoint_id:
|
||||
# Resume from checkpoint (explicit or auto-latest) using unified API
|
||||
logger.info(f"Resuming workflow from checkpoint {checkpoint_id} in session {conversation_id}")
|
||||
|
||||
try:
|
||||
async for event in workflow.run_stream(
|
||||
checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage
|
||||
):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
self._enrich_request_info_event_with_response_schema(event, workflow)
|
||||
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
yield trace_event
|
||||
|
||||
yield event
|
||||
|
||||
# Note: Removed break on RequestInfoEvent - continue yielding all events
|
||||
# The workflow is already paused by ctx.request_info() in the framework
|
||||
# DevUI should continue yielding events even during HIL pause
|
||||
|
||||
except ValueError as e:
|
||||
error_msg = f"Cannot resume from checkpoint: {e}"
|
||||
logger.error(error_msg)
|
||||
yield {"type": "error", "message": error_msg}
|
||||
|
||||
else:
|
||||
# First run - pass DevUI's checkpoint storage to enable checkpointing
|
||||
logger.info(f"Starting fresh workflow in session {conversation_id}")
|
||||
|
||||
parsed_input = await self._parse_workflow_input(workflow, request.input)
|
||||
|
||||
async for event in workflow.run_stream(parsed_input, checkpoint_storage=checkpoint_storage):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
self._enrich_request_info_event_with_response_schema(event, workflow)
|
||||
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
yield trace_event
|
||||
|
||||
yield event
|
||||
|
||||
# Note: Removed break on RequestInfoEvent - continue yielding all events
|
||||
# The workflow is already paused by ctx.request_info() in the framework
|
||||
# DevUI should continue yielding events even during HIL pause
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in workflow execution: {e}")
|
||||
@@ -569,6 +770,59 @@ class AgentFrameworkExecutor:
|
||||
|
||||
return start_executor, message_types
|
||||
|
||||
def _extract_workflow_hil_responses(self, input_data: Any) -> dict[str, Any] | None:
|
||||
"""Extract workflow HIL responses from OpenAI input format.
|
||||
|
||||
Looks for special content type: workflow_hil_response
|
||||
|
||||
Args:
|
||||
input_data: OpenAI ResponseInputParam
|
||||
|
||||
Returns:
|
||||
Dict of {request_id: response_value} if found, None otherwise
|
||||
"""
|
||||
if not isinstance(input_data, list):
|
||||
return None
|
||||
|
||||
for item in input_data:
|
||||
if isinstance(item, dict) and item.get("type") == "message":
|
||||
message_content = item.get("content", [])
|
||||
|
||||
if isinstance(message_content, list):
|
||||
for content_item in message_content:
|
||||
if isinstance(content_item, dict):
|
||||
content_type = content_item.get("type")
|
||||
|
||||
if content_type == "workflow_hil_response":
|
||||
# Extract responses dict
|
||||
# dict.get() returns Any, so we explicitly type it
|
||||
responses: dict[str, Any] = content_item.get("responses", {}) # type: ignore[assignment]
|
||||
logger.info(f"Found workflow HIL responses: {list(responses.keys())}")
|
||||
return responses
|
||||
|
||||
return None
|
||||
|
||||
def _get_or_create_conversation(self, conversation_id: str, entity_id: str) -> Any:
|
||||
"""Get existing conversation or create a new one.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID from frontend
|
||||
entity_id: Entity ID (e.g., "spam_workflow") for metadata filtering
|
||||
|
||||
Returns:
|
||||
Conversation object
|
||||
"""
|
||||
conversation = self.conversation_store.get_conversation(conversation_id)
|
||||
if not conversation:
|
||||
# Create conversation with frontend's ID
|
||||
# Use agent_id in metadata so it can be filtered by list_conversations(agent_id=...)
|
||||
conversation = self.conversation_store.create_conversation(
|
||||
metadata={"agent_id": entity_id}, conversation_id=conversation_id
|
||||
)
|
||||
logger.info(f"Created conversation {conversation_id} for entity {entity_id}")
|
||||
|
||||
return conversation
|
||||
|
||||
def _parse_structured_workflow_input(self, workflow: Any, input_data: dict[str, Any]) -> Any:
|
||||
"""Parse structured input data for workflow execution.
|
||||
|
||||
@@ -644,3 +898,53 @@ class AgentFrameworkExecutor:
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing workflow input: {e}")
|
||||
return raw_input
|
||||
|
||||
def _enrich_request_info_event_with_response_schema(self, event: Any, workflow: Any) -> None:
|
||||
"""Extract response type from workflow executor and attach response schema to RequestInfoEvent.
|
||||
|
||||
Args:
|
||||
event: RequestInfoEvent to enrich
|
||||
workflow: Workflow object containing executors
|
||||
"""
|
||||
try:
|
||||
from agent_framework_devui._utils import extract_response_type_from_executor, generate_input_schema
|
||||
|
||||
# Get source executor ID and request type from event
|
||||
source_executor_id = getattr(event, "source_executor_id", None)
|
||||
request_type = getattr(event, "request_type", None)
|
||||
|
||||
if not source_executor_id or not request_type:
|
||||
logger.debug("RequestInfoEvent missing source_executor_id or request_type")
|
||||
return
|
||||
|
||||
# Find the source executor in the workflow
|
||||
if not hasattr(workflow, "executors") or not isinstance(workflow.executors, dict):
|
||||
logger.debug("Workflow doesn't have executors dict")
|
||||
return
|
||||
|
||||
source_executor = workflow.executors.get(source_executor_id)
|
||||
if not source_executor:
|
||||
logger.debug(f"Could not find executor '{source_executor_id}' in workflow")
|
||||
return
|
||||
|
||||
# Extract response type from the executor's handler signature
|
||||
response_type = extract_response_type_from_executor(source_executor, request_type)
|
||||
|
||||
if response_type:
|
||||
# Generate JSON schema for response type
|
||||
response_schema = generate_input_schema(response_type)
|
||||
|
||||
# Attach response_schema to event for mapper to include in output
|
||||
event._response_schema = response_schema
|
||||
|
||||
logger.debug(f"Extracted response schema for {request_type.__name__}: {response_schema}")
|
||||
else:
|
||||
# Even if extraction fails, provide a reasonable default to avoid warnings
|
||||
logger.debug(
|
||||
f"Could not extract response type for {request_type.__name__}, using default string schema"
|
||||
)
|
||||
response_schema = {"type": "string"}
|
||||
event._response_schema = response_schema
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to enrich RequestInfoEvent with response schema: {e}")
|
||||
|
||||
@@ -34,6 +34,9 @@ from .models import (
|
||||
ResponseFunctionCallArgumentsDeltaEvent,
|
||||
ResponseFunctionResultComplete,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseOutputData,
|
||||
ResponseOutputFile,
|
||||
ResponseOutputImage,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputText,
|
||||
@@ -160,7 +163,7 @@ class MessageMapper:
|
||||
if isinstance(raw_event, ResponseTraceEvent):
|
||||
return [
|
||||
ResponseTraceEventComplete(
|
||||
type="response.trace.complete",
|
||||
type="response.trace.completed",
|
||||
data=raw_event.data,
|
||||
item_id=context["item_id"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
@@ -338,6 +341,147 @@ class MessageMapper:
|
||||
context["sequence_counter"] += 1
|
||||
return int(context["sequence_counter"])
|
||||
|
||||
def _serialize_value(self, value: Any) -> Any:
|
||||
"""Recursively serialize a value, handling complex nested objects.
|
||||
|
||||
Handles:
|
||||
- Primitives (str, int, float, bool, None)
|
||||
- Collections (list, tuple, set, dict)
|
||||
- SerializationMixin objects (ChatMessage, etc.) - calls to_dict()
|
||||
- Pydantic models - calls model_dump()
|
||||
- Dataclasses - recursively serializes with asdict()
|
||||
- Enums - extracts value
|
||||
- datetime/date/UUID - converts to ISO string
|
||||
|
||||
Args:
|
||||
value: Value to serialize
|
||||
|
||||
Returns:
|
||||
JSON-serializable representation
|
||||
"""
|
||||
from dataclasses import is_dataclass
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from uuid import UUID
|
||||
|
||||
# Handle None
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# Handle primitives
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
|
||||
# Handle datetime/date - convert to ISO format
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
|
||||
# Handle UUID - convert to string
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
|
||||
# Handle Enums - extract value
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
|
||||
# Handle lists/tuples/sets - recursively serialize elements
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [self._serialize_value(item) for item in value]
|
||||
if isinstance(value, set):
|
||||
return [self._serialize_value(item) for item in value]
|
||||
|
||||
# Handle dicts - recursively serialize values
|
||||
if isinstance(value, dict):
|
||||
return {k: self._serialize_value(v) for k, v in value.items()}
|
||||
|
||||
# Handle SerializationMixin (like ChatMessage) - call to_dict()
|
||||
if hasattr(value, "to_dict") and callable(getattr(value, "to_dict", None)):
|
||||
try:
|
||||
return value.to_dict() # type: ignore[attr-defined, no-any-return]
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to serialize with to_dict(): {e}")
|
||||
return str(value)
|
||||
|
||||
# Handle Pydantic models - call model_dump()
|
||||
if hasattr(value, "model_dump") and callable(getattr(value, "model_dump", None)):
|
||||
try:
|
||||
return value.model_dump() # type: ignore[attr-defined, no-any-return]
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to serialize Pydantic model: {e}")
|
||||
return str(value)
|
||||
|
||||
# Handle dataclasses - recursively serialize with asdict
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
try:
|
||||
from dataclasses import asdict
|
||||
|
||||
# Use our custom serializer as dict_factory
|
||||
return asdict(value, dict_factory=lambda items: {k: self._serialize_value(v) for k, v in items})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to serialize nested dataclass: {e}")
|
||||
return str(value)
|
||||
|
||||
# Fallback: convert to string (for unknown types)
|
||||
logger.debug(f"Serializing unknown type {type(value).__name__} as string")
|
||||
return str(value)
|
||||
|
||||
def _serialize_request_data(self, request_data: Any) -> dict[str, Any]:
|
||||
"""Serialize RequestInfoMessage to dict for JSON transmission.
|
||||
|
||||
Handles nested SerializationMixin objects (like ChatMessage) within dataclasses.
|
||||
|
||||
Args:
|
||||
request_data: The RequestInfoMessage instance
|
||||
|
||||
Returns:
|
||||
Serialized dict representation
|
||||
"""
|
||||
from dataclasses import asdict, fields, is_dataclass
|
||||
|
||||
if request_data is None:
|
||||
return {}
|
||||
|
||||
# Handle dict first (most common)
|
||||
if isinstance(request_data, dict):
|
||||
return {k: self._serialize_value(v) for k, v in request_data.items()}
|
||||
|
||||
# Handle dataclasses with nested SerializationMixin objects
|
||||
# We can't use asdict() directly because it doesn't handle ChatMessage
|
||||
if is_dataclass(request_data) and not isinstance(request_data, type):
|
||||
try:
|
||||
# Manually serialize each field to handle nested SerializationMixin
|
||||
result = {}
|
||||
for field in fields(request_data):
|
||||
field_value = getattr(request_data, field.name)
|
||||
result[field.name] = self._serialize_value(field_value)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to serialize dataclass fields: {e}")
|
||||
# Fallback to asdict() if our custom serialization fails
|
||||
try:
|
||||
return asdict(request_data) # type: ignore[arg-type]
|
||||
except Exception as e2:
|
||||
logger.debug(f"Failed to serialize dataclass with asdict(): {e2}")
|
||||
|
||||
# Handle Pydantic models (have model_dump method)
|
||||
if hasattr(request_data, "model_dump") and callable(getattr(request_data, "model_dump", None)):
|
||||
try:
|
||||
return request_data.model_dump() # type: ignore[attr-defined, no-any-return]
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to serialize Pydantic model: {e}")
|
||||
|
||||
# Handle SerializationMixin (have to_dict method)
|
||||
if hasattr(request_data, "to_dict") and callable(getattr(request_data, "to_dict", None)):
|
||||
try:
|
||||
return request_data.to_dict() # type: ignore[attr-defined, no-any-return]
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to serialize with to_dict(): {e}")
|
||||
|
||||
# Fallback: string representation
|
||||
return {"raw": str(request_data)}
|
||||
|
||||
async def _convert_agent_update(self, update: Any, context: dict[str, Any]) -> Sequence[Any]:
|
||||
"""Convert agent text updates to proper content part events.
|
||||
|
||||
@@ -638,7 +782,65 @@ class MessageMapper:
|
||||
|
||||
return events
|
||||
|
||||
if event_class in ["WorkflowCompletedEvent", "WorkflowOutputEvent"]:
|
||||
# Handle WorkflowOutputEvent separately to preserve output data
|
||||
if event_class == "WorkflowOutputEvent":
|
||||
output_data = getattr(event, "data", None)
|
||||
source_executor_id = getattr(event, "source_executor_id", "unknown")
|
||||
|
||||
if output_data is not None:
|
||||
# Import required types
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
|
||||
|
||||
# Increment output index for each yield_output
|
||||
context["output_index"] = context.get("output_index", -1) + 1
|
||||
|
||||
# Extract text from output data based on type
|
||||
text = None
|
||||
if hasattr(output_data, "__class__") and output_data.__class__.__name__ == "ChatMessage":
|
||||
# Handle ChatMessage (from Magentic and AgentExecutor with output_response=True)
|
||||
text = getattr(output_data, "text", None)
|
||||
if not text:
|
||||
# Fallback to string representation
|
||||
text = str(output_data)
|
||||
elif isinstance(output_data, str):
|
||||
# String output
|
||||
text = output_data
|
||||
else:
|
||||
# Object/dict/list → JSON string
|
||||
try:
|
||||
text = json.dumps(output_data, indent=2)
|
||||
except (TypeError, ValueError):
|
||||
# Fallback to string representation if not JSON serializable
|
||||
text = str(output_data)
|
||||
|
||||
# Create output message with text content
|
||||
text_content = ResponseOutputText(type="output_text", text=text, annotations=[])
|
||||
|
||||
output_message = ResponseOutputMessage(
|
||||
type="message",
|
||||
id=f"msg_{uuid4().hex[:8]}",
|
||||
role="assistant",
|
||||
content=[text_content],
|
||||
status="completed",
|
||||
)
|
||||
|
||||
# Emit output_item.added for each yield_output
|
||||
logger.debug(
|
||||
f"WorkflowOutputEvent converted to output_item.added "
|
||||
f"(executor: {source_executor_id}, length: {len(text)})"
|
||||
)
|
||||
return [
|
||||
ResponseOutputItemAddedEvent(
|
||||
type="response.output_item.added",
|
||||
item=output_message,
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
]
|
||||
|
||||
# Handle WorkflowCompletedEvent - emit response.completed
|
||||
if event_class == "WorkflowCompletedEvent":
|
||||
workflow_id = context.get("workflow_id", str(uuid4()))
|
||||
|
||||
# Import Response type for proper construction
|
||||
@@ -654,7 +856,7 @@ class MessageMapper:
|
||||
object="response",
|
||||
created_at=float(time.time()),
|
||||
model=model_name,
|
||||
output=[], # Output should be populated by this point from text streaming
|
||||
output=[], # Output items already sent via output_item.added events
|
||||
status="completed",
|
||||
parallel_tool_calls=False,
|
||||
tool_choice="none",
|
||||
@@ -781,8 +983,77 @@ class MessageMapper:
|
||||
)
|
||||
]
|
||||
|
||||
# Handle informational workflow events (status, warnings, errors)
|
||||
if event_class in ["WorkflowStatusEvent", "WorkflowWarningEvent", "WorkflowErrorEvent", "RequestInfoEvent"]:
|
||||
# Handle RequestInfoEvent specially - emit as HIL event with schema
|
||||
if event_class == "RequestInfoEvent":
|
||||
from .models._openai_custom import ResponseRequestInfoEvent
|
||||
|
||||
request_id = getattr(event, "request_id", "")
|
||||
source_executor_id = getattr(event, "source_executor_id", "")
|
||||
request_type_class = getattr(event, "request_type", None)
|
||||
request_data = getattr(event, "data", None)
|
||||
|
||||
logger.info("📨 [MAPPER] Processing RequestInfoEvent")
|
||||
logger.info(f" request_id: {request_id}")
|
||||
logger.info(f" source_executor_id: {source_executor_id}")
|
||||
logger.info(f" request_type_class: {request_type_class}")
|
||||
logger.info(f" request_data: {request_data}")
|
||||
|
||||
# Serialize request data
|
||||
serialized_data = self._serialize_request_data(request_data)
|
||||
logger.info(f" serialized_data: {serialized_data}")
|
||||
|
||||
# Get request type name for debugging
|
||||
request_type_name = "Unknown"
|
||||
if request_type_class:
|
||||
request_type_name = f"{request_type_class.__module__}:{request_type_class.__name__}"
|
||||
|
||||
# Get response schema that was attached by executor
|
||||
# This tells the UI what format to collect from the user
|
||||
response_schema = getattr(event, "_response_schema", None)
|
||||
if not response_schema:
|
||||
# Fallback to string if somehow not set (shouldn't happen with current executor enrichment)
|
||||
logger.warning(f"⚠️ Response schema not found for {request_type_name}, using default")
|
||||
response_schema = {"type": "string"}
|
||||
else:
|
||||
logger.info(f" response_schema: {response_schema}")
|
||||
|
||||
# Wrap primitive schemas in object for form rendering
|
||||
# The UI's SchemaFormRenderer expects an object with properties
|
||||
if response_schema.get("type") in ["string", "integer", "number", "boolean"]:
|
||||
# Wrap primitive type in object with "response" field
|
||||
wrapped_schema = {
|
||||
"type": "object",
|
||||
"properties": {"response": response_schema},
|
||||
"required": ["response"],
|
||||
}
|
||||
logger.info(" wrapped primitive schema in object")
|
||||
else:
|
||||
wrapped_schema = response_schema
|
||||
|
||||
# Create HIL request event with response schema
|
||||
hil_event = ResponseRequestInfoEvent(
|
||||
type="response.request_info.requested",
|
||||
request_id=request_id,
|
||||
source_executor_id=source_executor_id,
|
||||
request_type=request_type_name,
|
||||
request_data=serialized_data,
|
||||
request_schema=wrapped_schema, # Send wrapped schema for form rendering
|
||||
response_schema=response_schema, # Keep original for reference
|
||||
item_id=context["item_id"],
|
||||
output_index=context.get("output_index", 0),
|
||||
sequence_number=self._next_sequence(context),
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
logger.info("✅ [MAPPER] Created ResponseRequestInfoEvent:")
|
||||
logger.info(f" type: {hil_event.type}")
|
||||
logger.info(f" request_id: {hil_event.request_id}")
|
||||
logger.info(f" sequence_number: {hil_event.sequence_number}")
|
||||
|
||||
return [hil_event]
|
||||
|
||||
# Handle other informational workflow events (status, warnings, errors)
|
||||
if event_class in ["WorkflowStatusEvent", "WorkflowWarningEvent", "WorkflowErrorEvent"]:
|
||||
# These are informational events that don't map to OpenAI lifecycle events
|
||||
# Convert them to trace events for debugging visibility
|
||||
event_data: dict[str, Any] = {}
|
||||
@@ -795,13 +1066,10 @@ class MessageMapper:
|
||||
elif event_class == "WorkflowErrorEvent":
|
||||
event_data["message"] = str(getattr(event, "message", ""))
|
||||
event_data["error"] = str(getattr(event, "error", ""))
|
||||
elif event_class == "RequestInfoEvent":
|
||||
request_info = getattr(event, "data", {})
|
||||
event_data["request_info"] = request_info if isinstance(request_info, dict) else str(request_info)
|
||||
|
||||
# Create a trace event for debugging
|
||||
trace_event = ResponseTraceEventComplete(
|
||||
type="response.trace.complete",
|
||||
type="response.trace.completed",
|
||||
data={
|
||||
"trace_type": "workflow_info",
|
||||
"event_type": event_class,
|
||||
@@ -816,6 +1084,237 @@ class MessageMapper:
|
||||
|
||||
return [trace_event]
|
||||
|
||||
# Handle Magentic-specific events
|
||||
if event_class == "MagenticAgentDeltaEvent":
|
||||
agent_id = getattr(event, "agent_id", "unknown_agent")
|
||||
text = getattr(event, "text", None)
|
||||
|
||||
if text:
|
||||
events = []
|
||||
|
||||
# Track Magentic agent messages separately from regular messages
|
||||
# Use timestamp to ensure uniqueness for multiple runs of same agent
|
||||
magentic_key = f"magentic_message_{agent_id}"
|
||||
|
||||
# Check if this is the first delta from this agent (need to create message container)
|
||||
if magentic_key not in context:
|
||||
# Create a unique message ID for this agent's streaming session
|
||||
message_id = f"msg_{agent_id}_{uuid4().hex[:8]}"
|
||||
context[magentic_key] = message_id
|
||||
context["output_index"] = context.get("output_index", -1) + 1
|
||||
|
||||
# Import required types
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
from openai.types.responses.response_content_part_added_event import (
|
||||
ResponseContentPartAddedEvent,
|
||||
)
|
||||
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
|
||||
|
||||
# Emit message output item (container for the agent's message)
|
||||
# This matches what _convert_agent_update does for regular agents
|
||||
events.append(
|
||||
ResponseOutputItemAddedEvent(
|
||||
type="response.output_item.added",
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
item=ResponseOutputMessage(
|
||||
type="message",
|
||||
id=message_id,
|
||||
role="assistant",
|
||||
content=[],
|
||||
status="in_progress",
|
||||
# Add metadata to identify this as a Magentic agent message
|
||||
metadata={"agent_id": agent_id, "source": "magentic"}, # type: ignore[call-arg]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Add content part for text (establishes the text container)
|
||||
events.append(
|
||||
ResponseContentPartAddedEvent(
|
||||
type="response.content_part.added",
|
||||
output_index=context["output_index"],
|
||||
content_index=0,
|
||||
item_id=message_id,
|
||||
sequence_number=self._next_sequence(context),
|
||||
part=ResponseOutputText(type="output_text", text="", annotations=[]),
|
||||
)
|
||||
)
|
||||
|
||||
# Get the message ID for this agent
|
||||
message_id = context[magentic_key]
|
||||
|
||||
# Emit text delta event using the message ID (matches regular agent behavior)
|
||||
events.append(
|
||||
ResponseTextDeltaEvent(
|
||||
type="response.output_text.delta",
|
||||
output_index=context["output_index"],
|
||||
content_index=0, # Always 0 for single text content
|
||||
item_id=message_id,
|
||||
delta=text,
|
||||
logprobs=[],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
# Handle function calls from Magentic agents
|
||||
if getattr(event, "function_call_id", None) and getattr(event, "function_call_name", None):
|
||||
# Handle function call initiation
|
||||
function_call_id = getattr(event, "function_call_id", None)
|
||||
function_call_name = getattr(event, "function_call_name", None)
|
||||
function_call_arguments = getattr(event, "function_call_arguments", None)
|
||||
|
||||
# Track function call for accumulating arguments
|
||||
context["active_function_calls"][function_call_id] = {
|
||||
"item_id": function_call_id,
|
||||
"name": function_call_name,
|
||||
"arguments_chunks": [],
|
||||
}
|
||||
|
||||
# Emit function call output item
|
||||
return [
|
||||
ResponseOutputItemAddedEvent(
|
||||
type="response.output_item.added",
|
||||
item=ResponseFunctionToolCall(
|
||||
id=function_call_id,
|
||||
call_id=function_call_id,
|
||||
name=function_call_name,
|
||||
arguments=json.dumps(function_call_arguments) if function_call_arguments else "",
|
||||
type="function_call",
|
||||
status="in_progress",
|
||||
),
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
]
|
||||
|
||||
# For other non-text deltas, emit as trace for debugging
|
||||
return [
|
||||
ResponseTraceEventComplete(
|
||||
type="response.trace.completed",
|
||||
data={
|
||||
"trace_type": "magentic_delta",
|
||||
"agent_id": agent_id,
|
||||
"function_call_id": getattr(event, "function_call_id", None),
|
||||
"function_call_name": getattr(event, "function_call_name", None),
|
||||
"function_result_id": getattr(event, "function_result_id", None),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
span_id=f"magentic_delta_{uuid4().hex[:8]}",
|
||||
item_id=context["item_id"],
|
||||
output_index=context.get("output_index", 0),
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
]
|
||||
|
||||
if event_class == "MagenticAgentMessageEvent":
|
||||
agent_id = getattr(event, "agent_id", "unknown_agent")
|
||||
message = getattr(event, "message", None)
|
||||
|
||||
# Track Magentic agent messages
|
||||
magentic_key = f"magentic_message_{agent_id}"
|
||||
|
||||
# Check if we were streaming for this agent
|
||||
if magentic_key in context:
|
||||
# Mark the streaming message as complete
|
||||
message_id = context[magentic_key]
|
||||
|
||||
# Import required types
|
||||
from openai.types.responses import ResponseOutputMessage
|
||||
from openai.types.responses.response_output_item_done_event import ResponseOutputItemDoneEvent
|
||||
|
||||
# Extract text from ChatMessage for the completed message
|
||||
text = None
|
||||
if message and hasattr(message, "text"):
|
||||
text = message.text
|
||||
|
||||
# Emit output_item.done to mark message as complete
|
||||
events = [
|
||||
ResponseOutputItemDoneEvent(
|
||||
type="response.output_item.done",
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
item=ResponseOutputMessage(
|
||||
type="message",
|
||||
id=message_id,
|
||||
role="assistant",
|
||||
content=[], # Content already streamed via deltas
|
||||
status="completed",
|
||||
metadata={"agent_id": agent_id, "source": "magentic"}, # type: ignore[call-arg]
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
# Clean up context for this agent
|
||||
del context[magentic_key]
|
||||
|
||||
logger.debug(f"MagenticAgentMessageEvent from {agent_id} marked streaming message as complete")
|
||||
return events
|
||||
# No streaming occurred, create a complete message (shouldn't happen normally)
|
||||
# Extract text from ChatMessage
|
||||
text = None
|
||||
if message and hasattr(message, "text"):
|
||||
text = message.text
|
||||
|
||||
if text:
|
||||
# Emit as output item for this agent
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
|
||||
|
||||
context["output_index"] = context.get("output_index", -1) + 1
|
||||
|
||||
text_content = ResponseOutputText(type="output_text", text=text, annotations=[])
|
||||
|
||||
output_message = ResponseOutputMessage(
|
||||
type="message",
|
||||
id=f"msg_{agent_id}_{uuid4().hex[:8]}",
|
||||
role="assistant",
|
||||
content=[text_content],
|
||||
status="completed",
|
||||
metadata={"agent_id": agent_id, "source": "magentic"}, # type: ignore[call-arg]
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"MagenticAgentMessageEvent from {agent_id} converted to output_item.added (non-streaming)"
|
||||
)
|
||||
return [
|
||||
ResponseOutputItemAddedEvent(
|
||||
type="response.output_item.added",
|
||||
item=output_message,
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
]
|
||||
|
||||
if event_class == "MagenticOrchestratorMessageEvent":
|
||||
orchestrator_id = getattr(event, "orchestrator_id", "orchestrator")
|
||||
message = getattr(event, "message", None)
|
||||
kind = getattr(event, "kind", "unknown")
|
||||
|
||||
# Extract text from ChatMessage
|
||||
text = None
|
||||
if message and hasattr(message, "text"):
|
||||
text = message.text
|
||||
|
||||
# Emit as trace event for orchestrator messages (typically task ledger, instructions)
|
||||
return [
|
||||
ResponseTraceEventComplete(
|
||||
type="response.trace.completed",
|
||||
data={
|
||||
"trace_type": "magentic_orchestrator",
|
||||
"orchestrator_id": orchestrator_id,
|
||||
"kind": kind,
|
||||
"text": text or str(message),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
span_id=f"magentic_orch_{uuid4().hex[:8]}",
|
||||
item_id=context["item_id"],
|
||||
output_index=context.get("output_index", 0),
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
]
|
||||
|
||||
# For unknown/legacy events, still emit as workflow event for backward compatibility
|
||||
# Get event data and serialize if it's a SerializationMixin
|
||||
raw_event_data = getattr(event, "data", None)
|
||||
@@ -830,7 +1329,7 @@ class MessageMapper:
|
||||
|
||||
# Create structured workflow event (keeping for backward compatibility)
|
||||
workflow_event = ResponseWorkflowEventComplete(
|
||||
type="response.workflow_event.complete",
|
||||
type="response.workflow_event.completed",
|
||||
data={
|
||||
"event_type": event.__class__.__name__,
|
||||
"data": serialized_event_data,
|
||||
@@ -1056,30 +1555,227 @@ class MessageMapper:
|
||||
# NO EVENT RETURNED - usage goes in final Response only
|
||||
return
|
||||
|
||||
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"],
|
||||
async def _map_data_content(
|
||||
self, content: Any, context: dict[str, Any]
|
||||
) -> ResponseOutputItemAddedEvent | ResponseTraceEventComplete:
|
||||
"""Map DataContent to proper output item (image/file/data) or fallback to trace.
|
||||
|
||||
Maps Agent Framework DataContent to appropriate output types:
|
||||
- Images (image/*) → ResponseOutputImage
|
||||
- Common files (pdf, audio, video) → ResponseOutputFile
|
||||
- Generic data → ResponseOutputData
|
||||
- Unknown/debugging content → ResponseTraceEventComplete (fallback)
|
||||
"""
|
||||
mime_type = getattr(content, "mime_type", "application/octet-stream")
|
||||
item_id = f"item_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
# Extract data/uri
|
||||
data_value = getattr(content, "data", None)
|
||||
uri_value = getattr(content, "uri", None)
|
||||
|
||||
# Handle images
|
||||
if mime_type.startswith("image/"):
|
||||
# Prefer URI, but create data URI from data if needed
|
||||
if uri_value:
|
||||
image_url = uri_value
|
||||
elif data_value:
|
||||
# Convert bytes to base64 data URI
|
||||
import base64
|
||||
|
||||
if isinstance(data_value, bytes):
|
||||
b64_data = base64.b64encode(data_value).decode("utf-8")
|
||||
else:
|
||||
b64_data = str(data_value)
|
||||
image_url = f"data:{mime_type};base64,{b64_data}"
|
||||
else:
|
||||
# No data available, fallback to trace
|
||||
logger.warning(f"DataContent with {mime_type} has no data or uri, falling back to trace")
|
||||
return ResponseTraceEventComplete(
|
||||
type="response.trace.completed",
|
||||
data={"content_type": "data", "mime_type": mime_type, "error": "No data or uri"},
|
||||
item_id=context["item_id"],
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
|
||||
return ResponseOutputItemAddedEvent(
|
||||
type="response.output_item.added",
|
||||
item=ResponseOutputImage( # type: ignore[arg-type]
|
||||
id=item_id,
|
||||
type="output_image",
|
||||
image_url=image_url,
|
||||
mime_type=mime_type,
|
||||
alt_text=None,
|
||||
),
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
|
||||
# Handle common file types
|
||||
if mime_type in [
|
||||
"application/pdf",
|
||||
"audio/mp3",
|
||||
"audio/wav",
|
||||
"audio/m4a",
|
||||
"audio/ogg",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/mpeg",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
]:
|
||||
# Determine filename from mime type
|
||||
ext = mime_type.split("/")[-1]
|
||||
if ext == "mpeg":
|
||||
ext = "mp3" # audio/mpeg → .mp3
|
||||
filename = f"output.{ext}"
|
||||
|
||||
# Prefer URI
|
||||
if uri_value:
|
||||
file_url = uri_value
|
||||
file_data = None
|
||||
elif data_value:
|
||||
# Convert bytes to base64
|
||||
import base64
|
||||
|
||||
if isinstance(data_value, bytes):
|
||||
b64_data = base64.b64encode(data_value).decode("utf-8")
|
||||
else:
|
||||
b64_data = str(data_value)
|
||||
file_url = f"data:{mime_type};base64,{b64_data}"
|
||||
file_data = b64_data
|
||||
else:
|
||||
# No data available, fallback to trace
|
||||
logger.warning(f"DataContent with {mime_type} has no data or uri, falling back to trace")
|
||||
return ResponseTraceEventComplete(
|
||||
type="response.trace.completed",
|
||||
data={"content_type": "data", "mime_type": mime_type, "error": "No data or uri"},
|
||||
item_id=context["item_id"],
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
|
||||
return ResponseOutputItemAddedEvent(
|
||||
type="response.output_item.added",
|
||||
item=ResponseOutputFile( # type: ignore[arg-type]
|
||||
id=item_id,
|
||||
type="output_file",
|
||||
filename=filename,
|
||||
file_url=file_url,
|
||||
file_data=file_data,
|
||||
mime_type=mime_type,
|
||||
),
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
|
||||
# Handle generic data (structured data, JSON, etc.)
|
||||
data_str = ""
|
||||
if uri_value:
|
||||
data_str = uri_value
|
||||
elif data_value:
|
||||
if isinstance(data_value, bytes):
|
||||
try:
|
||||
data_str = data_value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# Binary data, encode as base64 for display
|
||||
import base64
|
||||
|
||||
data_str = base64.b64encode(data_value).decode("utf-8")
|
||||
else:
|
||||
data_str = str(data_value)
|
||||
|
||||
return ResponseOutputItemAddedEvent(
|
||||
type="response.output_item.added",
|
||||
item=ResponseOutputData( # type: ignore[arg-type]
|
||||
id=item_id,
|
||||
type="output_data",
|
||||
data=data_str,
|
||||
mime_type=mime_type,
|
||||
description=None,
|
||||
),
|
||||
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."""
|
||||
async def _map_uri_content(
|
||||
self, content: Any, context: dict[str, Any]
|
||||
) -> ResponseOutputItemAddedEvent | ResponseTraceEventComplete:
|
||||
"""Map UriContent to proper output item (image/file) based on MIME type.
|
||||
|
||||
UriContent has a URI and MIME type, so we can create appropriate output items:
|
||||
- Images → ResponseOutputImage
|
||||
- Common files → ResponseOutputFile
|
||||
- Other URIs → ResponseTraceEventComplete (fallback for debugging)
|
||||
"""
|
||||
mime_type = getattr(content, "mime_type", "text/plain")
|
||||
uri = getattr(content, "uri", "")
|
||||
item_id = f"item_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
if not uri:
|
||||
# No URI available, fallback to trace
|
||||
logger.warning("UriContent has no uri, falling back to trace")
|
||||
return ResponseTraceEventComplete(
|
||||
type="response.trace.completed",
|
||||
data={"content_type": "uri", "mime_type": mime_type, "error": "No uri"},
|
||||
item_id=context["item_id"],
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
|
||||
# Handle images
|
||||
if mime_type.startswith("image/"):
|
||||
return ResponseOutputItemAddedEvent(
|
||||
type="response.output_item.added",
|
||||
item=ResponseOutputImage( # type: ignore[arg-type]
|
||||
id=item_id,
|
||||
type="output_image",
|
||||
image_url=uri,
|
||||
mime_type=mime_type,
|
||||
alt_text=None,
|
||||
),
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
|
||||
# Handle common file types
|
||||
if mime_type in [
|
||||
"application/pdf",
|
||||
"audio/mp3",
|
||||
"audio/wav",
|
||||
"audio/m4a",
|
||||
"audio/ogg",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/mpeg",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
]:
|
||||
# Extract filename from URI or use generic name
|
||||
filename = uri.split("/")[-1] if "/" in uri else f"output.{mime_type.split('/')[-1]}"
|
||||
|
||||
return ResponseOutputItemAddedEvent(
|
||||
type="response.output_item.added",
|
||||
item=ResponseOutputFile( # type: ignore[arg-type]
|
||||
id=item_id,
|
||||
type="output_file",
|
||||
filename=filename,
|
||||
file_url=uri,
|
||||
file_data=None,
|
||||
mime_type=mime_type,
|
||||
),
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
|
||||
# For other URI types (text/plain, application/json, etc.), use trace for now
|
||||
logger.debug(f"UriContent with unsupported MIME type {mime_type}, using trace event")
|
||||
return ResponseTraceEventComplete(
|
||||
type="response.trace.complete",
|
||||
type="response.trace.completed",
|
||||
data={
|
||||
"content_type": "uri",
|
||||
"uri": getattr(content, "uri", ""),
|
||||
"mime_type": getattr(content, "mime_type", "text/plain"),
|
||||
"uri": uri,
|
||||
"mime_type": mime_type,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
item_id=context["item_id"],
|
||||
@@ -1088,9 +1784,15 @@ class MessageMapper:
|
||||
)
|
||||
|
||||
async def _map_hosted_file_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete:
|
||||
"""Map HostedFileContent to structured trace event."""
|
||||
"""Map HostedFileContent to trace event.
|
||||
|
||||
HostedFileContent references external file IDs (like OpenAI file IDs).
|
||||
These remain as traces since they're metadata about hosted resources,
|
||||
not direct content to display. To display them, agents should return
|
||||
DataContent or UriContent with the actual file data/URL.
|
||||
"""
|
||||
return ResponseTraceEventComplete(
|
||||
type="response.trace.complete",
|
||||
type="response.trace.completed",
|
||||
data={
|
||||
"content_type": "hosted_file",
|
||||
"file_id": getattr(content, "file_id", "unknown"),
|
||||
@@ -1104,9 +1806,14 @@ class MessageMapper:
|
||||
async def _map_hosted_vector_store_content(
|
||||
self, content: Any, context: dict[str, Any]
|
||||
) -> ResponseTraceEventComplete:
|
||||
"""Map HostedVectorStoreContent to structured trace event."""
|
||||
"""Map HostedVectorStoreContent to trace event.
|
||||
|
||||
HostedVectorStoreContent references external vector store IDs.
|
||||
These remain as traces since they're metadata about hosted resources,
|
||||
not direct content to display.
|
||||
"""
|
||||
return ResponseTraceEventComplete(
|
||||
type="response.trace.complete",
|
||||
type="response.trace.completed",
|
||||
data={
|
||||
"content_type": "hosted_vector_store",
|
||||
"vector_store_id": getattr(content, "vector_store_id", "unknown"),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""OpenAI integration for DevUI - proxy support for OpenAI Responses API."""
|
||||
|
||||
from ._executor import OpenAIExecutor
|
||||
|
||||
__all__ = [
|
||||
"OpenAIExecutor",
|
||||
]
|
||||
@@ -0,0 +1,270 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""OpenAI Executor - proxies requests to OpenAI Responses API.
|
||||
|
||||
This executor mirrors the AgentFrameworkExecutor interface but routes
|
||||
requests to OpenAI's API instead of executing local entities.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from openai import APIStatusError, AsyncOpenAI, AsyncStream, AuthenticationError, PermissionDeniedError, RateLimitError
|
||||
from openai.types.responses import Response, ResponseStreamEvent
|
||||
|
||||
from .._conversations import ConversationStore
|
||||
from ..models import AgentFrameworkRequest, OpenAIResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OpenAIExecutor:
|
||||
"""Executor for OpenAI Responses API - mirrors AgentFrameworkExecutor interface.
|
||||
|
||||
This executor provides the same interface as AgentFrameworkExecutor but proxies
|
||||
requests to OpenAI's Responses API instead of executing local entities.
|
||||
|
||||
Key features:
|
||||
- Same execute_streaming() and execute_sync() interface
|
||||
- Shares ConversationStore with local executor
|
||||
- Configured via OPENAI_API_KEY environment variable
|
||||
- Supports all OpenAI Responses API parameters
|
||||
"""
|
||||
|
||||
def __init__(self, conversation_store: ConversationStore):
|
||||
"""Initialize OpenAI executor.
|
||||
|
||||
Args:
|
||||
conversation_store: Shared conversation store (works for both local and OpenAI)
|
||||
"""
|
||||
self.conversation_store = conversation_store
|
||||
|
||||
# Load configuration from environment
|
||||
self.api_key = os.getenv("OPENAI_API_KEY")
|
||||
self.base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||
self._client: AsyncOpenAI | None = None
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if OpenAI executor is properly configured.
|
||||
|
||||
Returns:
|
||||
True if OPENAI_API_KEY is set
|
||||
"""
|
||||
return self.api_key is not None
|
||||
|
||||
def _get_client(self) -> AsyncOpenAI:
|
||||
"""Get or create OpenAI async client.
|
||||
|
||||
Returns:
|
||||
AsyncOpenAI client instance
|
||||
|
||||
Raises:
|
||||
ValueError: If OPENAI_API_KEY not configured
|
||||
"""
|
||||
if self._client is None:
|
||||
if not self.api_key:
|
||||
raise ValueError("OPENAI_API_KEY environment variable not set")
|
||||
|
||||
self._client = AsyncOpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
logger.debug(f"Created OpenAI client with base_url: {self.base_url}")
|
||||
|
||||
return self._client
|
||||
|
||||
async def execute_streaming(self, request: AgentFrameworkRequest) -> AsyncGenerator[Any, None]:
|
||||
"""Execute request via OpenAI and stream results in OpenAI format.
|
||||
|
||||
This mirrors AgentFrameworkExecutor.execute_streaming() interface.
|
||||
|
||||
Args:
|
||||
request: Request to execute
|
||||
|
||||
Yields:
|
||||
OpenAI ResponseStreamEvent objects (already in correct format!)
|
||||
"""
|
||||
if not self.is_configured:
|
||||
logger.error("OpenAI executor not configured (missing OPENAI_API_KEY)")
|
||||
# Emit proper response.failed event
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": "OpenAI not configured on server. Set OPENAI_API_KEY environment variable.",
|
||||
"type": "configuration_error",
|
||||
"code": "openai_not_configured",
|
||||
},
|
||||
},
|
||||
}
|
||||
return
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
# Convert AgentFrameworkRequest to OpenAI params
|
||||
params = request.to_openai_params()
|
||||
|
||||
# Remove DevUI-specific fields that OpenAI doesn't recognize
|
||||
params.pop("extra_body", None)
|
||||
|
||||
# Conversation ID is now from OpenAI (created via /v1/conversations proxy)
|
||||
# so we can pass it through!
|
||||
|
||||
# Force streaming mode (remove if already present to avoid duplicate)
|
||||
params.pop("stream", None)
|
||||
|
||||
logger.info(f"🔀 Proxying to OpenAI Responses API: model={params.get('model')}")
|
||||
logger.debug(f"Request params: {params}")
|
||||
|
||||
# Call OpenAI Responses API - returns AsyncStream[ResponseStreamEvent]
|
||||
stream: AsyncStream[ResponseStreamEvent] = await client.responses.create(
|
||||
**params,
|
||||
stream=True, # Force streaming
|
||||
)
|
||||
|
||||
# Yield events directly - they're already ResponseStreamEvent objects!
|
||||
# No conversion needed - OpenAI SDK returns proper typed objects
|
||||
async for event in stream:
|
||||
yield event
|
||||
|
||||
except AuthenticationError as e:
|
||||
# 401 - Invalid API key or authentication issue
|
||||
logger.error(f"OpenAI authentication error: {e}", exc_info=True)
|
||||
error_body = e.body if hasattr(e, "body") else {}
|
||||
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": error_data.get("message", str(e)),
|
||||
"type": error_data.get("type", "authentication_error"),
|
||||
"code": error_data.get("code", "invalid_api_key"),
|
||||
},
|
||||
},
|
||||
}
|
||||
except PermissionDeniedError as e:
|
||||
# 403 - Permission denied
|
||||
logger.error(f"OpenAI permission denied: {e}", exc_info=True)
|
||||
error_body = e.body if hasattr(e, "body") else {}
|
||||
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": error_data.get("message", str(e)),
|
||||
"type": error_data.get("type", "permission_denied"),
|
||||
"code": error_data.get("code", "insufficient_permissions"),
|
||||
},
|
||||
},
|
||||
}
|
||||
except RateLimitError as e:
|
||||
# 429 - Rate limit exceeded
|
||||
logger.error(f"OpenAI rate limit exceeded: {e}", exc_info=True)
|
||||
error_body = e.body if hasattr(e, "body") else {}
|
||||
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": error_data.get("message", str(e)),
|
||||
"type": error_data.get("type", "rate_limit_error"),
|
||||
"code": error_data.get("code", "rate_limit_exceeded"),
|
||||
},
|
||||
},
|
||||
}
|
||||
except APIStatusError as e:
|
||||
# Other OpenAI API errors
|
||||
logger.error(f"OpenAI API error: {e}", exc_info=True)
|
||||
error_body = e.body if hasattr(e, "body") else {}
|
||||
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": error_data.get("message", str(e)),
|
||||
"type": error_data.get("type", "api_error"),
|
||||
"code": error_data.get("code", "unknown_error"),
|
||||
},
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
# Catch-all for unexpected errors
|
||||
logger.error(f"Unexpected error in OpenAI proxy: {e}", exc_info=True)
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": f"Unexpected error: {e!s}",
|
||||
"type": "internal_error",
|
||||
"code": "unexpected_error",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async def execute_sync(self, request: AgentFrameworkRequest) -> OpenAIResponse:
|
||||
"""Execute request via OpenAI and return complete response.
|
||||
|
||||
This mirrors AgentFrameworkExecutor.execute_sync() interface.
|
||||
|
||||
Args:
|
||||
request: Request to execute
|
||||
|
||||
Returns:
|
||||
Final OpenAI Response object
|
||||
|
||||
Raises:
|
||||
ValueError: If OpenAI not configured
|
||||
Exception: If OpenAI API call fails
|
||||
"""
|
||||
if not self.is_configured:
|
||||
raise ValueError("OpenAI not configured on server. Set OPENAI_API_KEY environment variable.")
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
# Convert AgentFrameworkRequest to OpenAI params
|
||||
params = request.to_openai_params()
|
||||
|
||||
# Remove DevUI-specific fields
|
||||
params.pop("extra_body", None)
|
||||
|
||||
# Force non-streaming mode (remove if already present to avoid duplicate)
|
||||
params.pop("stream", None)
|
||||
|
||||
logger.info(f"🔀 Proxying to OpenAI Responses API (non-streaming): model={params.get('model')}")
|
||||
logger.debug(f"Request params: {params}")
|
||||
|
||||
# Call OpenAI Responses API - returns Response object
|
||||
response: Response = await client.responses.create(
|
||||
**params,
|
||||
stream=False, # Force non-streaming
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OpenAI proxy error: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the OpenAI client and release resources."""
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
self._client = None
|
||||
logger.debug("Closed OpenAI client")
|
||||
@@ -5,7 +5,9 @@
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
@@ -14,15 +16,20 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from ._deployment import DeploymentManager
|
||||
from ._discovery import EntityDiscovery
|
||||
from ._executor import AgentFrameworkExecutor
|
||||
from ._mapper import MessageMapper
|
||||
from .models import AgentFrameworkRequest, OpenAIError
|
||||
from .models._discovery_models import DiscoveryResponse, EntityInfo
|
||||
from ._openai import OpenAIExecutor
|
||||
from .models import AgentFrameworkRequest, MetaResponse, OpenAIError
|
||||
from .models._discovery_models import Deployment, DeploymentConfig, DiscoveryResponse, EntityInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# No AuthMiddleware class needed - we'll use the decorator pattern instead
|
||||
|
||||
|
||||
class DevServer:
|
||||
"""Development Server - OpenAI compatible API server for debugging agents."""
|
||||
|
||||
@@ -33,6 +40,7 @@ class DevServer:
|
||||
host: str = "127.0.0.1",
|
||||
cors_origins: list[str] | None = None,
|
||||
ui_enabled: bool = True,
|
||||
mode: str = "developer",
|
||||
) -> None:
|
||||
"""Initialize the development server.
|
||||
|
||||
@@ -42,16 +50,79 @@ class DevServer:
|
||||
host: Host to bind server to
|
||||
cors_origins: List of allowed CORS origins
|
||||
ui_enabled: Whether to enable the UI
|
||||
mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)
|
||||
"""
|
||||
self.entities_dir = entities_dir
|
||||
self.port = port
|
||||
self.host = host
|
||||
self.cors_origins = cors_origins or ["*"]
|
||||
|
||||
# Smart CORS defaults: permissive for localhost, restrictive for network-exposed deployments
|
||||
if cors_origins is None:
|
||||
# Localhost development: allow cross-origin for dev tools (e.g., frontend dev server)
|
||||
# Network-exposed: empty list (same-origin only, no CORS)
|
||||
cors_origins = ["*"] if host in ("127.0.0.1", "localhost") else []
|
||||
|
||||
self.cors_origins = cors_origins
|
||||
self.ui_enabled = ui_enabled
|
||||
self.mode = mode
|
||||
self.executor: AgentFrameworkExecutor | None = None
|
||||
self.openai_executor: OpenAIExecutor | None = None
|
||||
self.deployment_manager = DeploymentManager()
|
||||
self._app: FastAPI | None = None
|
||||
self._pending_entities: list[Any] | None = None
|
||||
|
||||
def _is_dev_mode(self) -> bool:
|
||||
"""Check if running in developer mode.
|
||||
|
||||
Returns:
|
||||
True if in developer mode, False if in user mode
|
||||
"""
|
||||
return self.mode == "developer"
|
||||
|
||||
def _format_error(self, error: Exception, context: str = "Operation") -> str:
|
||||
"""Format error message based on server mode.
|
||||
|
||||
In developer mode: Returns detailed error message for debugging.
|
||||
In user mode: Returns generic message and logs details internally.
|
||||
|
||||
Args:
|
||||
error: The exception that occurred
|
||||
context: Description of the operation that failed (e.g., "Request execution")
|
||||
|
||||
Returns:
|
||||
Formatted error message appropriate for the current mode
|
||||
"""
|
||||
if self._is_dev_mode():
|
||||
# Developer mode: Show full error details for debugging
|
||||
return f"{context} failed: {error!s}"
|
||||
|
||||
# User mode: Generic message to user, detailed logging internally
|
||||
logger.error(f"{context} failed: {error}", exc_info=True)
|
||||
return f"{context} failed"
|
||||
|
||||
def _require_developer_mode(self, feature: str = "operation") -> None:
|
||||
"""Check if current mode allows developer operations.
|
||||
|
||||
Args:
|
||||
feature: Name of the feature being accessed (for error message)
|
||||
|
||||
Raises:
|
||||
HTTPException: If in user mode
|
||||
"""
|
||||
if self.mode == "user":
|
||||
logger.warning(f"Blocked {feature} access in user mode")
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Access denied: {feature} requires developer mode",
|
||||
"type": "permission_denied",
|
||||
"code": "developer_mode_required",
|
||||
"current_mode": self.mode,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def _ensure_executor(self) -> AgentFrameworkExecutor:
|
||||
"""Ensure executor is initialized."""
|
||||
if self.executor is None:
|
||||
@@ -84,6 +155,29 @@ class DevServer:
|
||||
|
||||
return self.executor
|
||||
|
||||
async def _ensure_openai_executor(self) -> OpenAIExecutor:
|
||||
"""Ensure OpenAI executor is initialized.
|
||||
|
||||
Returns:
|
||||
OpenAI executor instance
|
||||
|
||||
Raises:
|
||||
ValueError: If OpenAI executor cannot be initialized
|
||||
"""
|
||||
if self.openai_executor is None:
|
||||
# Initialize local executor first to get conversation_store
|
||||
local_executor = await self._ensure_executor()
|
||||
|
||||
# Create OpenAI executor with shared conversation store
|
||||
self.openai_executor = OpenAIExecutor(local_executor.conversation_store)
|
||||
|
||||
if self.openai_executor.is_configured:
|
||||
logger.info("OpenAI proxy mode available (OPENAI_API_KEY configured)")
|
||||
else:
|
||||
logger.info("OpenAI proxy mode disabled (OPENAI_API_KEY not set)")
|
||||
|
||||
return self.openai_executor
|
||||
|
||||
async def _cleanup_entities(self) -> None:
|
||||
"""Cleanup entity resources (close clients, MCP tools, credentials, etc.)."""
|
||||
if not self.executor:
|
||||
@@ -94,12 +188,28 @@ class DevServer:
|
||||
closed_count = 0
|
||||
mcp_tools_closed = 0
|
||||
credentials_closed = 0
|
||||
hook_count = 0
|
||||
|
||||
for entity_info in entities:
|
||||
entity_id = entity_info.id
|
||||
|
||||
try:
|
||||
entity_obj = self.executor.entity_discovery.get_entity_object(entity_info.id)
|
||||
# Step 1: Execute registered cleanup hooks (NEW)
|
||||
cleanup_hooks = self.executor.entity_discovery.get_cleanup_hooks(entity_id)
|
||||
for hook in cleanup_hooks:
|
||||
try:
|
||||
if inspect.iscoroutinefunction(hook):
|
||||
await hook()
|
||||
else:
|
||||
hook()
|
||||
hook_count += 1
|
||||
logger.debug(f"✓ Executed cleanup hook for: {entity_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠ Cleanup hook failed for {entity_id}: {e}")
|
||||
|
||||
# Step 2: Close chat clients and their credentials (EXISTING)
|
||||
entity_obj = self.executor.entity_discovery.get_entity_object(entity_id)
|
||||
|
||||
# Close chat clients and their credentials
|
||||
if entity_obj and hasattr(entity_obj, "chat_client"):
|
||||
client = entity_obj.chat_client
|
||||
|
||||
@@ -144,14 +254,24 @@ class DevServer:
|
||||
logger.warning(f"Error closing MCP tool for {entity_info.id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing entity {entity_info.id}: {e}")
|
||||
logger.warning(f"Error cleaning up entity {entity_id}: {e}")
|
||||
|
||||
if hook_count > 0:
|
||||
logger.info(f"✓ Executed {hook_count} cleanup hook(s)")
|
||||
if closed_count > 0:
|
||||
logger.info(f"Closed {closed_count} entity client(s)")
|
||||
logger.info(f"✓ Closed {closed_count} entity client(s)")
|
||||
if credentials_closed > 0:
|
||||
logger.info(f"Closed {credentials_closed} credential(s)")
|
||||
logger.info(f"✓ Closed {credentials_closed} credential(s)")
|
||||
if mcp_tools_closed > 0:
|
||||
logger.info(f"Closed {mcp_tools_closed} MCP tool(s)")
|
||||
logger.info(f"✓ Closed {mcp_tools_closed} MCP tool(s)")
|
||||
|
||||
# Close OpenAI executor if it exists
|
||||
if self.openai_executor:
|
||||
try:
|
||||
await self.openai_executor.close()
|
||||
logger.info("Closed OpenAI executor")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing OpenAI executor: {e}")
|
||||
|
||||
def create_app(self) -> FastAPI:
|
||||
"""Create the FastAPI application."""
|
||||
@@ -161,6 +281,7 @@ class DevServer:
|
||||
# Startup
|
||||
logger.info("Starting Agent Framework Server")
|
||||
await self._ensure_executor()
|
||||
await self._ensure_openai_executor() # Initialize OpenAI executor
|
||||
yield
|
||||
# Shutdown
|
||||
logger.info("Shutting down Agent Framework Server")
|
||||
@@ -177,14 +298,74 @@ class DevServer:
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
# Note: allow_credentials cannot be True when allow_origins is ["*"]
|
||||
# For localhost dev with wildcard origins, credentials are disabled
|
||||
# For network deployments with specific origins or empty list, credentials can be enabled
|
||||
allow_credentials = self.cors_origins != ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=self.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_credentials=allow_credentials,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Add authentication middleware using decorator pattern
|
||||
# Auth is enabled by presence of DEVUI_AUTH_TOKEN
|
||||
auth_token = os.getenv("DEVUI_AUTH_TOKEN", "")
|
||||
auth_required = bool(auth_token)
|
||||
|
||||
if auth_required:
|
||||
logger.info("Authentication middleware enabled")
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next: Callable[[Request], Awaitable[Any]]) -> Any:
|
||||
"""Validate Bearer token authentication.
|
||||
|
||||
Skips authentication for health, meta, static UI endpoints, and OPTIONS requests.
|
||||
"""
|
||||
# Skip auth for OPTIONS (CORS preflight) requests
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
# Skip auth for health checks, meta endpoint, and static files
|
||||
if request.url.path in ["/health", "/meta", "/"] or request.url.path.startswith("/assets"):
|
||||
return await call_next(request)
|
||||
|
||||
# Check Authorization header
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": {
|
||||
"message": (
|
||||
"Missing or invalid Authorization header. Expected: Authorization: Bearer <token>"
|
||||
),
|
||||
"type": "authentication_error",
|
||||
"code": "missing_token",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Extract and validate token
|
||||
token = auth_header.replace("Bearer ", "", 1).strip()
|
||||
if not secrets.compare_digest(token, auth_token):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": {
|
||||
"message": "Invalid authentication token",
|
||||
"type": "authentication_error",
|
||||
"code": "invalid_token",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Token valid, proceed
|
||||
return await call_next(request)
|
||||
|
||||
self._register_routes(app)
|
||||
self._mount_ui(app)
|
||||
|
||||
@@ -202,6 +383,28 @@ class DevServer:
|
||||
|
||||
return {"status": "healthy", "entities_count": len(entities), "framework": "agent_framework"}
|
||||
|
||||
@app.get("/meta", response_model=MetaResponse)
|
||||
async def get_meta() -> MetaResponse:
|
||||
"""Get server metadata and configuration."""
|
||||
import os
|
||||
|
||||
from . import __version__
|
||||
|
||||
# Ensure executors are initialized to check capabilities
|
||||
openai_executor = await self._ensure_openai_executor()
|
||||
|
||||
return MetaResponse(
|
||||
ui_mode=self.mode, # type: ignore[arg-type]
|
||||
version=__version__,
|
||||
framework="agent_framework",
|
||||
capabilities={
|
||||
"tracing": os.getenv("ENABLE_OTEL") == "true",
|
||||
"openai_proxy": openai_executor.is_configured,
|
||||
"deployment": True, # Deployment feature is available
|
||||
},
|
||||
auth_required=bool(os.getenv("DEVUI_AUTH_TOKEN")),
|
||||
)
|
||||
|
||||
@app.get("/v1/entities", response_model=DiscoveryResponse)
|
||||
async def discover_entities() -> DiscoveryResponse:
|
||||
"""List all registered entities."""
|
||||
@@ -226,7 +429,10 @@ class DevServer:
|
||||
|
||||
# Trigger lazy loading if entity not yet loaded
|
||||
# This will import the module and enrich metadata
|
||||
entity_obj = await executor.entity_discovery.load_entity(entity_id)
|
||||
# Pass checkpoint_manager to ensure workflows get checkpoint storage injected
|
||||
entity_obj = await executor.entity_discovery.load_entity(
|
||||
entity_id, checkpoint_manager=executor.checkpoint_manager
|
||||
)
|
||||
|
||||
# Get updated entity info (may have been enriched during load)
|
||||
entity_info = executor.get_entity_info(entity_id) or entity_info
|
||||
@@ -305,6 +511,7 @@ class DevServer:
|
||||
executor_list = [getattr(ex, "executor_id", str(ex)) for ex in entity_obj.executors]
|
||||
|
||||
# Create copy of entity info and populate workflow-specific fields
|
||||
# Note: DevUI provides runtime checkpoint storage for ALL workflows via conversations
|
||||
update_payload: dict[str, Any] = {
|
||||
"workflow_dump": workflow_dump,
|
||||
"input_schema": input_schema,
|
||||
@@ -320,9 +527,13 @@ class DevServer:
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
# ValueError from load_entity indicates entity not found or invalid
|
||||
error_msg = self._format_error(e, "Entity loading")
|
||||
raise HTTPException(status_code=404, detail=error_msg) from e
|
||||
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
|
||||
error_msg = self._format_error(e, "Entity info retrieval")
|
||||
raise HTTPException(status_code=500, detail=error_msg) from e
|
||||
|
||||
@app.post("/v1/entities/{entity_id}/reload")
|
||||
async def reload_entity(entity_id: str) -> dict[str, Any]:
|
||||
@@ -331,6 +542,7 @@ class DevServer:
|
||||
This enables hot reload during development - edit entity code, call this endpoint,
|
||||
and the next execution will use the updated code without server restart.
|
||||
"""
|
||||
self._require_developer_mode("entity hot reload")
|
||||
try:
|
||||
executor = await self._ensure_executor()
|
||||
|
||||
@@ -353,10 +565,140 @@ class DevServer:
|
||||
logger.error(f"Error reloading entity {entity_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to reload entity: {e!s}") from e
|
||||
|
||||
# ============================================================================
|
||||
# Deployment Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/v1/deployments")
|
||||
async def create_deployment(config: DeploymentConfig) -> StreamingResponse:
|
||||
"""Deploy entity to Azure Container Apps with streaming events.
|
||||
|
||||
Returns SSE stream of deployment progress events.
|
||||
"""
|
||||
self._require_developer_mode("deployment")
|
||||
try:
|
||||
executor = await self._ensure_executor()
|
||||
|
||||
# Validate entity exists and supports deployment
|
||||
entity_info = executor.get_entity_info(config.entity_id)
|
||||
if not entity_info:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {config.entity_id} not found")
|
||||
|
||||
if not entity_info.deployment_supported:
|
||||
reason = entity_info.deployment_reason or "Deployment not supported for this entity"
|
||||
raise HTTPException(status_code=400, detail=reason)
|
||||
|
||||
# Get entity path from metadata
|
||||
from pathlib import Path
|
||||
|
||||
entity_path_str = entity_info.metadata.get("path")
|
||||
if not entity_path_str:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Entity path not found in metadata (in-memory entities cannot be deployed)",
|
||||
)
|
||||
|
||||
entity_path = Path(entity_path_str)
|
||||
|
||||
# Stream deployment events
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
async for event in self.deployment_manager.deploy(config, entity_path):
|
||||
# Format as SSE
|
||||
import json
|
||||
|
||||
yield f"data: {json.dumps(event.model_dump())}\n\n"
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = self._format_error(e, "Deployment creation")
|
||||
raise HTTPException(status_code=500, detail=error_msg) from e
|
||||
|
||||
@app.get("/v1/deployments")
|
||||
async def list_deployments(entity_id: str | None = None) -> list[Deployment]:
|
||||
"""List all deployments, optionally filtered by entity."""
|
||||
self._require_developer_mode("deployment listing")
|
||||
try:
|
||||
return await self.deployment_manager.list_deployments(entity_id)
|
||||
except Exception as e:
|
||||
error_msg = self._format_error(e, "Deployment listing")
|
||||
raise HTTPException(status_code=500, detail=error_msg) from e
|
||||
|
||||
@app.get("/v1/deployments/{deployment_id}")
|
||||
async def get_deployment(deployment_id: str) -> Deployment:
|
||||
"""Get deployment by ID."""
|
||||
self._require_developer_mode("deployment details")
|
||||
try:
|
||||
deployment = await self.deployment_manager.get_deployment(deployment_id)
|
||||
if not deployment:
|
||||
raise HTTPException(status_code=404, detail=f"Deployment {deployment_id} not found")
|
||||
return deployment
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting deployment: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get deployment: {e!s}") from e
|
||||
|
||||
@app.delete("/v1/deployments/{deployment_id}")
|
||||
async def delete_deployment(deployment_id: str) -> dict[str, Any]:
|
||||
"""Delete deployment from Azure Container Apps."""
|
||||
self._require_developer_mode("deployment deletion")
|
||||
try:
|
||||
await self.deployment_manager.delete_deployment(deployment_id)
|
||||
return {"success": True, "message": f"Deployment {deployment_id} deleted successfully"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting deployment: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete deployment: {e!s}") from e
|
||||
|
||||
# Convenience endpoint: deploy specific entity
|
||||
@app.post("/v1/entities/{entity_id}/deploy")
|
||||
async def deploy_entity(entity_id: str, config: DeploymentConfig) -> StreamingResponse:
|
||||
"""Convenience endpoint to deploy entity (shortcuts to /v1/deployments)."""
|
||||
self._require_developer_mode("deployment")
|
||||
# Override entity_id from path parameter
|
||||
config.entity_id = entity_id
|
||||
return await create_deployment(config)
|
||||
|
||||
# ============================================================================
|
||||
# Response/Conversation Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/v1/responses")
|
||||
async def create_response(request: AgentFrameworkRequest, raw_request: Request) -> Any:
|
||||
"""OpenAI Responses API endpoint."""
|
||||
"""OpenAI Responses API endpoint - routes to local or OpenAI executor."""
|
||||
try:
|
||||
# Check if frontend requested OpenAI proxy mode
|
||||
proxy_mode = raw_request.headers.get("X-Proxy-Backend")
|
||||
|
||||
if proxy_mode == "openai":
|
||||
# Route to OpenAI executor
|
||||
logger.info("🔀 Routing to OpenAI proxy mode")
|
||||
openai_executor = await self._ensure_openai_executor()
|
||||
|
||||
if not openai_executor.is_configured:
|
||||
error = OpenAIError.create(
|
||||
"OpenAI proxy mode not configured. Set OPENAI_API_KEY environment variable."
|
||||
)
|
||||
return JSONResponse(status_code=503, content=error.to_dict())
|
||||
|
||||
# Execute via OpenAI with dedicated streaming method
|
||||
if request.stream:
|
||||
return StreamingResponse(
|
||||
self._stream_openai_execution(openai_executor, request),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
)
|
||||
return await openai_executor.execute_sync(request)
|
||||
|
||||
# Route to local Agent Framework executor (original behavior)
|
||||
raw_body = await raw_request.body()
|
||||
logger.info(f"Raw request body: {raw_body.decode()}")
|
||||
logger.info(f"Parsed request: metadata={request.metadata}")
|
||||
@@ -392,18 +734,86 @@ class DevServer:
|
||||
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}")
|
||||
error_msg = self._format_error(e, "Request execution")
|
||||
error = OpenAIError.create(error_msg)
|
||||
return JSONResponse(status_code=500, content=error.to_dict())
|
||||
|
||||
# ========================================
|
||||
# OpenAI Conversations API (Standard)
|
||||
# ========================================
|
||||
|
||||
@app.post("/v1/conversations")
|
||||
async def create_conversation(request_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a new conversation - OpenAI standard."""
|
||||
@app.post("/v1/conversations", response_model=None)
|
||||
async def create_conversation(raw_request: Request) -> dict[str, Any] | JSONResponse:
|
||||
"""Create a new conversation - routes to OpenAI or local based on mode."""
|
||||
try:
|
||||
# Parse request body
|
||||
request_data = await raw_request.json()
|
||||
|
||||
# Check if frontend requested OpenAI proxy mode
|
||||
proxy_mode = raw_request.headers.get("X-Proxy-Backend")
|
||||
|
||||
if proxy_mode == "openai":
|
||||
# Create conversation in OpenAI
|
||||
openai_executor = await self._ensure_openai_executor()
|
||||
if not openai_executor.is_configured:
|
||||
error = OpenAIError.create(
|
||||
"OpenAI proxy mode not configured. Set OPENAI_API_KEY environment variable.",
|
||||
type="configuration_error",
|
||||
code="openai_not_configured",
|
||||
)
|
||||
return JSONResponse(status_code=503, content=error.to_dict())
|
||||
|
||||
# Use OpenAI client to create conversation
|
||||
from openai import APIStatusError, AsyncOpenAI, AuthenticationError, PermissionDeniedError
|
||||
|
||||
client = AsyncOpenAI(
|
||||
api_key=openai_executor.api_key,
|
||||
base_url=openai_executor.base_url,
|
||||
)
|
||||
|
||||
try:
|
||||
metadata = request_data.get("metadata")
|
||||
logger.debug(f"Creating OpenAI conversation with metadata: {metadata}")
|
||||
conversation = await client.conversations.create(metadata=metadata)
|
||||
logger.info(f"Created OpenAI conversation: {conversation.id}")
|
||||
return conversation.model_dump()
|
||||
except AuthenticationError as e:
|
||||
# 401 - Invalid API key or authentication issue
|
||||
logger.error(f"OpenAI authentication error creating conversation: {e}")
|
||||
error_body = e.body if hasattr(e, "body") else {}
|
||||
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
|
||||
error = OpenAIError.create(
|
||||
message=error_data.get("message", str(e)),
|
||||
type=error_data.get("type", "authentication_error"),
|
||||
code=error_data.get("code", "invalid_api_key"),
|
||||
)
|
||||
return JSONResponse(status_code=401, content=error.to_dict())
|
||||
except PermissionDeniedError as e:
|
||||
# 403 - Permission denied
|
||||
logger.error(f"OpenAI permission denied creating conversation: {e}")
|
||||
error_body = e.body if hasattr(e, "body") else {}
|
||||
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
|
||||
error = OpenAIError.create(
|
||||
message=error_data.get("message", str(e)),
|
||||
type=error_data.get("type", "permission_denied"),
|
||||
code=error_data.get("code", "insufficient_permissions"),
|
||||
)
|
||||
return JSONResponse(status_code=403, content=error.to_dict())
|
||||
except APIStatusError as e:
|
||||
# Other OpenAI API errors (rate limit, etc.)
|
||||
logger.error(f"OpenAI API error creating conversation: {e}")
|
||||
error_body = e.body if hasattr(e, "body") else {}
|
||||
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
|
||||
error = OpenAIError.create(
|
||||
message=error_data.get("message", str(e)),
|
||||
type=error_data.get("type", "api_error"),
|
||||
code=error_data.get("code", "unknown_error"),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=e.status_code if hasattr(e, "status_code") else 500, content=error.to_dict()
|
||||
)
|
||||
|
||||
# Local mode - use DevUI conversation store
|
||||
metadata = request_data.get("metadata")
|
||||
executor = await self._ensure_executor()
|
||||
conversation = executor.conversation_store.create_conversation(metadata=metadata)
|
||||
@@ -411,22 +821,39 @@ class DevServer:
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating conversation: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create conversation: {e!s}") from e
|
||||
logger.error(f"Error creating conversation: {e}", exc_info=True)
|
||||
error = OpenAIError.create(f"Failed to create conversation: {e!s}")
|
||||
return JSONResponse(status_code=500, content=error.to_dict())
|
||||
|
||||
@app.get("/v1/conversations")
|
||||
async def list_conversations(agent_id: str | None = None) -> dict[str, Any]:
|
||||
"""List conversations, optionally filtered by agent_id."""
|
||||
async def list_conversations(
|
||||
agent_id: str | None = None,
|
||||
entity_id: str | None = None,
|
||||
type: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List conversations, optionally filtered by agent_id, entity_id, and/or type.
|
||||
|
||||
Query Parameters:
|
||||
- agent_id: Filter by agent_id (for agent conversations)
|
||||
- entity_id: Filter by entity_id (for workflow sessions or other entities)
|
||||
- type: Filter by conversation type (e.g., "workflow_session")
|
||||
|
||||
Multiple filters can be combined (AND logic).
|
||||
"""
|
||||
try:
|
||||
executor = await self._ensure_executor()
|
||||
|
||||
# Build filter criteria
|
||||
filters = {}
|
||||
if agent_id:
|
||||
# Filter by agent_id metadata
|
||||
conversations = executor.conversation_store.list_conversations_by_metadata({"agent_id": agent_id})
|
||||
else:
|
||||
# Return all conversations (for InMemoryStore, list all)
|
||||
# Note: This assumes list_conversations_by_metadata({}) returns all
|
||||
conversations = executor.conversation_store.list_conversations_by_metadata({})
|
||||
filters["agent_id"] = agent_id
|
||||
if entity_id:
|
||||
filters["entity_id"] = entity_id
|
||||
if type:
|
||||
filters["type"] = type
|
||||
|
||||
# Apply filters
|
||||
conversations = executor.conversation_store.list_conversations_by_metadata(filters)
|
||||
|
||||
return {
|
||||
"object": "list",
|
||||
@@ -511,9 +938,20 @@ class DevServer:
|
||||
items, has_more = await executor.conversation_store.list_items(
|
||||
conversation_id, limit=limit, after=after, order=order
|
||||
)
|
||||
# Handle both Pydantic models and dicts (some stores return raw dicts)
|
||||
serialized_items = []
|
||||
for item in items:
|
||||
if hasattr(item, "model_dump"):
|
||||
serialized_items.append(item.model_dump())
|
||||
elif isinstance(item, dict):
|
||||
serialized_items.append(item)
|
||||
else:
|
||||
logger.warning(f"Unexpected item type: {type(item)}, converting to dict")
|
||||
serialized_items.append(dict(item))
|
||||
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [item.model_dump() for item in items],
|
||||
"data": serialized_items,
|
||||
"has_more": has_more,
|
||||
}
|
||||
except ValueError as e:
|
||||
@@ -532,13 +970,51 @@ class DevServer:
|
||||
item = executor.conversation_store.get_item(conversation_id, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
return item.model_dump()
|
||||
result: dict[str, Any] = item.model_dump()
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting item {item_id} from conversation {conversation_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get item: {e!s}") from e
|
||||
|
||||
@app.delete("/v1/conversations/{conversation_id}/items/{item_id}")
|
||||
async def delete_conversation_item(conversation_id: str, item_id: str) -> dict[str, Any]:
|
||||
"""Delete conversation item - supports checkpoint deletion."""
|
||||
try:
|
||||
executor = await self._ensure_executor()
|
||||
|
||||
# Check if this is a checkpoint item
|
||||
if item_id.startswith("checkpoint_"):
|
||||
# Extract checkpoint_id from item_id (format: "checkpoint_{checkpoint_id}")
|
||||
checkpoint_id = item_id[len("checkpoint_") :]
|
||||
storage = executor.checkpoint_manager.get_checkpoint_storage(conversation_id)
|
||||
deleted = await storage.delete_checkpoint(checkpoint_id)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Checkpoint not found")
|
||||
|
||||
return {
|
||||
"id": item_id,
|
||||
"object": "item.deleted",
|
||||
"deleted": True,
|
||||
}
|
||||
# For other items, delegate to conversation store (if it supports deletion)
|
||||
raise HTTPException(status_code=501, detail="Deletion of non-checkpoint items not implemented")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting item {item_id} from conversation {conversation_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete item: {e!s}") from e
|
||||
|
||||
# ============================================================================
|
||||
# Checkpoint Management - Now handled through conversation items API
|
||||
# Checkpoints are exposed as conversation items with type="checkpoint"
|
||||
# ============================================================================
|
||||
|
||||
async def _stream_execution(
|
||||
self, executor: AgentFrameworkExecutor, request: AgentFrameworkRequest
|
||||
) -> AsyncGenerator[str, None]:
|
||||
@@ -587,6 +1063,63 @@ class DevServer:
|
||||
error_event = {"id": "error", "object": "error", "error": {"message": str(e), "type": "execution_error"}}
|
||||
yield f"data: {json.dumps(error_event)}\n\n"
|
||||
|
||||
async def _stream_openai_execution(
|
||||
self, executor: OpenAIExecutor, request: AgentFrameworkRequest
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream execution through OpenAI executor.
|
||||
|
||||
OpenAI events are already in final format - no conversion or aggregation needed.
|
||||
Just serialize and stream them as SSE.
|
||||
|
||||
Args:
|
||||
executor: OpenAI executor instance
|
||||
request: Request to execute
|
||||
|
||||
Yields:
|
||||
SSE-formatted event strings
|
||||
"""
|
||||
try:
|
||||
# Stream events from OpenAI - they're already ResponseStreamEvent objects
|
||||
async for event in executor.execute_streaming(request):
|
||||
# Handle error dicts from executor
|
||||
if isinstance(event, dict):
|
||||
payload = json.dumps(event)
|
||||
yield f"data: {payload}\n\n"
|
||||
continue
|
||||
|
||||
# OpenAI SDK events have model_dump_json() - use it for single-line JSON
|
||||
if hasattr(event, "model_dump_json"):
|
||||
payload = event.model_dump_json() # type: ignore[attr-defined]
|
||||
yield f"data: {payload}\n\n"
|
||||
else:
|
||||
# Fallback (shouldn't happen with OpenAI SDK)
|
||||
logger.warning(f"Unexpected event type from OpenAI: {type(event)}")
|
||||
payload = json.dumps(str(event))
|
||||
yield f"data: {payload}\n\n"
|
||||
|
||||
# OpenAI already sends response.completed event - no aggregation needed!
|
||||
# Just send [DONE] marker
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in OpenAI streaming execution: {e}", exc_info=True)
|
||||
# Emit proper response.failed event
|
||||
import os
|
||||
|
||||
error_event = {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": str(e),
|
||||
"type": "internal_error",
|
||||
"code": "streaming_error",
|
||||
},
|
||||
},
|
||||
}
|
||||
yield f"data: {json.dumps(error_event)}\n\n"
|
||||
|
||||
def _mount_ui(self, app: FastAPI) -> None:
|
||||
"""Mount the UI as static files."""
|
||||
from pathlib import Path
|
||||
|
||||
@@ -324,6 +324,71 @@ def generate_schema_from_dataclass(cls: type[Any]) -> dict[str, Any]:
|
||||
return schema
|
||||
|
||||
|
||||
def extract_response_type_from_executor(executor: Any, request_type: type) -> type | None:
|
||||
"""Extract the expected response type from an executor's response handler.
|
||||
|
||||
Looks for methods decorated with @response_handler that have signature:
|
||||
async def handler(self, original_request: RequestType, response: ResponseType, ctx)
|
||||
|
||||
Args:
|
||||
executor: Executor object that should have a handler for the request type
|
||||
request_type: The request message type
|
||||
|
||||
Returns:
|
||||
The response type class, or None if not found
|
||||
"""
|
||||
try:
|
||||
from typing import get_type_hints
|
||||
|
||||
# Introspect handler methods for @response_handler pattern
|
||||
for attr_name in dir(executor):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
attr = getattr(executor, attr_name, None)
|
||||
if not callable(attr):
|
||||
continue
|
||||
|
||||
# Get type hints for this method
|
||||
try:
|
||||
type_hints = get_type_hints(attr)
|
||||
|
||||
# Check for @response_handler pattern:
|
||||
# async def handler(self, original_request: RequestType, response: ResponseType, ctx)
|
||||
type_hint_params = {k: v for k, v in type_hints.items() if k not in ("self", "return")}
|
||||
|
||||
# Look for at least 2 parameters: original_request, response (ctx is optional)
|
||||
if len(type_hint_params) >= 2:
|
||||
param_items = list(type_hint_params.items())
|
||||
# First param should be original_request matching request_type
|
||||
_, first_param_type = param_items[0]
|
||||
_, second_param_type = param_items[1] if len(param_items) > 1 else (None, None)
|
||||
|
||||
# Check if first param matches request_type
|
||||
first_matches_request = first_param_type == request_type or (
|
||||
hasattr(first_param_type, "__name__")
|
||||
and hasattr(request_type, "__name__")
|
||||
and first_param_type.__name__ == request_type.__name__
|
||||
)
|
||||
|
||||
# Verify we have a matching request type and valid response type (must be a type class)
|
||||
if first_matches_request and second_param_type is not None and isinstance(second_param_type, type):
|
||||
response_type_class: type = second_param_type
|
||||
logger.debug(
|
||||
f"Found response type {response_type_class} for request {request_type} "
|
||||
f"via @response_handler"
|
||||
)
|
||||
return response_type_class
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get type hints for {attr_name}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to extract response type from executor: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def generate_input_schema(input_type: type) -> dict[str, Any]:
|
||||
"""Generate JSON schema for workflow input type.
|
||||
|
||||
|
||||
@@ -27,14 +27,18 @@ from openai.types.responses import (
|
||||
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
|
||||
from openai.types.shared import Metadata, ResponsesModel
|
||||
|
||||
from ._discovery_models import DiscoveryResponse, EntityInfo
|
||||
from ._discovery_models import Deployment, DeploymentConfig, DeploymentEvent, DiscoveryResponse, EntityInfo
|
||||
from ._openai_custom import (
|
||||
AgentFrameworkRequest,
|
||||
CustomResponseOutputItemAddedEvent,
|
||||
CustomResponseOutputItemDoneEvent,
|
||||
ExecutorActionItem,
|
||||
MetaResponse,
|
||||
OpenAIError,
|
||||
ResponseFunctionResultComplete,
|
||||
ResponseOutputData,
|
||||
ResponseOutputFile,
|
||||
ResponseOutputImage,
|
||||
ResponseTraceEvent,
|
||||
ResponseTraceEventComplete,
|
||||
ResponseWorkflowEventComplete,
|
||||
@@ -51,10 +55,14 @@ __all__ = [
|
||||
"ConversationItem",
|
||||
"CustomResponseOutputItemAddedEvent",
|
||||
"CustomResponseOutputItemDoneEvent",
|
||||
"Deployment",
|
||||
"DeploymentConfig",
|
||||
"DeploymentEvent",
|
||||
"DiscoveryResponse",
|
||||
"EntityInfo",
|
||||
"ExecutorActionItem",
|
||||
"InputTokensDetails",
|
||||
"MetaResponse",
|
||||
"Metadata",
|
||||
"OpenAIError",
|
||||
"OpenAIResponse",
|
||||
@@ -67,6 +75,9 @@ __all__ = [
|
||||
"ResponseFunctionToolCall",
|
||||
"ResponseFunctionToolCallOutputItem",
|
||||
"ResponseInputParam",
|
||||
"ResponseOutputData",
|
||||
"ResponseOutputFile",
|
||||
"ResponseOutputImage",
|
||||
"ResponseOutputItemAddedEvent",
|
||||
"ResponseOutputItemDoneEvent",
|
||||
"ResponseOutputMessage",
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class EnvVarRequirement(BaseModel):
|
||||
@@ -36,6 +37,10 @@ class EntityInfo(BaseModel):
|
||||
# Environment variable requirements
|
||||
required_env_vars: list[EnvVarRequirement] | None = None
|
||||
|
||||
# Deployment support
|
||||
deployment_supported: bool = False # Whether entity can be deployed
|
||||
deployment_reason: str | None = None # Explanation of why/why not entity can be deployed
|
||||
|
||||
# Agent-specific fields (optional, populated when available)
|
||||
instructions: str | None = None
|
||||
model_id: str | None = None
|
||||
@@ -55,3 +60,144 @@ class DiscoveryResponse(BaseModel):
|
||||
"""Response model for entity discovery."""
|
||||
|
||||
entities: list[EntityInfo] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Deployment Models
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class DeploymentConfig(BaseModel):
|
||||
"""Configuration for deploying an entity."""
|
||||
|
||||
entity_id: str = Field(description="Entity ID to deploy")
|
||||
resource_group: str = Field(description="Azure resource group name")
|
||||
app_name: str = Field(description="Azure Container App name")
|
||||
region: str = Field(default="eastus", description="Azure region")
|
||||
ui_mode: str = Field(default="user", description="UI mode (user or developer)")
|
||||
ui_enabled: bool = Field(default=True, description="Whether to enable web interface")
|
||||
stream: bool = Field(default=True, description="Stream deployment events")
|
||||
|
||||
@field_validator("app_name")
|
||||
@classmethod
|
||||
def validate_app_name(cls, v: str) -> str:
|
||||
"""Validate Azure Container App name format.
|
||||
|
||||
Azure Container App names must:
|
||||
- Be 3-32 characters long
|
||||
- Contain only lowercase letters, numbers, and hyphens
|
||||
- Start with a lowercase letter
|
||||
- End with a lowercase letter or number
|
||||
- Not contain consecutive hyphens
|
||||
"""
|
||||
if not v:
|
||||
raise ValueError("app_name cannot be empty")
|
||||
|
||||
if len(v) < 3 or len(v) > 32:
|
||||
raise ValueError("app_name must be between 3 and 32 characters")
|
||||
|
||||
if not re.match(r"^[a-z][a-z0-9-]*[a-z0-9]$", v):
|
||||
raise ValueError(
|
||||
"app_name must start with a lowercase letter, "
|
||||
"end with a letter or number, and contain only lowercase letters, numbers, and hyphens"
|
||||
)
|
||||
|
||||
if "--" in v:
|
||||
raise ValueError("app_name cannot contain consecutive hyphens")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("resource_group")
|
||||
@classmethod
|
||||
def validate_resource_group(cls, v: str) -> str:
|
||||
"""Validate Azure resource group name format.
|
||||
|
||||
Azure resource group names must:
|
||||
- Be 1-90 characters long
|
||||
- Contain only alphanumeric, underscore, parentheses, hyphen, period (except at end)
|
||||
- Not end with a period
|
||||
"""
|
||||
if not v:
|
||||
raise ValueError("resource_group cannot be empty")
|
||||
|
||||
if len(v) > 90:
|
||||
raise ValueError("resource_group must be 90 characters or less")
|
||||
|
||||
if not re.match(r"^[a-zA-Z0-9._()-]+$", v):
|
||||
raise ValueError(
|
||||
"resource_group can only contain alphanumeric characters, "
|
||||
"underscores, hyphens, periods, and parentheses"
|
||||
)
|
||||
|
||||
if v.endswith("."):
|
||||
raise ValueError("resource_group cannot end with a period")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("region")
|
||||
@classmethod
|
||||
def validate_region(cls, v: str) -> str:
|
||||
"""Validate Azure region format.
|
||||
|
||||
Validates that the region string is a reasonable format.
|
||||
Does not validate against the full list of Azure regions (which changes).
|
||||
"""
|
||||
if not v:
|
||||
raise ValueError("region cannot be empty")
|
||||
|
||||
if len(v) > 50:
|
||||
raise ValueError("region name too long")
|
||||
|
||||
# Azure regions are typically lowercase with no spaces (e.g., eastus, westeurope)
|
||||
if not re.match(r"^[a-z0-9]+$", v):
|
||||
raise ValueError("region must contain only lowercase letters and numbers (e.g., eastus, westeurope)")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("entity_id")
|
||||
@classmethod
|
||||
def validate_entity_id(cls, v: str) -> str:
|
||||
"""Validate entity_id format to prevent injection attacks."""
|
||||
if not v:
|
||||
raise ValueError("entity_id cannot be empty")
|
||||
|
||||
if len(v) > 256:
|
||||
raise ValueError("entity_id too long")
|
||||
|
||||
# Allow alphanumeric, hyphens, underscores, and periods
|
||||
if not re.match(r"^[a-zA-Z0-9._-]+$", v):
|
||||
raise ValueError("entity_id contains invalid characters")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("ui_mode")
|
||||
@classmethod
|
||||
def validate_ui_mode(cls, v: str) -> str:
|
||||
"""Validate ui_mode is one of the allowed values."""
|
||||
if v not in ("user", "developer"):
|
||||
raise ValueError("ui_mode must be 'user' or 'developer'")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class DeploymentEvent(BaseModel):
|
||||
"""Real-time deployment event (SSE)."""
|
||||
|
||||
type: str = Field(description="Event type (e.g., deploy.validating, deploy.building)")
|
||||
message: str = Field(description="Human-readable message")
|
||||
url: str | None = Field(default=None, description="Deployment URL (on completion)")
|
||||
auth_token: str | None = Field(default=None, description="Auth token (on completion, shown once)")
|
||||
|
||||
|
||||
class Deployment(BaseModel):
|
||||
"""Deployment record."""
|
||||
|
||||
id: str = Field(description="Deployment ID (UUID)")
|
||||
entity_id: str = Field(description="Entity ID that was deployed")
|
||||
resource_group: str = Field(description="Azure resource group")
|
||||
app_name: str = Field(description="Azure Container App name")
|
||||
region: str = Field(description="Azure region")
|
||||
url: str = Field(description="Deployment URL")
|
||||
status: str = Field(description="Deployment status (deploying, deployed, failed)")
|
||||
created_at: str = Field(description="ISO 8601 timestamp")
|
||||
error: str | None = Field(default=None, description="Error message if failed")
|
||||
|
||||
@@ -80,9 +80,16 @@ class CustomResponseOutputItemDoneEvent(BaseModel):
|
||||
|
||||
|
||||
class ResponseWorkflowEventComplete(BaseModel):
|
||||
"""Complete workflow event data."""
|
||||
"""Complete workflow event data.
|
||||
|
||||
type: Literal["response.workflow_event.complete"] = "response.workflow_event.complete"
|
||||
DevUI extension for workflow execution events (debugging/observability).
|
||||
Uses past-tense 'completed' to follow OpenAI's event naming pattern.
|
||||
|
||||
Workflow events are shown in the debug panel for monitoring execution flow,
|
||||
not in main chat. Use response.output_item.added for user-facing content.
|
||||
"""
|
||||
|
||||
type: Literal["response.workflow_event.completed"] = "response.workflow_event.completed"
|
||||
data: dict[str, Any] # Complete event data, not delta
|
||||
executor_id: str | None = None
|
||||
item_id: str
|
||||
@@ -91,9 +98,17 @@ class ResponseWorkflowEventComplete(BaseModel):
|
||||
|
||||
|
||||
class ResponseTraceEventComplete(BaseModel):
|
||||
"""Complete trace event data."""
|
||||
"""Complete trace event data.
|
||||
|
||||
type: Literal["response.trace.complete"] = "response.trace.complete"
|
||||
DevUI extension for non-displayable debugging/metadata events.
|
||||
Uses past-tense 'completed' to follow OpenAI's event naming pattern
|
||||
(e.g., response.completed, response.output_item.added).
|
||||
|
||||
Trace events are shown in the Traces debug panel, not in main chat.
|
||||
Use response.output_item.added for user-facing content.
|
||||
"""
|
||||
|
||||
type: Literal["response.trace.completed"] = "response.trace.completed"
|
||||
data: dict[str, Any] # Complete trace data, not delta
|
||||
span_id: str | None = None
|
||||
item_id: str
|
||||
@@ -124,6 +139,139 @@ class ResponseFunctionResultComplete(BaseModel):
|
||||
timestamp: str | None = None # Optional timestamp for UI display
|
||||
|
||||
|
||||
class ResponseRequestInfoEvent(BaseModel):
|
||||
"""DevUI extension: Workflow requests human input.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API doesn't have a concept of workflow human-in-the-loop pausing
|
||||
- Agent Framework workflows can pause via RequestInfoExecutor to collect external information
|
||||
- Clients need to render forms and submit responses to continue workflow execution
|
||||
|
||||
When a workflow emits this event, it enters IDLE_WITH_PENDING_REQUESTS state.
|
||||
Client should render a form based on request_schema and submit responses via
|
||||
a new request with workflow_hil_response content type.
|
||||
"""
|
||||
|
||||
type: Literal["response.request_info.requested"] = "response.request_info.requested"
|
||||
request_id: str
|
||||
"""Unique identifier for correlating this request with the response."""
|
||||
|
||||
source_executor_id: str
|
||||
"""ID of the executor that is waiting for this response."""
|
||||
|
||||
request_type: str
|
||||
"""Fully qualified type name of the request (e.g., 'module.path:ClassName')."""
|
||||
|
||||
request_data: dict[str, Any]
|
||||
"""Current data from the RequestInfoMessage (may contain defaults/context)."""
|
||||
|
||||
request_schema: dict[str, Any]
|
||||
"""JSON schema describing the request data structure (what the workflow is asking about)."""
|
||||
|
||||
response_schema: dict[str, Any] | None = None
|
||||
"""JSON schema describing the expected response structure for form rendering (what user should provide)."""
|
||||
|
||||
item_id: str
|
||||
"""OpenAI item ID for correlation."""
|
||||
|
||||
output_index: int = 0
|
||||
"""Output index for OpenAI compatibility."""
|
||||
|
||||
sequence_number: int
|
||||
"""Sequence number for ordering events."""
|
||||
|
||||
timestamp: str
|
||||
"""ISO timestamp when the request was made."""
|
||||
|
||||
|
||||
# DevUI Output Content Types - for agent-generated media/data
|
||||
# These extend ResponseOutputItem to support rich content outputs that OpenAI's API doesn't natively support
|
||||
|
||||
|
||||
class ResponseOutputImage(BaseModel):
|
||||
"""DevUI extension: Agent-generated image output.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
|
||||
- ImageGenerationCall exists but is for tool calls (generating images), not returning existing images
|
||||
- Agent Framework agents can return images via DataContent/UriContent that need proper display
|
||||
|
||||
This type allows images to be displayed inline in chat rather than hidden in trace logs.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""The unique ID of the image output."""
|
||||
|
||||
image_url: str
|
||||
"""The URL or data URI of the image (e.g., data:image/png;base64,...)"""
|
||||
|
||||
type: Literal["output_image"] = "output_image"
|
||||
"""The type of the output. Always `output_image`."""
|
||||
|
||||
alt_text: str | None = None
|
||||
"""Optional alt text for accessibility."""
|
||||
|
||||
mime_type: str = "image/png"
|
||||
"""The MIME type of the image (e.g., image/png, image/jpeg)."""
|
||||
|
||||
|
||||
class ResponseOutputFile(BaseModel):
|
||||
"""DevUI extension: Agent-generated file output.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
|
||||
- Agent Framework agents can return files via DataContent/UriContent that need proper display
|
||||
- Supports PDFs, audio files, and other media types
|
||||
|
||||
This type allows files to be displayed inline in chat with appropriate renderers.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""The unique ID of the file output."""
|
||||
|
||||
filename: str
|
||||
"""The filename (used to determine rendering and download)."""
|
||||
|
||||
type: Literal["output_file"] = "output_file"
|
||||
"""The type of the output. Always `output_file`."""
|
||||
|
||||
file_url: str | None = None
|
||||
"""Optional URL to the file."""
|
||||
|
||||
file_data: str | None = None
|
||||
"""Optional base64-encoded file data."""
|
||||
|
||||
mime_type: str = "application/octet-stream"
|
||||
"""The MIME type of the file (e.g., application/pdf, audio/mp3)."""
|
||||
|
||||
|
||||
class ResponseOutputData(BaseModel):
|
||||
"""DevUI extension: Agent-generated generic data output.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
|
||||
- Agent Framework agents can return arbitrary structured data that needs display
|
||||
- Useful for debugging and displaying non-text content
|
||||
|
||||
This type allows generic data to be displayed inline in chat.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""The unique ID of the data output."""
|
||||
|
||||
data: str
|
||||
"""The data payload (string representation)."""
|
||||
|
||||
type: Literal["output_data"] = "output_data"
|
||||
"""The type of the output. Always `output_data`."""
|
||||
|
||||
mime_type: str
|
||||
"""The MIME type of the data."""
|
||||
|
||||
description: str | None = None
|
||||
"""Optional description of the data."""
|
||||
|
||||
|
||||
# Agent Framework extension fields
|
||||
class AgentFrameworkExtraBody(BaseModel):
|
||||
"""Agent Framework specific routing fields for OpenAI requests."""
|
||||
@@ -156,8 +304,12 @@ class AgentFrameworkRequest(BaseModel):
|
||||
metadata: dict[str, Any] | None = None
|
||||
temperature: float | None = None
|
||||
max_output_tokens: int | None = None
|
||||
top_p: float | None = None
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
|
||||
# Reasoning parameters (for o-series models)
|
||||
reasoning: dict[str, Any] | None = None # {"effort": "low" | "medium" | "high" | "minimal"}
|
||||
|
||||
# Optional extra_body for advanced use cases
|
||||
extra_body: dict[str, Any] | None = None
|
||||
|
||||
@@ -219,11 +371,37 @@ class OpenAIError(BaseModel):
|
||||
return self.model_dump_json()
|
||||
|
||||
|
||||
class MetaResponse(BaseModel):
|
||||
"""Server metadata response for /meta endpoint.
|
||||
|
||||
Provides information about the DevUI server configuration and capabilities.
|
||||
"""
|
||||
|
||||
ui_mode: Literal["developer", "user"] = "developer"
|
||||
"""UI interface mode - 'developer' shows debug tools, 'user' shows simplified interface."""
|
||||
|
||||
version: str
|
||||
"""DevUI version string."""
|
||||
|
||||
framework: str = "agent_framework"
|
||||
"""Backend framework identifier."""
|
||||
|
||||
capabilities: dict[str, bool] = {}
|
||||
"""Server capabilities (e.g., tracing, openai_proxy)."""
|
||||
|
||||
auth_required: bool = False
|
||||
"""Whether the server requires Bearer token authentication."""
|
||||
|
||||
|
||||
# Export all custom types
|
||||
__all__ = [
|
||||
"AgentFrameworkRequest",
|
||||
"MetaResponse",
|
||||
"OpenAIError",
|
||||
"ResponseFunctionResultComplete",
|
||||
"ResponseOutputData",
|
||||
"ResponseOutputFile",
|
||||
"ResponseOutputImage",
|
||||
"ResponseTraceEvent",
|
||||
"ResponseTraceEventComplete",
|
||||
"ResponseWorkflowEventComplete",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -15,7 +15,9 @@
|
||||
"@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-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
|
||||
@@ -3,33 +3,49 @@
|
||||
* Features: Entity selection, layout management, debug coordination
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useEffect, useCallback, useState } from "react";
|
||||
import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout";
|
||||
import { GalleryView } from "@/components/features/gallery";
|
||||
import { AgentView } from "@/components/features/agent";
|
||||
import { WorkflowView } from "@/components/features/workflow";
|
||||
import { Toast } from "@/components/ui/toast";
|
||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||
import { apiClient } from "@/services/api";
|
||||
import { PanelRightOpen, ChevronDown, ServerOff, Rocket } from "lucide-react";
|
||||
import { PanelRightOpen, ChevronLeft, ChevronDown, ServerOff, Rocket, Lock } from "lucide-react";
|
||||
import type {
|
||||
AgentInfo,
|
||||
WorkflowInfo,
|
||||
ExtendedResponseStreamEvent,
|
||||
} from "@/types";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { Input } from "./components/ui/input";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
export default function App() {
|
||||
// Local state for auth handling
|
||||
const [authRequired, setAuthRequired] = useState(false);
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [isTestingToken, setIsTestingToken] = useState(false);
|
||||
const [authError, setAuthError] = useState("");
|
||||
|
||||
// Entity state from Zustand
|
||||
const agents = useDevUIStore((state) => state.agents);
|
||||
const workflows = useDevUIStore((state) => state.workflows);
|
||||
const entities = useDevUIStore((state) => state.entities);
|
||||
const selectedAgent = useDevUIStore((state) => state.selectedAgent);
|
||||
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
|
||||
const isLoadingEntities = useDevUIStore((state) => state.isLoadingEntities);
|
||||
const entityError = useDevUIStore((state) => state.entityError);
|
||||
|
||||
// OpenAI proxy mode
|
||||
const oaiMode = useDevUIStore((state) => state.oaiMode);
|
||||
|
||||
// UI mode
|
||||
const uiMode = useDevUIStore((state) => state.uiMode);
|
||||
|
||||
// Entity actions
|
||||
const setAgents = useDevUIStore((state) => state.setAgents);
|
||||
const setWorkflows = useDevUIStore((state) => state.setWorkflows);
|
||||
const setEntities = useDevUIStore((state) => state.setEntities);
|
||||
const selectEntity = useDevUIStore((state) => state.selectEntity);
|
||||
const updateAgent = useDevUIStore((state) => state.updateAgent);
|
||||
const updateWorkflow = useDevUIStore((state) => state.updateWorkflow);
|
||||
@@ -38,12 +54,14 @@ export default function App() {
|
||||
|
||||
// UI state from Zustand
|
||||
const showDebugPanel = useDevUIStore((state) => state.showDebugPanel);
|
||||
const debugPanelMinimized = useDevUIStore((state) => state.debugPanelMinimized);
|
||||
const debugPanelWidth = useDevUIStore((state) => state.debugPanelWidth);
|
||||
const debugEvents = useDevUIStore((state) => state.debugEvents);
|
||||
const isResizing = useDevUIStore((state) => state.isResizing);
|
||||
|
||||
// UI actions
|
||||
const setShowDebugPanel = useDevUIStore((state) => state.setShowDebugPanel);
|
||||
const setDebugPanelMinimized = useDevUIStore((state) => state.setDebugPanelMinimized);
|
||||
const setDebugPanelWidth = useDevUIStore((state) => state.setDebugPanelWidth);
|
||||
const addDebugEvent = useDevUIStore((state) => state.addDebugEvent);
|
||||
const clearDebugEvents = useDevUIStore((state) => state.clearDebugEvents);
|
||||
@@ -61,13 +79,39 @@ export default function App() {
|
||||
const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal);
|
||||
const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast);
|
||||
|
||||
// Toast state and actions
|
||||
const toasts = useDevUIStore((state) => state.toasts);
|
||||
const removeToast = useDevUIStore((state) => state.removeToast);
|
||||
|
||||
// Initialize app - load agents and workflows
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// Single API call instead of two parallel calls to same endpoint
|
||||
const { agents: agentList, workflows: workflowList } = await apiClient.getEntities();
|
||||
// Fetch server metadata first (ui_mode, capabilities, auth status)
|
||||
const meta = await apiClient.getMeta();
|
||||
|
||||
// Check if auth is required
|
||||
if (meta.auth_required) {
|
||||
setAuthRequired(true);
|
||||
|
||||
// If we don't have a token, stop here and show auth UI
|
||||
if (!apiClient.getAuthToken()) {
|
||||
setEntityError("UNAUTHORIZED");
|
||||
setIsLoadingEntities(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
useDevUIStore.getState().setServerMeta({
|
||||
uiMode: meta.ui_mode,
|
||||
capabilities: meta.capabilities,
|
||||
authRequired: meta.auth_required,
|
||||
});
|
||||
|
||||
// Single API call instead of two parallel calls to same endpoint
|
||||
const { entities: allEntities, agents: agentList, workflows: workflowList } = await apiClient.getEntities();
|
||||
|
||||
setEntities(allEntities);
|
||||
setAgents(agentList);
|
||||
setWorkflows(workflowList);
|
||||
|
||||
@@ -79,9 +123,7 @@ export default function App() {
|
||||
|
||||
// Try to find entity from URL parameter first
|
||||
if (entityId) {
|
||||
selectedEntity =
|
||||
agentList.find((a) => a.id === entityId) ||
|
||||
workflowList.find((w) => w.id === entityId);
|
||||
selectedEntity = allEntities.find((e) => e.id === entityId);
|
||||
|
||||
// If entity not found but was requested, show notification
|
||||
if (!selectedEntity) {
|
||||
@@ -91,12 +133,9 @@ export default function App() {
|
||||
|
||||
// Fallback to first available entity if URL entity not found
|
||||
if (!selectedEntity) {
|
||||
selectedEntity =
|
||||
agentList.length > 0
|
||||
? agentList[0]
|
||||
: workflowList.length > 0
|
||||
? workflowList[0]
|
||||
: undefined;
|
||||
// Use the first entity from the backend's original order
|
||||
// This respects the backend's intended display order
|
||||
selectedEntity = allEntities.length > 0 ? allEntities[0] : undefined;
|
||||
|
||||
// Update URL to match actual selected entity (or clear if none)
|
||||
if (selectedEntity) {
|
||||
@@ -140,9 +179,14 @@ export default function App() {
|
||||
setIsLoadingEntities(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to load agents/workflows:", error);
|
||||
setEntityError(
|
||||
error instanceof Error ? error.message : "Failed to load data"
|
||||
);
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to load data";
|
||||
|
||||
// Check if this is an auth error
|
||||
if (errorMessage === "UNAUTHORIZED") {
|
||||
setAuthRequired(true);
|
||||
}
|
||||
|
||||
setEntityError(errorMessage);
|
||||
setIsLoadingEntities(false);
|
||||
}
|
||||
};
|
||||
@@ -150,6 +194,47 @@ export default function App() {
|
||||
loadData();
|
||||
}, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast]);
|
||||
|
||||
// Handle auth token submission
|
||||
const handleAuthTokenSubmit = useCallback(async () => {
|
||||
if (!authToken.trim()) return;
|
||||
|
||||
setIsTestingToken(true);
|
||||
setAuthError("");
|
||||
|
||||
try {
|
||||
// Set token in API client (stores in localStorage)
|
||||
apiClient.setAuthToken(authToken.trim());
|
||||
|
||||
// Test the token with an actual PROTECTED endpoint (not /meta which is public)
|
||||
await apiClient.getEntities();
|
||||
|
||||
// If successful, reload to initialize with new token
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
// Token is invalid - clear it and show error
|
||||
apiClient.clearAuthToken();
|
||||
setIsTestingToken(false);
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
||||
if (errorMsg === "UNAUTHORIZED") {
|
||||
setAuthError("Invalid token. Please check and try again.");
|
||||
} else {
|
||||
setAuthError(`Failed to connect: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}, [authToken]);
|
||||
|
||||
// Auto-switch from workflow to agent when OpenAI proxy mode is enabled
|
||||
useEffect(() => {
|
||||
if (oaiMode.enabled && selectedAgent?.type === "workflow") {
|
||||
// Workflows don't work with OpenAI proxy - switch to first available agent
|
||||
const firstAgent = agents[0];
|
||||
if (firstAgent) {
|
||||
selectEntity(firstAgent);
|
||||
}
|
||||
}
|
||||
}, [oaiMode.enabled, selectedAgent, agents, selectEntity]);
|
||||
|
||||
// Handle resize drag
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -242,12 +327,14 @@ export default function App() {
|
||||
// Show error state if loading failed
|
||||
if (entityError) {
|
||||
const currentBackendUrl = apiClient.getBaseUrl();
|
||||
const isAuthError = entityError === "UNAUTHORIZED" || authRequired;
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background">
|
||||
<AppHeader
|
||||
agents={[]}
|
||||
workflows={[]}
|
||||
entities={[]}
|
||||
selectedItem={undefined}
|
||||
onSelect={() => {}}
|
||||
isLoading={false}
|
||||
@@ -260,63 +347,124 @@ export default function App() {
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-muted p-4 animate-pulse">
|
||||
<ServerOff className="h-12 w-12 text-muted-foreground" />
|
||||
{isAuthError ? (
|
||||
<Lock className="h-12 w-12 text-muted-foreground" />
|
||||
) : (
|
||||
<ServerOff className="h-12 w-12 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
Can't Connect to Backend
|
||||
{isAuthError ? "Authentication Required" : "Can't Connect to Backend"}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base">
|
||||
No worries! Just start the DevUI backend server and you'll be
|
||||
good to go.
|
||||
{isAuthError
|
||||
? "This backend requires a bearer token to access."
|
||||
: "No worries! Just start the DevUI backend server and you'll be good to go."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Command Instructions */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Start the backend:
|
||||
</p>
|
||||
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
|
||||
devui ./agents --port 8080
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or launch programmatically with{" "}
|
||||
<code className="text-xs">serve(entities=[agent])</code>
|
||||
</p>
|
||||
{/* Auth Input or Command Instructions */}
|
||||
{isAuthError ? (
|
||||
<div className="space-y-4">
|
||||
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Enter Authentication Token
|
||||
</p>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Paste token from server logs"
|
||||
value={authToken}
|
||||
onChange={(e) => setAuthToken(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isTestingToken) {
|
||||
handleAuthTokenSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={isTestingToken}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAuthTokenSubmit}
|
||||
disabled={!authToken.trim() || isTestingToken}
|
||||
className="w-full"
|
||||
>
|
||||
{isTestingToken ? "Verifying..." : "Connect"}
|
||||
</Button>
|
||||
|
||||
{/* Error message */}
|
||||
{authError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 text-center">
|
||||
{authError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details className="text-left group">
|
||||
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2 justify-center">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Where do I find the token?
|
||||
</summary>
|
||||
<div className="mt-3 text-left bg-muted/30 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Look for this in your DevUI server startup logs:
|
||||
</p>
|
||||
<code className="block bg-background px-2 py-1 rounded text-xs font-mono text-foreground">
|
||||
🔑 DEV TOKEN (localhost only, shown once):
|
||||
<br />
|
||||
abc123xyz...
|
||||
</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Start the backend:
|
||||
</p>
|
||||
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
|
||||
devui ./agents --port 8080
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or launch programmatically with{" "}
|
||||
<code className="text-xs">serve(entities=[agent])</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default:{" "}
|
||||
<span className="font-mono">{currentBackendUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default:{" "}
|
||||
<span className="font-mono">{currentBackendUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Details (Collapsible) */}
|
||||
{entityError && (
|
||||
<details className="text-left group">
|
||||
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Error details
|
||||
</summary>
|
||||
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
|
||||
{entityError}
|
||||
</p>
|
||||
</details>
|
||||
{/* Error Details (Collapsible) */}
|
||||
{entityError && (
|
||||
<details className="text-left group">
|
||||
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Error details
|
||||
</summary>
|
||||
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
|
||||
{entityError}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Retry Button */}
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="default"
|
||||
className="mt-2"
|
||||
>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Retry Button */}
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="default"
|
||||
className="mt-2"
|
||||
>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -331,6 +479,7 @@ export default function App() {
|
||||
<AppHeader
|
||||
agents={agents}
|
||||
workflows={workflows}
|
||||
entities={entities}
|
||||
selectedItem={selectedAgent}
|
||||
onSelect={handleEntitySelect}
|
||||
onBrowseGallery={() => setShowGallery(true)}
|
||||
@@ -377,7 +526,7 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDebugPanel ? (
|
||||
{uiMode === "developer" && showDebugPanel ? (
|
||||
<>
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
@@ -400,31 +549,68 @@ export default function App() {
|
||||
{/* Right Panel - Debug */}
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]"
|
||||
style={{ width: `${debugPanelWidth}px` }}
|
||||
style={{ width: debugPanelMinimized ? '2.5rem' : `${debugPanelWidth}px` }}
|
||||
>
|
||||
<DebugPanel
|
||||
events={debugEvents}
|
||||
isStreaming={false} // Each view manages its own streaming state
|
||||
onClose={() => setShowDebugPanel(false)}
|
||||
/>
|
||||
|
||||
{/* Deploy Footer - Pinned to bottom */}
|
||||
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => setShowDeployModal(true)}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
{debugPanelMinimized ? (
|
||||
/* Minimized Debug Panel - Vertical Bar (fully clickable) */
|
||||
<div
|
||||
className="h-full w-10 bg-background border-l flex flex-col items-center py-2 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => setDebugPanelMinimized(false)}
|
||||
title="Expand debug panel"
|
||||
>
|
||||
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||
<span className="truncate text-xs">
|
||||
Deployment Guide for {selectedAgent?.name || "Agent"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Expand button at top (visual affordance) */}
|
||||
<div className="h-8 w-8 flex items-center justify-center">
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Text and count centered in middle */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 pointer-events-none">
|
||||
<div
|
||||
className="text-xs text-muted-foreground select-none"
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
transform: 'rotate(180deg)'
|
||||
}}
|
||||
>
|
||||
Debug Panel
|
||||
</div>
|
||||
{debugEvents.length > 0 && (
|
||||
<div className="bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center"
|
||||
style={{ fontSize: '10px' }}>
|
||||
{debugEvents.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DebugPanel
|
||||
events={debugEvents}
|
||||
isStreaming={false} // Each view manages its own streaming state
|
||||
onMinimize={() => setDebugPanelMinimized(true)}
|
||||
/>
|
||||
|
||||
{/* Deploy Footer - Pinned to bottom */}
|
||||
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => setShowDeployModal(true)}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||
<span className="truncate text-xs">
|
||||
{azureDeploymentEnabled && selectedAgent?.deployment_supported
|
||||
? "Deploy to Azure"
|
||||
: "Deployment Guide"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
) : uiMode === "developer" ? (
|
||||
/* Button to reopen when closed */
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
@@ -437,7 +623,7 @@ export default function App() {
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -450,6 +636,7 @@ export default function App() {
|
||||
open={showDeployModal}
|
||||
onClose={() => setShowDeployModal(false)}
|
||||
agentName={selectedAgent?.name}
|
||||
entity={selectedAgent}
|
||||
/>
|
||||
|
||||
{/* Toast Notification */}
|
||||
@@ -460,6 +647,9 @@ export default function App() {
|
||||
onClose={() => setShowEntityNotFoundToast(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast Container for reload and other notifications */}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
X,
|
||||
Copy,
|
||||
CheckCheck,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type {
|
||||
@@ -161,7 +162,12 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
|
||||
<span>{new Date().toLocaleTimeString()}</span>
|
||||
<span>
|
||||
{item.created_at
|
||||
? new Date(item.created_at * 1000).toLocaleTimeString()
|
||||
: new Date().toLocaleTimeString() // Fallback for legacy items without timestamp
|
||||
}
|
||||
</span>
|
||||
{!isUser && item.usage && (
|
||||
<>
|
||||
<span>•</span>
|
||||
@@ -207,8 +213,10 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const loadingConversations = useDevUIStore((state) => state.loadingConversations);
|
||||
const inputValue = useDevUIStore((state) => state.inputValue);
|
||||
const attachments = useDevUIStore((state) => state.attachments);
|
||||
const uiMode = useDevUIStore((state) => state.uiMode);
|
||||
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
|
||||
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
|
||||
const oaiMode = useDevUIStore((state) => state.oaiMode);
|
||||
|
||||
// Get conversation actions from Zustand (only the ones we actually use)
|
||||
const setCurrentConversation = useDevUIStore((state) => state.setCurrentConversation);
|
||||
@@ -227,6 +235,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const [dragCounter, setDragCounter] = useState(0);
|
||||
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [conversationError, setConversationError] = useState<{
|
||||
message: string;
|
||||
code?: string;
|
||||
type?: string;
|
||||
} | null>(null);
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -604,10 +618,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setAvailableConversations([newConversation]);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
} catch {
|
||||
setConversationError(null); // Clear any previous errors
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(cachedKey, JSON.stringify([newConversation]));
|
||||
} catch (error) {
|
||||
setAvailableConversations([]);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Extract error details for display
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
|
||||
setConversationError({
|
||||
message: errorMessage,
|
||||
type: "conversation_creation_error",
|
||||
});
|
||||
} finally {
|
||||
setLoadingConversations(false);
|
||||
}
|
||||
@@ -856,11 +881,22 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setAvailableConversations([newConversation, ...useDevUIStore.getState().availableConversations]);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
setConversationError(null); // Clear any previous errors
|
||||
// Reset conversation usage by setting it to initial state
|
||||
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
|
||||
accumulatedTextRef.current = "";
|
||||
} catch {
|
||||
// Failed to create conversation
|
||||
|
||||
// Update localStorage cache with new conversation
|
||||
const cachedKey = `devui_convs_${selectedAgent.id}`;
|
||||
const updated = [newConversation, ...availableConversations];
|
||||
localStorage.setItem(cachedKey, JSON.stringify(updated));
|
||||
} catch (error) {
|
||||
// Failed to create conversation - show error to user
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
|
||||
setConversationError({
|
||||
message: errorMessage,
|
||||
type: "conversation_creation_error",
|
||||
});
|
||||
}
|
||||
}, [selectedAgent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
|
||||
|
||||
@@ -915,6 +951,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
[availableConversations, currentConversation, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]
|
||||
);
|
||||
|
||||
// Handle entity reload (hot reload)
|
||||
const handleReloadEntity = useCallback(async () => {
|
||||
if (isReloading || !selectedAgent) return;
|
||||
|
||||
setIsReloading(true);
|
||||
const addToast = useDevUIStore.getState().addToast;
|
||||
const updateAgent = useDevUIStore.getState().updateAgent;
|
||||
|
||||
try {
|
||||
// Call backend reload endpoint
|
||||
await apiClient.reloadEntity(selectedAgent.id);
|
||||
|
||||
// Fetch updated entity info
|
||||
const updatedAgent = await apiClient.getAgentInfo(selectedAgent.id);
|
||||
|
||||
// Update store with fresh metadata
|
||||
updateAgent(updatedAgent);
|
||||
|
||||
// Show success toast
|
||||
addToast({
|
||||
message: `${selectedAgent.name} has been reloaded successfully`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
// Show error toast
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to reload entity";
|
||||
addToast({
|
||||
message: `Failed to reload: ${errorMessage}`,
|
||||
type: "error",
|
||||
duration: 6000,
|
||||
});
|
||||
} finally {
|
||||
setIsReloading(false);
|
||||
}
|
||||
}, [isReloading, selectedAgent]);
|
||||
|
||||
// Handle conversation selection
|
||||
const handleConversationSelect = useCallback(
|
||||
async (conversationId: string) => {
|
||||
@@ -1002,6 +1074,27 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const approval = pendingApprovals.find((a) => a.request_id === request_id);
|
||||
if (!approval) return;
|
||||
|
||||
// Add user's decision as a visible message in the chat
|
||||
const messageTimestamp = Math.floor(Date.now() / 1000);
|
||||
const userDecisionMessage: import("@/types/openai").ConversationMessage = {
|
||||
id: `user-approval-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "function_approval_request",
|
||||
request_id: request_id,
|
||||
status: approved ? "approved" : "rejected",
|
||||
function_call: approval.function_call,
|
||||
} as import("@/types/openai").MessageFunctionApprovalRequestContent,
|
||||
],
|
||||
status: "completed",
|
||||
created_at: messageTimestamp,
|
||||
};
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems([...currentItems, userDecisionMessage]);
|
||||
|
||||
// Create approval response in OpenAI-compatible format
|
||||
const approvalInput: import("@/types/agent-framework").ResponseInputParam = [
|
||||
{
|
||||
@@ -1019,13 +1112,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
];
|
||||
|
||||
// Send approval response through the conversation
|
||||
// We'll call handleSendMessage directly when invoked (it's defined below)
|
||||
const request: RunAgentRequest = {
|
||||
input: approvalInput,
|
||||
conversation_id: currentConversation?.id,
|
||||
};
|
||||
|
||||
// Remove from pending immediately (will be confirmed by backend event)
|
||||
// Remove from pending immediately
|
||||
setPendingApprovals(
|
||||
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== request_id)
|
||||
);
|
||||
@@ -1039,6 +1131,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
async (request: RunAgentRequest) => {
|
||||
if (!selectedAgent) return;
|
||||
|
||||
// Check if this is a function approval response (internal, don't show in chat)
|
||||
const isApprovalResponse = request.input.some(
|
||||
(inputItem) =>
|
||||
inputItem.type === "message" &&
|
||||
Array.isArray(inputItem.content) &&
|
||||
inputItem.content.some((c) => c.type === "function_approval_response")
|
||||
);
|
||||
|
||||
// Extract content from OpenAI format to create ConversationMessage
|
||||
const messageContent: import("@/types/openai").MessageContent[] = [];
|
||||
|
||||
@@ -1069,16 +1169,23 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message to UI state (OpenAI ConversationMessage)
|
||||
const userMessage: import("@/types/openai").ConversationMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: messageContent,
|
||||
status: "completed",
|
||||
};
|
||||
// Capture timestamp once for both user and assistant messages
|
||||
const messageTimestamp = Math.floor(Date.now() / 1000); // Unix seconds
|
||||
|
||||
// Only add user message to UI if it's not an approval response (internal messages)
|
||||
if (!isApprovalResponse && messageContent.length > 0) {
|
||||
const userMessage: import("@/types/openai").ConversationMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: messageContent,
|
||||
status: "completed",
|
||||
created_at: messageTimestamp,
|
||||
};
|
||||
|
||||
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
|
||||
}
|
||||
|
||||
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
|
||||
setIsStreaming(true);
|
||||
|
||||
// Create assistant message placeholder
|
||||
@@ -1088,6 +1195,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
role: "assistant",
|
||||
content: [], // Will be filled during streaming
|
||||
status: "in_progress",
|
||||
created_at: messageTimestamp,
|
||||
};
|
||||
|
||||
setChatItems([...useDevUIStore.getState().chatItems, assistantMessage]);
|
||||
@@ -1102,8 +1210,17 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
});
|
||||
setCurrentConversation(conversationToUse);
|
||||
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
|
||||
} catch {
|
||||
// Failed to create conversation
|
||||
setConversationError(null); // Clear any previous errors
|
||||
} catch (error) {
|
||||
// Failed to create conversation - show error and stop execution
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
|
||||
setConversationError({
|
||||
message: errorMessage,
|
||||
type: "conversation_creation_error",
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
setIsStreaming(false);
|
||||
return; // Stop execution - can't send message without conversation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,16 +1262,25 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
continue; // Continue processing other events
|
||||
}
|
||||
|
||||
// Handle response.failed event
|
||||
// Handle response.failed event (OpenAI standard)
|
||||
if (openAIEvent.type === "response.failed") {
|
||||
const failedEvent = openAIEvent as import("@/types/openai").ResponseFailedEvent;
|
||||
const error = failedEvent.response?.error;
|
||||
const errorMessage = error
|
||||
? typeof error === "object" && "message" in error
|
||||
? (error as any).message
|
||||
: JSON.stringify(error)
|
||||
: "Request failed";
|
||||
|
||||
// Format error message with details
|
||||
let errorMessage = "Request failed";
|
||||
if (error) {
|
||||
if (typeof error === "object" && "message" in error) {
|
||||
errorMessage = error.message as string;
|
||||
if ("code" in error && error.code) {
|
||||
errorMessage += ` (Code: ${error.code})`;
|
||||
}
|
||||
} else if (typeof error === "string") {
|
||||
errorMessage = error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update assistant message with error
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
@@ -1171,14 +1297,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
return; // Exit stream processing on failure
|
||||
}
|
||||
|
||||
// Handle function approval request events
|
||||
if (openAIEvent.type === "response.function_approval.requested") {
|
||||
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
|
||||
|
||||
// Add to pending approvals
|
||||
// Add to pending approvals (for popup)
|
||||
setPendingApprovals([
|
||||
...useDevUIStore.getState().pendingApprovals,
|
||||
{
|
||||
@@ -1186,17 +1312,46 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
function_call: approvalEvent.function_call,
|
||||
},
|
||||
]);
|
||||
continue; // Don't add approval requests to chat UI
|
||||
|
||||
// Also add to chat UI to show function call progress
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) => {
|
||||
if (item.id === assistantMessage.id && item.type === "message") {
|
||||
return {
|
||||
...item,
|
||||
content: [
|
||||
...item.content,
|
||||
{
|
||||
type: "function_approval_request",
|
||||
request_id: approvalEvent.request_id,
|
||||
status: "pending",
|
||||
function_call: approvalEvent.function_call,
|
||||
} as import("@/types/openai").MessageFunctionApprovalRequestContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle function approval response events
|
||||
if (openAIEvent.type === "response.function_approval.responded") {
|
||||
const responseEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRespondedEvent;
|
||||
// Handle function result events (after function execution)
|
||||
if (openAIEvent.type === "response.function_result.complete") {
|
||||
const resultEvent = openAIEvent as import("@/types/openai").ResponseFunctionResultComplete;
|
||||
|
||||
// Remove from pending approvals
|
||||
setPendingApprovals(
|
||||
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== responseEvent.request_id)
|
||||
);
|
||||
// Add function result as a separate conversation item for clear visibility
|
||||
const functionResultItem: import("@/types/openai").ConversationFunctionCallOutput = {
|
||||
id: `result-${Date.now()}`,
|
||||
type: "function_call_output",
|
||||
call_id: resultEvent.call_id,
|
||||
output: resultEvent.output,
|
||||
status: resultEvent.status === "completed" ? "completed" : "incomplete",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems([...currentItems, functionResultItem]);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1227,6 +1382,57 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
return; // Exit stream processing early on error
|
||||
}
|
||||
|
||||
// Handle output item added events (images, files, data)
|
||||
if (openAIEvent.type === "response.output_item.added") {
|
||||
const outputItemEvent = openAIEvent as import("@/types/openai").ResponseOutputItemAddedEvent;
|
||||
const item = outputItemEvent.item;
|
||||
|
||||
// Add output items to assistant message content
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((chatItem) => {
|
||||
if (chatItem.id === assistantMessage.id && chatItem.type === "message") {
|
||||
const existingContent = chatItem.content;
|
||||
let newContent: import("@/types/openai").MessageContent | null = null;
|
||||
|
||||
// Map output items to message content
|
||||
if (item.type === "output_image") {
|
||||
newContent = {
|
||||
type: "output_image",
|
||||
image_url: item.image_url,
|
||||
alt_text: item.alt_text,
|
||||
mime_type: item.mime_type,
|
||||
} as import("@/types/openai").MessageOutputImage;
|
||||
} else if (item.type === "output_file") {
|
||||
newContent = {
|
||||
type: "output_file",
|
||||
filename: item.filename,
|
||||
file_url: item.file_url,
|
||||
file_data: item.file_data,
|
||||
mime_type: item.mime_type,
|
||||
} as import("@/types/openai").MessageOutputFile;
|
||||
} else if (item.type === "output_data") {
|
||||
newContent = {
|
||||
type: "output_data",
|
||||
data: item.data,
|
||||
mime_type: item.mime_type,
|
||||
description: item.description,
|
||||
} as import("@/types/openai").MessageOutputData;
|
||||
}
|
||||
|
||||
// If we created new content, append it
|
||||
if (newContent) {
|
||||
return {
|
||||
...chatItem,
|
||||
content: [...existingContent, newContent],
|
||||
status: "in_progress" as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
return chatItem;
|
||||
}));
|
||||
continue; // Continue to next event
|
||||
}
|
||||
|
||||
// Handle text delta events for chat
|
||||
if (
|
||||
openAIEvent.type === "response.output_text.delta" &&
|
||||
@@ -1236,21 +1442,26 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
accumulatedTextRef.current += openAIEvent.delta;
|
||||
|
||||
// Update assistant message with accumulated content
|
||||
// Preserve any existing non-text content (images, files, data)
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedTextRef.current,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setChatItems(currentItems.map((item) => {
|
||||
if (item.id === assistantMessage.id && item.type === "message") {
|
||||
// Keep existing non-text content, update text content
|
||||
const existingNonTextContent = item.content.filter(c => c.type !== "text");
|
||||
return {
|
||||
...item,
|
||||
content: [
|
||||
...existingNonTextContent,
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedTextRef.current,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
// Handle completion/error by detecting when streaming stops
|
||||
@@ -1435,19 +1646,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
Chat with {selectedAgent.name || selectedAgent.id}
|
||||
{oaiMode.enabled
|
||||
? `Chat with ${oaiMode.model}`
|
||||
: `Chat with ${selectedAgent.name || selectedAgent.id}`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDetailsModalOpen(true)}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title="View agent details"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
{!oaiMode.enabled && uiMode === "developer" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDetailsModalOpen(true)}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title="View agent details"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReloadEntity}
|
||||
disabled={isReloading || selectedAgent.metadata?.source === "in_memory"}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title={
|
||||
selectedAgent.metadata?.source === "in_memory"
|
||||
? "In-memory entities cannot be reloaded"
|
||||
: isReloading
|
||||
? "Reloading..."
|
||||
: "Reload entity code (hot reload)"
|
||||
}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isReloading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conversation Controls */}
|
||||
@@ -1539,13 +1773,46 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAgent.description && (
|
||||
{oaiMode.enabled ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedAgent.description}
|
||||
Using OpenAI model directly. Local agent tools and instructions are not applied.
|
||||
</p>
|
||||
) : (
|
||||
selectedAgent.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedAgent.description}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{conversationError && (
|
||||
<div className="mx-4 mt-2 p-3 bg-destructive/10 border border-destructive/30 rounded-md flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-destructive">
|
||||
Failed to Create Conversation
|
||||
</div>
|
||||
<div className="text-xs text-destructive/90 mt-1 break-words">
|
||||
{conversationError.message}
|
||||
</div>
|
||||
{conversationError.code && (
|
||||
<div className="text-xs text-destructive/70 mt-1">
|
||||
Error Code: {conversationError.code}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setConversationError(null)}
|
||||
className="text-destructive hover:text-destructive/80 flex-shrink-0"
|
||||
title="Dismiss error"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-4 h-0" ref={scrollAreaRef}>
|
||||
<div className="space-y-4">
|
||||
|
||||
+136
-4
@@ -11,6 +11,9 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Music,
|
||||
Check,
|
||||
X,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import type { MessageContent } from "@/types/openai";
|
||||
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
|
||||
@@ -37,12 +40,12 @@ function TextContentRenderer({ content, className, isStreaming }: ContentRendere
|
||||
);
|
||||
}
|
||||
|
||||
// Image content renderer
|
||||
// Image content renderer (handles both input and output images)
|
||||
function ImageContentRenderer({ content, className }: ContentRendererProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "input_image") return null;
|
||||
if (content.type !== "input_image" && content.type !== "output_image") return null;
|
||||
|
||||
const imageUrl = content.image_url;
|
||||
|
||||
@@ -77,9 +80,9 @@ function ImageContentRenderer({ content, className }: ContentRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// File content renderer
|
||||
// File content renderer (handles both input and output files)
|
||||
function FileContentRenderer({ content, className }: ContentRendererProps) {
|
||||
if (content.type !== "input_file") return null;
|
||||
if (content.type !== "input_file" && content.type !== "output_file") return null;
|
||||
|
||||
const fileUrl = content.file_url || content.file_data;
|
||||
const filename = content.filename || "file";
|
||||
@@ -156,6 +159,129 @@ function FileContentRenderer({ content, className }: ContentRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Data content renderer (for generic structured data outputs)
|
||||
function DataContentRenderer({ content, className }: ContentRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "output_data") return null;
|
||||
|
||||
const data = content.data;
|
||||
const mimeType = content.mime_type;
|
||||
const description = content.description;
|
||||
|
||||
// Try to parse as JSON for pretty printing
|
||||
let displayData = data;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
displayData = JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
// Not JSON, display as-is
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{description || "Data Output"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{mimeType}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<pre className="mt-2 text-xs overflow-auto max-h-64 bg-background p-2 rounded border font-mono">
|
||||
{displayData}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Function approval request renderer
|
||||
function FunctionApprovalRequestRenderer({ content, className }: ContentRendererProps) {
|
||||
if (content.type !== "function_approval_request") return null;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { status, function_call } = content;
|
||||
|
||||
// Status styling
|
||||
const statusConfig = {
|
||||
pending: {
|
||||
icon: Clock,
|
||||
color: "amber",
|
||||
label: "Awaiting Approval",
|
||||
bgClass: "bg-amber-50 dark:bg-amber-950/20",
|
||||
borderClass: "border-amber-200 dark:border-amber-800",
|
||||
iconClass: "text-amber-600 dark:text-amber-400",
|
||||
textClass: "text-amber-800 dark:text-amber-300",
|
||||
},
|
||||
approved: {
|
||||
icon: Check,
|
||||
color: "green",
|
||||
label: "Approved",
|
||||
bgClass: "bg-green-50 dark:bg-green-950/20",
|
||||
borderClass: "border-green-200 dark:border-green-800",
|
||||
iconClass: "text-green-600 dark:text-green-400",
|
||||
textClass: "text-green-800 dark:text-green-300",
|
||||
},
|
||||
rejected: {
|
||||
icon: X,
|
||||
color: "red",
|
||||
label: "Rejected",
|
||||
bgClass: "bg-red-50 dark:bg-red-950/20",
|
||||
borderClass: "border-red-200 dark:border-red-800",
|
||||
iconClass: "text-red-600 dark:text-red-400",
|
||||
textClass: "text-red-800 dark:text-red-300",
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
let parsedArgs;
|
||||
try {
|
||||
parsedArgs = typeof function_call.arguments === "string"
|
||||
? JSON.parse(function_call.arguments)
|
||||
: function_call.arguments;
|
||||
} catch {
|
||||
parsedArgs = function_call.arguments;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded ${config.bgClass} ${config.borderClass} ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<StatusIcon className={`h-4 w-4 ${config.iconClass}`} />
|
||||
<span className={`text-sm font-medium ${config.textClass}`}>
|
||||
{config.label}: {function_call.name}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className={`h-4 w-4 ${config.iconClass} ml-auto`} />
|
||||
) : (
|
||||
<ChevronRight className={`h-4 w-4 ${config.iconClass} ml-auto`} />
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
<div className={`${config.textClass} mb-1`}>Arguments:</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedArgs, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main content renderer that delegates to specific renderers
|
||||
export function OpenAIContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
|
||||
switch (content.type) {
|
||||
@@ -164,9 +290,15 @@ export function OpenAIContentRenderer({ content, className, isStreaming }: Conte
|
||||
case "output_text":
|
||||
return <TextContentRenderer content={content} className={className} isStreaming={isStreaming} />;
|
||||
case "input_image":
|
||||
case "output_image":
|
||||
return <ImageContentRenderer content={content} className={className} />;
|
||||
case "input_file":
|
||||
case "output_file":
|
||||
return <FileContentRenderer content={content} className={className} />;
|
||||
case "output_data":
|
||||
return <DataContentRenderer content={content} className={className} />;
|
||||
case "function_approval_request":
|
||||
return <FunctionApprovalRequestRenderer content={content} className={className} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* ExecutionTimeline - Vertical timeline showing workflow executor runs
|
||||
* Features: Chronological executor execution, expandable output, bidirectional graph highlighting
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
import type { ExecutorState } from "./executor-node";
|
||||
|
||||
interface ExecutorRun {
|
||||
executorId: string;
|
||||
executorName: string;
|
||||
itemId: string; // Unique ID for this specific run
|
||||
state: ExecutorState;
|
||||
output: string;
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
runNumber: number; // For multiple runs of same executor
|
||||
}
|
||||
|
||||
interface ExecutionTimelineProps {
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
itemOutputs: Record<string, string>;
|
||||
currentExecutorId: string | null;
|
||||
isStreaming: boolean;
|
||||
onExecutorClick?: (executorId: string) => void;
|
||||
selectedExecutorId?: string | null;
|
||||
workflowResult?: string;
|
||||
}
|
||||
|
||||
function getStateIcon(state: ExecutorState) {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return <Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin" />;
|
||||
case "completed":
|
||||
return <CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />;
|
||||
case "failed":
|
||||
return <XCircle className="w-4 h-4 text-red-500 dark:text-red-400" />;
|
||||
case "cancelled":
|
||||
return <AlertCircle className="w-4 h-4 text-orange-500 dark:text-orange-400" />;
|
||||
default:
|
||||
return <div className="w-4 h-4 rounded-full border-2 border-gray-400 dark:border-gray-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getStateBadgeClass(state: ExecutorState) {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return "bg-[#643FB2]/10 text-[#643FB2] dark:bg-[#8B5CF6]/10 dark:text-[#8B5CF6] border-[#643FB2]/20 dark:border-[#8B5CF6]/20";
|
||||
case "completed":
|
||||
return "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20";
|
||||
case "failed":
|
||||
return "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20";
|
||||
case "cancelled":
|
||||
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20";
|
||||
default:
|
||||
return "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20";
|
||||
}
|
||||
}
|
||||
|
||||
function ExecutorRunItem({
|
||||
run,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onClick,
|
||||
isSelected,
|
||||
}: {
|
||||
run: ExecutorRun;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const timestamp = new Date(run.timestamp).toLocaleTimeString();
|
||||
const hasOutput = run.output.trim().length > 0;
|
||||
const canExpand = hasOutput || run.error;
|
||||
const outputRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
// Auto-scroll output to bottom when content changes (during streaming)
|
||||
useEffect(() => {
|
||||
if (isExpanded && run.state === "running" && outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||
}
|
||||
}, [run.output, isExpanded, run.state]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-lg transition-all ${
|
||||
isSelected
|
||||
? "border-blue-500 dark:border-blue-400 bg-blue-500/5 dark:bg-blue-500/10"
|
||||
: "border-border hover:border-muted-foreground/30"
|
||||
}`}
|
||||
>
|
||||
{/* Header - Always Visible */}
|
||||
<div
|
||||
className="p-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
onClick();
|
||||
if (canExpand) onToggle();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{canExpand && (
|
||||
<div className="text-muted-foreground">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{getStateIcon(run.state)}
|
||||
<span className="font-medium text-sm truncate flex-1">
|
||||
{run.executorName}
|
||||
</span>
|
||||
{run.runNumber > 1 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Run #{run.runNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-5">
|
||||
<span className="font-mono">{timestamp}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs border ${getStateBadgeClass(run.state)}`}
|
||||
>
|
||||
{run.state}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Content */}
|
||||
{isExpanded && canExpand && (
|
||||
<div className="border-t px-3 py-2 bg-muted/30">
|
||||
{run.error ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-red-600 dark:text-red-400">
|
||||
Error:
|
||||
</div>
|
||||
<pre className="text-xs bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded p-2 overflow-y-auto overflow-x-hidden max-h-40 whitespace-pre-wrap break-all">
|
||||
{run.error}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Output:
|
||||
</div>
|
||||
<pre
|
||||
ref={outputRef}
|
||||
className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all"
|
||||
>
|
||||
{run.output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExecutionTimeline({
|
||||
events,
|
||||
itemOutputs,
|
||||
currentExecutorId,
|
||||
isStreaming,
|
||||
onExecutorClick,
|
||||
selectedExecutorId,
|
||||
workflowResult,
|
||||
}: ExecutionTimelineProps) {
|
||||
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(new Set());
|
||||
const [updateTrigger, setUpdateTrigger] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const lastScrolledRunRef = useRef<string | null>(null);
|
||||
const timelineEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Force re-render when streaming to show updated outputs from itemOutputs ref
|
||||
// Note: itemOutputs is a ref (not state), so changes don't trigger re-renders automatically.
|
||||
// This polling approach ensures the UI updates during streaming. Could be optimized by:
|
||||
// 1. Converting itemOutputs to state (increases re-renders)
|
||||
// 2. Using requestAnimationFrame instead of setInterval
|
||||
// 3. Having parent component trigger updates via callback
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
const interval = setInterval(() => {
|
||||
setUpdateTrigger((prev) => prev + 1);
|
||||
}, 100); // Update 10 times per second during streaming
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// Process events to extract executor runs - memoized to prevent recalculation
|
||||
const { executorRuns, executorRunCount } = useMemo(() => {
|
||||
const runs: ExecutorRun[] = [];
|
||||
const runCount = new Map<string, number>();
|
||||
|
||||
events.forEach((event) => {
|
||||
// Extract UI timestamp (captured when event arrived, won't change on re-render)
|
||||
const uiTimestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
|
||||
? event._uiTimestamp * 1000
|
||||
: Date.now();
|
||||
|
||||
// Handle new standard OpenAI events
|
||||
if (event.type === "response.output_item.added") {
|
||||
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; created_at?: number; metadata?: any } }).item;
|
||||
|
||||
// Handle both executor_action items AND message items from Magentic agents
|
||||
if (item && item.type === "executor_action" && item.executor_id && item.id) {
|
||||
const executorId = item.executor_id;
|
||||
const itemId = item.id;
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: executorId,
|
||||
itemId,
|
||||
state: "running",
|
||||
output: itemOutputs[itemId] || "",
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
|
||||
// Handle message items from Magentic agents
|
||||
const executorId = item.metadata.agent_id;
|
||||
const itemId = item.id;
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: executorId,
|
||||
itemId,
|
||||
state: "running",
|
||||
output: itemOutputs[itemId] || "",
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle completion events
|
||||
if (event.type === "response.output_item.done") {
|
||||
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; status?: string; error?: string; metadata?: any } }).item;
|
||||
|
||||
// Handle both executor_action items AND message items from Magentic agents
|
||||
if (item && item.type === "executor_action" && item.executor_id && item.id) {
|
||||
const itemId = item.id;
|
||||
// Find the run by ITEM ID (not executor ID!) to handle multiple runs correctly
|
||||
const existingRun = runs.find((r) => r.itemId === itemId);
|
||||
|
||||
if (existingRun) {
|
||||
existingRun.state =
|
||||
item.status === "completed"
|
||||
? "completed"
|
||||
: item.status === "failed"
|
||||
? "failed"
|
||||
: "completed";
|
||||
// Use item-specific output, not executor-wide output
|
||||
existingRun.output = itemOutputs[itemId] || "";
|
||||
if (item.status === "failed" && item.error) {
|
||||
existingRun.error = item.error;
|
||||
}
|
||||
}
|
||||
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
|
||||
// Handle message completion from Magentic agents
|
||||
const itemId = item.id;
|
||||
const existingRun = runs.find((r) => r.itemId === itemId);
|
||||
|
||||
if (existingRun) {
|
||||
existingRun.state = item.status === "completed" ? "completed" : "failed";
|
||||
existingRun.output = itemOutputs[itemId] || "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback support for workflow_event format (used for unhandled event types and status/warning/error events)
|
||||
if (
|
||||
event.type === "response.workflow_event.completed" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
) {
|
||||
const data = event.data as { executor_id?: string; event_type?: string; data?: unknown; timestamp?: string };
|
||||
const executorId = data.executor_id;
|
||||
if (!executorId) return;
|
||||
|
||||
const eventType = data.event_type;
|
||||
|
||||
if (eventType === "ExecutorInvokedEvent") {
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
// Create synthetic item ID for fallback format (no real item.id from backend)
|
||||
const syntheticItemId = `fallback_${executorId}_${uiTimestamp}`;
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: executorId,
|
||||
itemId: syntheticItemId,
|
||||
state: "running",
|
||||
output: itemOutputs[syntheticItemId] || "",
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
} else if (eventType === "ExecutorCompletedEvent") {
|
||||
// Find the most recent running instance of this executor (search from end)
|
||||
let existingRun: ExecutorRun | undefined;
|
||||
for (let i = runs.length - 1; i >= 0; i--) {
|
||||
if (runs[i].executorId === executorId && runs[i].state === "running") {
|
||||
existingRun = runs[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (existingRun) {
|
||||
existingRun.state = "completed";
|
||||
existingRun.output = itemOutputs[existingRun.itemId] || "";
|
||||
}
|
||||
} else if (
|
||||
eventType?.includes("Error") ||
|
||||
eventType?.includes("Failed")
|
||||
) {
|
||||
// Find the most recent running instance of this executor (search from end)
|
||||
let existingRun: ExecutorRun | undefined;
|
||||
for (let i = runs.length - 1; i >= 0; i--) {
|
||||
if (runs[i].executorId === executorId && runs[i].state === "running") {
|
||||
existingRun = runs[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (existingRun) {
|
||||
existingRun.state = "failed";
|
||||
existingRun.error =
|
||||
typeof data.data === "string" ? data.data : "Execution failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update outputs for running executors using item-specific outputs
|
||||
// This ensures each run gets its own output, even for multiple runs of the same executor
|
||||
runs.forEach((run) => {
|
||||
if (run.state === "running" && itemOutputs[run.itemId]) {
|
||||
run.output = itemOutputs[run.itemId];
|
||||
}
|
||||
});
|
||||
|
||||
return { executorRuns: runs, executorRunCount: runCount };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [events, itemOutputs, updateTrigger]);
|
||||
|
||||
// Auto-expand running executors
|
||||
useEffect(() => {
|
||||
if (currentExecutorId) {
|
||||
setExpandedRuns((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(`${currentExecutorId}-${executorRunCount.get(currentExecutorId) || 1}`);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [currentExecutorId, executorRunCount]);
|
||||
|
||||
// Auto-scroll to newest executor when it appears or changes
|
||||
useEffect(() => {
|
||||
if (executorRuns.length > 0 && isStreaming) {
|
||||
const latestRun = executorRuns[executorRuns.length - 1];
|
||||
const latestRunKey = `${latestRun.executorId}-${latestRun.runNumber}`;
|
||||
|
||||
// Only scroll if this is a new run we haven't scrolled to yet
|
||||
if (latestRunKey !== lastScrolledRunRef.current) {
|
||||
lastScrolledRunRef.current = latestRunKey;
|
||||
|
||||
// Scroll to the end of the timeline
|
||||
if (timelineEndRef.current) {
|
||||
timelineEndRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [executorRuns, isStreaming]);
|
||||
|
||||
// Auto-scroll to show workflow result when it appears (after streaming completes)
|
||||
useEffect(() => {
|
||||
if (workflowResult && !isStreaming && timelineEndRef.current) {
|
||||
// Small delay to ensure the result card is rendered before scrolling
|
||||
setTimeout(() => {
|
||||
timelineEndRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}, [workflowResult, isStreaming]);
|
||||
|
||||
const handleCopyAll = () => {
|
||||
const text = executorRuns
|
||||
.map((run) => {
|
||||
const timestamp = new Date(run.timestamp).toLocaleTimeString();
|
||||
const header = `[${timestamp}] ${run.executorName} (${run.state})`;
|
||||
const content = run.error || run.output || "(no output)";
|
||||
return `${header}\n${content}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col border-l bg-muted/30">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b bg-background flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">Execution Timeline</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{executorRuns.length}
|
||||
</Badge>
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-[#643FB2] dark:bg-[#8B5CF6]" />
|
||||
<span>Running</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{executorRuns.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyAll}
|
||||
className={`h-7 px-2 text-xs ${copied ? "text-green-600 dark:text-green-400" : ""}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 mr-1" />
|
||||
Copy All
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3 space-y-2">
|
||||
{executorRuns.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
No executor runs yet. Start the workflow to see execution timeline.
|
||||
</div>
|
||||
) : (
|
||||
executorRuns.map((run, index) => {
|
||||
const runKey = `${run.executorId}-${run.runNumber}`;
|
||||
return (
|
||||
<ExecutorRunItem
|
||||
key={`${runKey}-${index}`}
|
||||
run={run}
|
||||
isExpanded={expandedRuns.has(runKey)}
|
||||
onToggle={() => {
|
||||
setExpandedRuns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(runKey)) {
|
||||
next.delete(runKey);
|
||||
} else {
|
||||
next.add(runKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onClick={() => onExecutorClick?.(run.executorId)}
|
||||
isSelected={selectedExecutorId === run.executorId}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{/* Workflow final output card */}
|
||||
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && (
|
||||
<div className="border rounded-lg border-green-500/40 bg-green-500/5 dark:bg-green-500/10">
|
||||
<div className="p-3 bg-green-500/10 border-b border-green-500/20">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />
|
||||
<span className="font-medium text-sm">Workflow Complete</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-3 py-2 bg-muted/30">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Final Output:
|
||||
</div>
|
||||
<pre className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all">
|
||||
{workflowResult}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Invisible element at the end for scroll target */}
|
||||
<div ref={timelineEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
Workflow,
|
||||
Home,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -155,34 +156,32 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
isRunning ? config.glow : "shadow-sm",
|
||||
)}
|
||||
>
|
||||
{/* Small circular handles */}
|
||||
{!nodeData.isStartNode && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={targetPosition}
|
||||
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#643FB2" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Small circular handles - always render both to support any edge configuration */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={targetPosition}
|
||||
id="target"
|
||||
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#643FB2" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
|
||||
}}
|
||||
/>
|
||||
|
||||
{!nodeData.isEndNode && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={sourcePosition}
|
||||
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#643FB2" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Handle
|
||||
type="source"
|
||||
position={sourcePosition}
|
||||
id="source"
|
||||
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#643FB2" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="p-3">
|
||||
{/* Header with icon and title */}
|
||||
@@ -196,18 +195,16 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
<Workflow className="w-5 h-5 text-gray-300 dark:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
{/* Small status badge for running state */}
|
||||
{isRunning && (
|
||||
<div className={cn(
|
||||
"absolute -top-1 -right-1 w-3 h-3 rounded-full animate-pulse",
|
||||
config.badgeColor
|
||||
)} />
|
||||
)}
|
||||
</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>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
|
||||
{nodeData.name || nodeData.executorId}
|
||||
</h3>
|
||||
{isRunning && (
|
||||
<Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{nodeData.executorType && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{nodeData.executorType}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MessageCircle, Send, Loader2 } from "lucide-react";
|
||||
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
|
||||
import type { JSONSchemaProperty } from "@/types";
|
||||
|
||||
interface HilRequest {
|
||||
request_id: string;
|
||||
request_data: Record<string, unknown>;
|
||||
request_schema: JSONSchemaProperty;
|
||||
}
|
||||
|
||||
interface HilInputModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
requests: HilRequest[];
|
||||
responses: Record<string, Record<string, unknown>>;
|
||||
onResponseChange: (requestId: string, values: Record<string, unknown>) => void;
|
||||
onSubmit: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export function HilInputModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
requests,
|
||||
responses,
|
||||
onResponseChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: HilInputModalProps) {
|
||||
// Check if all required fields are filled
|
||||
const areAllRequiredFieldsFilled = () => {
|
||||
return requests.every((req) => {
|
||||
const response = responses[req.request_id] || {};
|
||||
return validateSchemaForm(req.request_schema, response);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageCircle className="w-5 h-5" />
|
||||
Workflow Requires Input ({requests.length} request
|
||||
{requests.length > 1 ? "s" : ""})
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
The workflow is paused and needs your input to continue.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{requests.map((req, index) => (
|
||||
<Card key={req.request_id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
Request {index + 1}
|
||||
<Badge variant="outline" className="ml-2 font-mono text-xs">
|
||||
{req.request_id.slice(0, 8)}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Show request data as readonly context */}
|
||||
{Object.keys(req.request_data).length > 0 && (
|
||||
<div className="mb-4 p-3 bg-muted rounded-md max-h-48 overflow-y-auto">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Request Context:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(req.request_data)
|
||||
.filter(([key]) => !["request_id", "source_executor_id"].includes(key))
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="text-xs">
|
||||
<span className="font-medium">{key}:</span>{" "}
|
||||
<span className="text-muted-foreground break-all">
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show expected response hint if available */}
|
||||
{req.request_schema?.description && (
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-md">
|
||||
<p className="text-xs font-medium text-blue-900 dark:text-blue-100 mb-1">
|
||||
Expected Response:
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
{req.request_schema.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Use schema-based form renderer for RESPONSE (not request) */}
|
||||
<SchemaFormRenderer
|
||||
schema={req.request_schema}
|
||||
values={responses[req.request_id] || {}}
|
||||
onChange={(values) => onResponseChange(req.request_id, values)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex gap-2 w-full justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !areAllRequiredFieldsFilled()}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Submit & Continue
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,3 +7,5 @@ export { WorkflowDetailsModal } from "./workflow-details-modal";
|
||||
export { WorkflowFlow } from "./workflow-flow";
|
||||
export { WorkflowInputForm } from "./workflow-input-form";
|
||||
export { ExecutorNode } from "./executor-node";
|
||||
export { SchemaFormRenderer, validateSchemaForm, filterEmptyOptionalFields } from "./schema-form-renderer";
|
||||
export { HilInputModal } from "./hil-input-modal";
|
||||
|
||||
+546
@@ -0,0 +1,546 @@
|
||||
import { useState } 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { JSONSchemaProperty } from "@/types";
|
||||
|
||||
// ============================================================================
|
||||
// Field Type Detection (from WorkflowInputForm)
|
||||
// ============================================================================
|
||||
|
||||
function isShortField(fieldName: string): boolean {
|
||||
const shortFieldNames = [
|
||||
"name",
|
||||
"title",
|
||||
"id",
|
||||
"key",
|
||||
"label",
|
||||
"type",
|
||||
"status",
|
||||
"tag",
|
||||
"category",
|
||||
"code",
|
||||
"username",
|
||||
"password",
|
||||
"email",
|
||||
];
|
||||
return shortFieldNames.includes(fieldName.toLowerCase());
|
||||
}
|
||||
|
||||
function shouldFieldBeTextarea(
|
||||
fieldName: string,
|
||||
schema: JSONSchemaProperty
|
||||
): boolean {
|
||||
return (
|
||||
schema.format === "textarea" ||
|
||||
(!!schema.description && schema.description.length > 100) ||
|
||||
(schema.type === "string" && !schema.enum && !isShortField(fieldName))
|
||||
);
|
||||
}
|
||||
|
||||
function getFieldColumnSpan(
|
||||
fieldName: string,
|
||||
schema: JSONSchemaProperty
|
||||
): string {
|
||||
const isTextarea = shouldFieldBeTextarea(fieldName, schema);
|
||||
const hasLongDescription =
|
||||
!!schema.description && schema.description.length > 150;
|
||||
|
||||
if (isTextarea || hasLongDescription) {
|
||||
return "md:col-span-2 lg:col-span-3 xl:col-span-4";
|
||||
}
|
||||
|
||||
if (
|
||||
schema.type === "array" ||
|
||||
(!!schema.description && schema.description.length > 80)
|
||||
) {
|
||||
return "xl:col-span-2";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ChatMessage Pattern Detection (from WorkflowInputForm)
|
||||
// ============================================================================
|
||||
|
||||
function detectChatMessagePattern(
|
||||
schema: JSONSchemaProperty,
|
||||
requiredFields: string[]
|
||||
): boolean {
|
||||
if (schema.type !== "object" || !schema.properties) return false;
|
||||
|
||||
const properties = schema.properties;
|
||||
const optionalFields = Object.keys(properties).filter(
|
||||
(name) => !requiredFields.includes(name)
|
||||
);
|
||||
|
||||
return (
|
||||
requiredFields.includes("role") &&
|
||||
optionalFields.some((f) => ["text", "message", "content"].includes(f)) &&
|
||||
properties["role"]?.type === "string"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Field Component (from WorkflowInputForm)
|
||||
// ============================================================================
|
||||
|
||||
interface FormFieldProps {
|
||||
name: string;
|
||||
schema: JSONSchemaProperty;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
isRequired?: boolean;
|
||||
isReadOnly?: boolean; // NEW: for HIL display-only fields
|
||||
}
|
||||
|
||||
function FormField({
|
||||
name,
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
isRequired = false,
|
||||
isReadOnly = false,
|
||||
}: FormFieldProps) {
|
||||
const { type, description, enum: enumValues, default: defaultValue } = schema;
|
||||
const isTextarea = shouldFieldBeTextarea(name, schema);
|
||||
|
||||
const renderInput = () => {
|
||||
// Read-only display (for HIL request context)
|
||||
if (isReadOnly) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name} className="text-muted-foreground">
|
||||
{name}
|
||||
</Label>
|
||||
<div className="text-sm p-2 bg-muted rounded border">
|
||||
{typeof value === "object"
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "string":
|
||||
if (enumValues) {
|
||||
// Enum select
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</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 (isTextarea) {
|
||||
// Multi-line text
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
typeof defaultValue === "string"
|
||||
? defaultValue
|
||||
: `Enter ${name}`
|
||||
}
|
||||
rows={4}
|
||||
className="min-w-[300px] w-full"
|
||||
/>
|
||||
{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}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</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 "integer":
|
||||
case "number":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type="number"
|
||||
step={type === "integer" ? "1" : "any"}
|
||||
value={typeof value === "number" ? value : ""}
|
||||
onChange={(e) => {
|
||||
const val =
|
||||
type === "integer"
|
||||
? parseInt(e.target.value)
|
||||
: 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}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</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}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</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:
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</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 {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className={getFieldColumnSpan(name, schema)}>{renderInput()}</div>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Schema Form Renderer Component
|
||||
// ============================================================================
|
||||
|
||||
export interface SchemaFormRendererProps {
|
||||
schema: JSONSchemaProperty;
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
readOnlyFields?: string[]; // NEW: Fields to display but not edit (for HIL)
|
||||
hideFields?: string[]; // NEW: Fields to completely hide
|
||||
showCollapsedByDefault?: boolean; // NEW: Control initial collapsed state
|
||||
}
|
||||
|
||||
export function SchemaFormRenderer({
|
||||
schema,
|
||||
values,
|
||||
onChange,
|
||||
disabled = false,
|
||||
readOnlyFields = [],
|
||||
hideFields = [],
|
||||
showCollapsedByDefault = false,
|
||||
}: SchemaFormRendererProps) {
|
||||
const [showAdvancedFields, setShowAdvancedFields] = useState(
|
||||
showCollapsedByDefault
|
||||
);
|
||||
|
||||
const properties = schema.properties || {};
|
||||
const allFieldNames = Object.keys(properties).filter(
|
||||
(name) => !hideFields.includes(name)
|
||||
);
|
||||
const requiredFields = (schema.required || []).filter(
|
||||
(name) => !hideFields.includes(name)
|
||||
);
|
||||
|
||||
// Detect ChatMessage pattern
|
||||
const isChatMessageLike = detectChatMessagePattern(schema, requiredFields);
|
||||
|
||||
// Separate required and optional fields
|
||||
const requiredFieldNames = allFieldNames.filter(
|
||||
(name) =>
|
||||
requiredFields.includes(name) && !(isChatMessageLike && name === "role")
|
||||
);
|
||||
|
||||
const optionalFieldNames = allFieldNames.filter(
|
||||
(name) => !requiredFields.includes(name)
|
||||
);
|
||||
|
||||
// For ChatMessage: prioritize text/message/content
|
||||
const sortedOptionalFields = isChatMessageLike
|
||||
? [...optionalFieldNames].sort((a, b) => {
|
||||
const priority = (name: string) =>
|
||||
["text", "message", "content"].includes(name) ? 1 : 0;
|
||||
return priority(b) - priority(a);
|
||||
})
|
||||
: optionalFieldNames;
|
||||
|
||||
// Show minimum visible fields
|
||||
const MIN_VISIBLE_FIELDS = isChatMessageLike ? 1 : 6;
|
||||
const visibleOptionalCount = Math.max(
|
||||
0,
|
||||
MIN_VISIBLE_FIELDS - requiredFieldNames.length
|
||||
);
|
||||
const visibleOptionalFields = sortedOptionalFields.slice(
|
||||
0,
|
||||
visibleOptionalCount
|
||||
);
|
||||
const collapsedOptionalFields = sortedOptionalFields.slice(
|
||||
visibleOptionalCount
|
||||
);
|
||||
|
||||
const hasCollapsedFields = collapsedOptionalFields.length > 0;
|
||||
const hasRequiredFields = requiredFieldNames.length > 0;
|
||||
|
||||
const updateField = (fieldName: string, value: unknown) => {
|
||||
onChange({
|
||||
...values,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
|
||||
{/* Required fields section */}
|
||||
{requiredFieldNames.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={values[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={true}
|
||||
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Separator between required and optional */}
|
||||
{hasRequiredFields && optionalFieldNames.length > 0 && (
|
||||
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||
<div className="border-t border-border"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visible optional fields */}
|
||||
{visibleOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={values[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Collapsed optional fields toggle */}
|
||||
{hasCollapsedFields && (
|
||||
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
|
||||
className="w-full justify-center gap-2"
|
||||
disabled={disabled}
|
||||
>
|
||||
{showAdvancedFields ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
Hide {collapsedOptionalFields.length} optional field
|
||||
{collapsedOptionalFields.length !== 1 ? "s" : ""}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
Show {collapsedOptionalFields.length} optional field
|
||||
{collapsedOptionalFields.length !== 1 ? "s" : ""}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed optional fields */}
|
||||
{showAdvancedFields &&
|
||||
collapsedOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={values[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export helper functions for validation
|
||||
// ============================================================================
|
||||
|
||||
export function validateSchemaForm(
|
||||
schema: JSONSchemaProperty,
|
||||
values: Record<string, unknown>
|
||||
): boolean {
|
||||
const requiredFields = schema.required || [];
|
||||
|
||||
return requiredFields.every((fieldName) => {
|
||||
const value = values[fieldName];
|
||||
return value !== undefined && value !== "" && value !== null;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterEmptyOptionalFields(
|
||||
schema: JSONSchemaProperty,
|
||||
values: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const requiredFields = schema.required || [];
|
||||
const filtered: Record<string, unknown> = {};
|
||||
|
||||
Object.keys(values).forEach((key) => {
|
||||
const value = values[key];
|
||||
// Include if: 1) required field, OR 2) has non-empty value
|
||||
if (
|
||||
requiredFields.includes(key) ||
|
||||
(value !== undefined && value !== "" && value !== null)
|
||||
) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Shuffle,
|
||||
Zap,
|
||||
ArrowDown,
|
||||
ArrowLeftRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
processWorkflowEvents,
|
||||
updateNodesWithEvents,
|
||||
updateEdgesWithSequenceAnalysis,
|
||||
consolidateBidirectionalEdges,
|
||||
type NodeUpdate,
|
||||
} from "@/utils/workflow-utils";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
@@ -59,7 +61,7 @@ function ViewOptionsPanel({
|
||||
}: {
|
||||
workflowDump?: Workflow;
|
||||
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
|
||||
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean; consolidateBidirectionalEdges: boolean };
|
||||
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
|
||||
layoutDirection: "LR" | "TB";
|
||||
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
|
||||
@@ -134,6 +136,16 @@ function ViewOptionsPanel({
|
||||
</div>
|
||||
<Checkbox checked={viewOptions.animateRun} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => onToggleViewOption?.("consolidateBidirectionalEdges")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<ArrowLeftRight className="mr-2 h-4 w-4" />
|
||||
Merge Bidirectional Edges
|
||||
</div>
|
||||
<Checkbox checked={viewOptions.consolidateBidirectionalEdges} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
@@ -192,12 +204,14 @@ interface WorkflowFlowProps {
|
||||
showMinimap: boolean;
|
||||
showGrid: boolean;
|
||||
animateRun: boolean;
|
||||
consolidateBidirectionalEdges: boolean;
|
||||
};
|
||||
onToggleViewOption?: (
|
||||
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
|
||||
) => void;
|
||||
layoutDirection?: "LR" | "TB";
|
||||
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
|
||||
timelineVisible?: boolean;
|
||||
}
|
||||
|
||||
// Animation handler component that runs inside ReactFlow context
|
||||
@@ -248,16 +262,35 @@ function WorkflowAnimationHandler({
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
|
||||
// Timeline resize handler component that runs inside ReactFlow context
|
||||
const TimelineResizeHandler = memo(({ timelineVisible }: { timelineVisible: boolean }) => {
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Trigger fitView when timeline visibility changes to adjust ReactFlow viewport
|
||||
useEffect(() => {
|
||||
// Delay fitView to let CSS transition complete (timeline animation is 300ms)
|
||||
const timeoutId = setTimeout(() => {
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
}, 350); // Slightly longer than timeline animation duration
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timelineVisible]); // Only trigger when timelineVisible changes, not fitView reference
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
});
|
||||
|
||||
export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
workflowDump,
|
||||
events,
|
||||
isStreaming,
|
||||
onNodeSelect,
|
||||
className = "",
|
||||
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
|
||||
viewOptions = { showMinimap: false, showGrid: true, animateRun: true, consolidateBidirectionalEdges: true },
|
||||
onToggleViewOption,
|
||||
layoutDirection = "LR",
|
||||
onLayoutDirectionChange,
|
||||
timelineVisible = false,
|
||||
}: WorkflowFlowProps) {
|
||||
// Create initial nodes and edges from workflow dump
|
||||
const { initialNodes, initialEdges } = useMemo(() => {
|
||||
@@ -272,17 +305,22 @@ export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
);
|
||||
const edges = convertWorkflowDumpToEdges(workflowDump);
|
||||
|
||||
// Apply bidirectional edge consolidation if enabled
|
||||
const finalEdges = viewOptions.consolidateBidirectionalEdges
|
||||
? consolidateBidirectionalEdges(edges)
|
||||
: edges;
|
||||
|
||||
// Apply auto-layout if we have nodes and edges
|
||||
const layoutedNodes =
|
||||
nodes.length > 0
|
||||
? applyDagreLayout(nodes, edges, layoutDirection)
|
||||
? applyDagreLayout(nodes, finalEdges, layoutDirection)
|
||||
: nodes;
|
||||
|
||||
return {
|
||||
initialNodes: layoutedNodes,
|
||||
initialEdges: edges,
|
||||
initialEdges: finalEdges,
|
||||
};
|
||||
}, [workflowDump, onNodeSelect, layoutDirection]);
|
||||
}, [workflowDump, onNodeSelect, layoutDirection, viewOptions.consolidateBidirectionalEdges]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] =
|
||||
useNodesState<Node<ExecutorNodeData>>(initialNodes);
|
||||
@@ -323,31 +361,38 @@ export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
currentEdges,
|
||||
events
|
||||
);
|
||||
return updatedEdges;
|
||||
// Apply consolidation if enabled (preserves updated styling from sequence analysis)
|
||||
return viewOptions.consolidateBidirectionalEdges
|
||||
? consolidateBidirectionalEdges(updatedEdges)
|
||||
: updatedEdges;
|
||||
});
|
||||
} else {
|
||||
// Reset all edges to default state when events are cleared
|
||||
setEdges((currentEdges) =>
|
||||
currentEdges.map((edge) => ({
|
||||
setEdges((currentEdges) => {
|
||||
const resetEdges = currentEdges.map((edge) => ({
|
||||
...edge,
|
||||
animated: false,
|
||||
style: {
|
||||
stroke: "#6b7280", // Gray
|
||||
strokeWidth: 2,
|
||||
},
|
||||
}))
|
||||
);
|
||||
}));
|
||||
// Apply consolidation if enabled
|
||||
return viewOptions.consolidateBidirectionalEdges
|
||||
? consolidateBidirectionalEdges(resetEdges)
|
||||
: resetEdges;
|
||||
});
|
||||
}
|
||||
}, [events, setEdges]);
|
||||
}, [events, setEdges, viewOptions.consolidateBidirectionalEdges]);
|
||||
|
||||
// Initialize nodes only when workflow structure changes (not on state updates)
|
||||
// Initialize nodes and edges when workflow structure OR consolidation setting changes
|
||||
useEffect(() => {
|
||||
if (initialNodes.length > 0) {
|
||||
setNodes(initialNodes);
|
||||
setEdges(initialEdges);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workflowDump]); // Only re-initialize when workflowDump changes
|
||||
}, [workflowDump, viewOptions.consolidateBidirectionalEdges]); // Re-initialize when workflow or consolidation toggle changes
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
|
||||
@@ -467,6 +512,7 @@ export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
isStreaming={isStreaming}
|
||||
animateRun={viewOptions.animateRun}
|
||||
/>
|
||||
<TimelineResizeHandler timelineVisible={timelineVisible} />
|
||||
<ViewOptionsPanel
|
||||
workflowDump={workflowDump}
|
||||
onNodeSelect={onNodeSelect}
|
||||
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Workflow Conversation Manager Component
|
||||
* Handles conversation selection, creation, and deletion for workflow executions
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { useDevUIStore } from "@/stores/devuiStore";
|
||||
import { apiClient } from "@/services/api";
|
||||
import { Trash2, Plus, Clock } from "lucide-react";
|
||||
import type { WorkflowSession } from "@/types";
|
||||
|
||||
interface WorkflowSessionManagerProps {
|
||||
workflowId: string;
|
||||
onSessionChange?: (session: WorkflowSession | undefined) => void;
|
||||
}
|
||||
|
||||
export const WorkflowSessionManager: React.FC<WorkflowSessionManagerProps> = ({
|
||||
workflowId,
|
||||
onSessionChange,
|
||||
}) => {
|
||||
// Use individual selectors to avoid creating new objects on every render
|
||||
const currentSession = useDevUIStore((state) => state.currentSession);
|
||||
const availableSessions = useDevUIStore((state) => state.availableSessions);
|
||||
const loadingSessions = useDevUIStore((state) => state.loadingSessions);
|
||||
const setCurrentSession = useDevUIStore((state) => state.setCurrentSession);
|
||||
const setAvailableSessions = useDevUIStore((state) => state.setAvailableSessions);
|
||||
const setLoadingSessions = useDevUIStore((state) => state.setLoadingSessions);
|
||||
const addSession = useDevUIStore((state) => state.addSession);
|
||||
const removeSession = useDevUIStore((state) => state.removeSession);
|
||||
const addToast = useDevUIStore((state) => state.addToast);
|
||||
|
||||
const [creatingSession, setCreatingSession] = useState(false);
|
||||
const [deletingSession, setDeletingSession] = useState<string | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
const response = await apiClient.listWorkflowSessions(workflowId);
|
||||
|
||||
// If no conversations exist, auto-create one (like agent conversations)
|
||||
if (response.data.length === 0) {
|
||||
console.log("No workflow conversations found, creating default conversation");
|
||||
const newSession = await apiClient.createWorkflowSession(workflowId, {
|
||||
name: `Conversation ${new Date().toLocaleString()}`,
|
||||
});
|
||||
setAvailableSessions([newSession]);
|
||||
setCurrentSession(newSession);
|
||||
onSessionChange?.(newSession);
|
||||
addToast({
|
||||
message: "Default conversation created",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
// Conversations exist - set available and auto-select the first one
|
||||
setAvailableSessions(response.data);
|
||||
|
||||
// Auto-select first conversation if no current selection
|
||||
if (!currentSession) {
|
||||
const firstSession = response.data[0];
|
||||
setCurrentSession(firstSession);
|
||||
onSessionChange?.(firstSession);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load workflow conversations:", error);
|
||||
addToast({
|
||||
message: "Failed to load workflow conversations",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}, [workflowId, currentSession, setLoadingSessions, setAvailableSessions, setCurrentSession, onSessionChange, addToast]);
|
||||
|
||||
// Load sessions on mount
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
setCreatingSession(true);
|
||||
try {
|
||||
const newSession = await apiClient.createWorkflowSession(workflowId, {
|
||||
name: `Conversation ${new Date().toLocaleString()}`,
|
||||
});
|
||||
addSession(newSession);
|
||||
setCurrentSession(newSession);
|
||||
onSessionChange?.(newSession);
|
||||
addToast({
|
||||
message: "New conversation created",
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create conversation:", error);
|
||||
addToast({
|
||||
message: "Failed to create conversation",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setCreatingSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSession = (session: WorkflowSession) => {
|
||||
setCurrentSession(session);
|
||||
onSessionChange?.(session);
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (
|
||||
sessionId: string,
|
||||
event: React.MouseEvent
|
||||
) => {
|
||||
event.stopPropagation(); // Prevent session selection when clicking delete
|
||||
|
||||
if (!confirm("Delete this conversation? All checkpoints will be lost.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingSession(sessionId);
|
||||
try {
|
||||
await apiClient.deleteWorkflowSession(workflowId, sessionId);
|
||||
removeSession(sessionId);
|
||||
addToast({
|
||||
message: "Conversation deleted",
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete conversation:", error);
|
||||
addToast({
|
||||
message: "Failed to delete conversation",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setDeletingSession(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
if (loadingSessions) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full" />
|
||||
<span className="ml-2 text-sm text-gray-600">Loading sessions...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workflow-session-manager space-y-3">
|
||||
{/* Header with Create Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Conversations
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCreateSession}
|
||||
disabled={creatingSession}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Create new conversation"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Conversation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Conversation List */}
|
||||
{availableSessions.length === 0 ? (
|
||||
<div className="text-center py-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading conversations...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{availableSessions.map((session) => (
|
||||
<div
|
||||
key={session.conversation_id}
|
||||
onClick={() => handleSelectSession(session)}
|
||||
className={`
|
||||
flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-all
|
||||
${
|
||||
currentSession?.conversation_id === session.conversation_id
|
||||
? "bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700"
|
||||
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{session.metadata.name || "Unnamed Conversation"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatTimestamp(session.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleDeleteSession(session.conversation_id, e)}
|
||||
disabled={deletingSession === session.conversation_id}
|
||||
className="ml-3 p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50"
|
||||
title="Delete conversation"
|
||||
>
|
||||
{deletingSession === session.conversation_id ? (
|
||||
<div className="animate-spin h-4 w-4 border-2 border-red-500 border-t-transparent rounded-full" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+950
-662
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,17 @@
|
||||
*/
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EntitySelector } from "./entity-selector";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { Settings } from "lucide-react";
|
||||
import { Settings, Zap } from "lucide-react";
|
||||
import type { AgentInfo, WorkflowInfo } from "@/types";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
interface AppHeaderProps {
|
||||
agents: AgentInfo[];
|
||||
workflows: WorkflowInfo[];
|
||||
entities?: (AgentInfo | WorkflowInfo)[];
|
||||
selectedItem?: AgentInfo | WorkflowInfo;
|
||||
onSelect: (item: AgentInfo | WorkflowInfo) => void;
|
||||
onBrowseGallery?: () => void;
|
||||
@@ -22,12 +25,15 @@ interface AppHeaderProps {
|
||||
export function AppHeader({
|
||||
agents,
|
||||
workflows,
|
||||
entities,
|
||||
selectedItem,
|
||||
onSelect,
|
||||
onBrowseGallery,
|
||||
isLoading = false,
|
||||
onSettingsClick,
|
||||
}: AppHeaderProps) {
|
||||
const { oaiMode } = useDevUIStore();
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center gap-4 border-b px-4">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
@@ -58,15 +64,29 @@ export function AppHeader({
|
||||
</defs>
|
||||
</svg>
|
||||
Dev UI
|
||||
{/* Mode Badge */}
|
||||
{oaiMode.enabled && (
|
||||
<Badge variant="secondary" className="gap-1 ml-2">
|
||||
<Zap className="h-3 w-3" />
|
||||
OpenAI: {oaiMode.model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<EntitySelector
|
||||
agents={agents}
|
||||
workflows={workflows}
|
||||
selectedItem={selectedItem}
|
||||
onSelect={onSelect}
|
||||
onBrowseGallery={onBrowseGallery}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Show entity selector only when NOT in OAI mode */}
|
||||
{!oaiMode.enabled && (
|
||||
<EntitySelector
|
||||
agents={agents}
|
||||
workflows={workflows}
|
||||
entities={entities}
|
||||
selectedItem={selectedItem}
|
||||
onSelect={onSelect}
|
||||
onBrowseGallery={onBrowseGallery}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<ModeToggle />
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Info,
|
||||
PanelRightClose,
|
||||
} from "lucide-react";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
|
||||
@@ -95,7 +94,7 @@ interface TraceEventData extends EventDataBase {
|
||||
interface DebugPanelProps {
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
isStreaming?: boolean;
|
||||
onClose?: () => void;
|
||||
onMinimize?: () => void;
|
||||
}
|
||||
|
||||
// Helper: Extract function result from DevUI custom event
|
||||
@@ -116,39 +115,6 @@ function getFunctionResultFromEvent(event: ExtendedResponseStreamEvent): {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to get a stable timestamp for an event
|
||||
// Uses event's own timestamp fields if available
|
||||
function getEventTimestamp(event: ExtendedResponseStreamEvent): string {
|
||||
// Priority 1: Check for top-level timestamp (DevUI custom events like function_result.complete)
|
||||
if ('timestamp' in event && typeof event.timestamp === 'string') {
|
||||
return new Date(event.timestamp).toLocaleTimeString();
|
||||
}
|
||||
|
||||
// Priority 2: Check for nested data.timestamp (workflow/trace events)
|
||||
if ('data' in event && event.data && typeof event.data === 'object' && 'timestamp' in event.data) {
|
||||
const dataTimestamp = (event.data as any).timestamp;
|
||||
if (typeof dataTimestamp === 'string') {
|
||||
return new Date(dataTimestamp).toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Check for created_at in response object (lifecycle events)
|
||||
if ('response' in event && event.response && typeof event.response === 'object' && 'created_at' in event.response) {
|
||||
const createdAt = (event.response as any).created_at;
|
||||
if (typeof createdAt === 'number') {
|
||||
return new Date(createdAt * 1000).toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use sequence number as label (better than showing same time for all)
|
||||
if ('sequence_number' in event && typeof event.sequence_number === 'number') {
|
||||
return `#${event.sequence_number}`;
|
||||
}
|
||||
|
||||
// Last resort: hide timestamp by returning empty string
|
||||
return '';
|
||||
}
|
||||
|
||||
// Helper function to accumulate OpenAI events into meaningful units
|
||||
function processEventsForDisplay(
|
||||
events: ExtendedResponseStreamEvent[]
|
||||
@@ -170,8 +136,8 @@ function processEventsForDisplay(
|
||||
for (const event of events) {
|
||||
// Skip trace events - they belong in the Traces tab only
|
||||
if (
|
||||
event.type === "response.trace_event.complete" ||
|
||||
event.type === "response.trace.complete"
|
||||
event.type === "response.trace.completed" ||
|
||||
event.type === "response.trace.completed"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -212,9 +178,9 @@ function processEventsForDisplay(
|
||||
event.type === "response.completed" ||
|
||||
event.type === "response.done" ||
|
||||
event.type === "error" ||
|
||||
event.type === "response.workflow_event.complete" ||
|
||||
event.type === "response.trace_event.complete" ||
|
||||
event.type === "response.trace.complete" ||
|
||||
event.type === "response.workflow_event.completed" ||
|
||||
event.type === "response.trace.completed" ||
|
||||
event.type === "response.trace.completed" ||
|
||||
isFunctionResult
|
||||
) {
|
||||
// Flush any accumulated text before showing these events
|
||||
@@ -228,8 +194,8 @@ function processEventsForDisplay(
|
||||
|
||||
// Extract function names from trace events
|
||||
if (
|
||||
(event.type === "response.trace_event.complete" ||
|
||||
event.type === "response.trace.complete") &&
|
||||
(event.type === "response.trace.completed" ||
|
||||
event.type === "response.trace.completed") &&
|
||||
"data" in event
|
||||
) {
|
||||
const traceData = event.data as TraceEventData;
|
||||
@@ -483,15 +449,14 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
|
||||
return "Output item added";
|
||||
}
|
||||
|
||||
case "response.workflow_event.complete":
|
||||
case "response.workflow_event.completed":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as WorkflowEventData;
|
||||
return `Executor: ${data.executor_id || "unknown"}`;
|
||||
}
|
||||
return "Workflow event";
|
||||
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
case "response.trace.completed":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as TraceEventData;
|
||||
return `Trace: ${data.operation_name || "unknown"}`;
|
||||
@@ -536,10 +501,9 @@ function getEventIcon(type: string) {
|
||||
return CheckCircle2;
|
||||
case "response.output_item.added":
|
||||
return CheckCircle2;
|
||||
case "response.workflow_event.complete":
|
||||
case "response.workflow_event.completed":
|
||||
return Activity;
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
case "response.trace.completed":
|
||||
return Search;
|
||||
case "response.completed":
|
||||
return CheckCircle2;
|
||||
@@ -564,10 +528,9 @@ function getEventColor(type: string) {
|
||||
return "text-green-600 dark:text-green-400";
|
||||
case "response.output_item.added":
|
||||
return "text-green-600 dark:text-green-400";
|
||||
case "response.workflow_event.complete":
|
||||
case "response.workflow_event.completed":
|
||||
return "text-purple-600 dark:text-purple-400";
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
case "response.trace.completed":
|
||||
return "text-orange-600 dark:text-orange-400";
|
||||
case "response.completed":
|
||||
return "text-green-600 dark:text-green-400";
|
||||
@@ -582,9 +545,15 @@ function getEventColor(type: string) {
|
||||
|
||||
function EventItem({ event }: EventItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const Icon = getEventIcon(event.type);
|
||||
const colorClass = getEventColor(event.type);
|
||||
const timestamp = getEventTimestamp(event);
|
||||
const eventType = event.type || "unknown";
|
||||
const Icon = getEventIcon(eventType);
|
||||
const colorClass = getEventColor(eventType);
|
||||
|
||||
// Use stored UI timestamp if available, otherwise compute from event data
|
||||
const timestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
|
||||
? new Date(event._uiTimestamp * 1000).toLocaleTimeString()
|
||||
: new Date().toLocaleTimeString();
|
||||
|
||||
const summary = getEventSummary(event);
|
||||
|
||||
// Determine if this event has expandable content
|
||||
@@ -595,13 +564,13 @@ function EventItem({ event }: EventItemProps) {
|
||||
event.type === "response.function_result.complete" ||
|
||||
(event.type === "response.output_item.added" &&
|
||||
getFunctionResultFromEvent(event) !== null) ||
|
||||
(event.type === "response.workflow_event.complete" &&
|
||||
(event.type === "response.workflow_event.completed" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
(event.type === "response.trace_event.complete" &&
|
||||
(event.type === "response.trace.completed" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
(event.type === "response.trace.complete" &&
|
||||
(event.type === "response.trace.completed" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
(event.type === "response.output_text.delta" &&
|
||||
@@ -620,7 +589,7 @@ function EventItem({ event }: EventItemProps) {
|
||||
<Icon className={`h-3 w-3 ${colorClass}`} />
|
||||
<span className="font-mono">{timestamp}</span>
|
||||
<Badge variant="outline" className="text-xs py-0">
|
||||
{event.type.replace("response.", "")}
|
||||
{event.type ? event.type.replace("response.", "") : "unknown"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -859,7 +828,7 @@ function EventExpandedContent({
|
||||
break;
|
||||
}
|
||||
|
||||
case "response.workflow_event.complete":
|
||||
case "response.workflow_event.completed":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as WorkflowEventData;
|
||||
return (
|
||||
@@ -915,8 +884,7 @@ function EventExpandedContent({
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
case "response.trace.completed":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as TraceEventData;
|
||||
return (
|
||||
@@ -1193,8 +1161,8 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
// ONLY show actual trace events - handle both event type formats
|
||||
const traceEvents = events.filter(
|
||||
(e) =>
|
||||
e.type === "response.trace_event.complete" ||
|
||||
e.type === "response.trace.complete"
|
||||
e.type === "response.trace.completed" ||
|
||||
e.type === "response.trace.completed"
|
||||
);
|
||||
|
||||
// Add separators between message rounds
|
||||
@@ -1253,8 +1221,8 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (
|
||||
(event.type !== "response.trace_event.complete" &&
|
||||
event.type !== "response.trace.complete") ||
|
||||
(event.type !== "response.trace.completed" &&
|
||||
event.type !== "response.trace.completed") ||
|
||||
!("data" in event)
|
||||
) {
|
||||
return (
|
||||
@@ -1266,14 +1234,19 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
|
||||
const data = event.data as TraceEventData;
|
||||
|
||||
// Use actual trace timestamp if available, fallback to current time
|
||||
let timestamp = new Date().toLocaleTimeString();
|
||||
if (data.end_time) {
|
||||
// Use stored UI timestamp first, then trace timestamps, then fallback to current time
|
||||
let timestamp: string;
|
||||
if ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number') {
|
||||
// Use stored UI timestamp from when event was received
|
||||
timestamp = new Date(event._uiTimestamp * 1000).toLocaleTimeString();
|
||||
} else if (data.end_time) {
|
||||
timestamp = new Date(data.end_time * 1000).toLocaleTimeString();
|
||||
} else if (data.start_time) {
|
||||
timestamp = new Date(data.start_time * 1000).toLocaleTimeString();
|
||||
} else if (data.timestamp) {
|
||||
timestamp = new Date(data.timestamp).toLocaleTimeString();
|
||||
} else {
|
||||
timestamp = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
const operationName = data.operation_name || "Unknown Operation";
|
||||
@@ -1520,7 +1493,10 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
}
|
||||
|
||||
function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
const timestamp = getEventTimestamp(event);
|
||||
// Use stored UI timestamp if available, otherwise compute from current time
|
||||
const timestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
|
||||
? new Date(event._uiTimestamp * 1000).toLocaleTimeString()
|
||||
: new Date().toLocaleTimeString();
|
||||
|
||||
// Check if this is a function call or result event
|
||||
const isFunctionCall = event.type === "response.function_call.complete";
|
||||
@@ -1621,7 +1597,7 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
export function DebugPanel({
|
||||
events,
|
||||
isStreaming = false,
|
||||
onClose,
|
||||
onMinimize,
|
||||
}: DebugPanelProps) {
|
||||
return (
|
||||
<div className="flex-1 border-l flex flex-col min-h-0">
|
||||
@@ -1638,15 +1614,15 @@ export function DebugPanel({
|
||||
Tools
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{onClose && (
|
||||
{onMinimize && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
onClick={onMinimize}
|
||||
className="h-8 w-8 p-0 flex-shrink-0"
|
||||
title="Hide debug panel"
|
||||
title="Minimize debug panel"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,12 +20,18 @@ import {
|
||||
Copy,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type { AgentInfo, WorkflowInfo } from "@/types";
|
||||
|
||||
interface DeploymentModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
agentName?: string;
|
||||
entity?: AgentInfo | WorkflowInfo;
|
||||
}
|
||||
|
||||
type Tab = "docker" | "azure";
|
||||
@@ -34,10 +40,108 @@ export function DeploymentModal({
|
||||
open,
|
||||
onClose,
|
||||
agentName = "Agent",
|
||||
entity,
|
||||
}: DeploymentModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("docker");
|
||||
// Get the Azure deployment feature flag from store
|
||||
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
|
||||
|
||||
// Check if deployment is truly supported (both feature flag and backend support)
|
||||
const deploymentSupported = azureDeploymentEnabled && (entity?.deployment_supported ?? false);
|
||||
|
||||
// Context-aware tab ordering: Azure first if deployable, Docker first otherwise
|
||||
const [activeTab, setActiveTab] = useState<Tab>(
|
||||
deploymentSupported ? "azure" : "docker"
|
||||
);
|
||||
const [copiedTemplate, setCopiedTemplate] = useState<string | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const logsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Deployment state from Zustand
|
||||
const isDeploying = useDevUIStore((state) => state.isDeploying);
|
||||
const deploymentLogs = useDevUIStore((state) => state.deploymentLogs);
|
||||
const lastDeployment = useDevUIStore((state) => state.lastDeployment);
|
||||
const startDeployment = useDevUIStore((state) => state.startDeployment);
|
||||
const addDeploymentLog = useDevUIStore((state) => state.addDeploymentLog);
|
||||
const setDeploymentResult = useDevUIStore((state) => state.setDeploymentResult);
|
||||
const stopDeployment = useDevUIStore((state) => state.stopDeployment);
|
||||
const clearDeploymentState = useDevUIStore((state) => state.clearDeploymentState);
|
||||
|
||||
// Generate Azure-compliant default app name from entity name
|
||||
const generateDefaultAppName = (entityName: string) => {
|
||||
// Convert to lowercase, replace spaces and underscores with hyphens
|
||||
// Remove any non-alphanumeric characters except hyphens
|
||||
// Ensure it starts with a letter and is under 32 chars
|
||||
const cleaned = entityName
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, '-') // Replace underscores and spaces with hyphens
|
||||
.replace(/[^a-z0-9-]/g, '') // Remove any other special characters
|
||||
.replace(/--+/g, '-') // Replace multiple hyphens with single
|
||||
.replace(/^[^a-z]+/, '') // Remove non-letter prefix
|
||||
.replace(/-$/, ''); // Remove trailing hyphen
|
||||
|
||||
// Ensure it starts with a letter, add 'app-' prefix if needed
|
||||
const withPrefix = cleaned.match(/^[a-z]/) ? cleaned : `app-${cleaned}`;
|
||||
|
||||
// Truncate to 31 chars max (32 limit)
|
||||
return withPrefix.substring(0, 31);
|
||||
};
|
||||
|
||||
// Form state for deployment with smart defaults
|
||||
const defaultAppName = entity ? generateDefaultAppName(entity.id) : "";
|
||||
const [resourceGroup, setResourceGroup] = useState("my-test-rg");
|
||||
const [appName, setAppName] = useState(defaultAppName);
|
||||
const [region, setRegion] = useState("eastus");
|
||||
const [appNameError, setAppNameError] = useState<string | null>(null);
|
||||
|
||||
// Update app name when entity changes or modal opens
|
||||
useEffect(() => {
|
||||
if (entity) {
|
||||
const newDefaultName = generateDefaultAppName(entity.id);
|
||||
setAppName(newDefaultName);
|
||||
// Validate the default name
|
||||
const error = validateAppName(newDefaultName);
|
||||
setAppNameError(error);
|
||||
}
|
||||
}, [entity?.id]); // Only re-run when entity ID changes
|
||||
|
||||
// Auto-scroll deployment logs to bottom when new logs are added
|
||||
useEffect(() => {
|
||||
if (logsContainerRef.current && deploymentLogs.length > 0) {
|
||||
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [deploymentLogs]);
|
||||
|
||||
// Validate Azure Container App name
|
||||
const validateAppName = (name: string): string | null => {
|
||||
if (!name) return null; // Don't show error for empty field
|
||||
|
||||
// Check length
|
||||
if (name.length >= 32) {
|
||||
return "App name must be less than 32 characters";
|
||||
}
|
||||
|
||||
// Check for valid characters (lowercase alphanumeric and hyphens only)
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
return "App name must contain only lowercase letters, numbers, and hyphens (no underscores or uppercase)";
|
||||
}
|
||||
|
||||
// Must start with a letter
|
||||
if (!/^[a-z]/.test(name)) {
|
||||
return "App name must start with a lowercase letter";
|
||||
}
|
||||
|
||||
// Must end with alphanumeric
|
||||
if (!/[a-z0-9]$/.test(name)) {
|
||||
return "App name must end with a letter or number";
|
||||
}
|
||||
|
||||
// Cannot have double hyphens
|
||||
if (name.includes("--")) {
|
||||
return "App name cannot contain consecutive hyphens (--)";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
@@ -48,6 +152,48 @@ export function DeploymentModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!entity?.id || !resourceGroup || !appName) return;
|
||||
|
||||
// Trim whitespace from inputs
|
||||
const trimmedResourceGroup = resourceGroup.trim();
|
||||
const trimmedAppName = appName.trim();
|
||||
|
||||
// Validate trimmed app name before deployment
|
||||
const nameError = validateAppName(trimmedAppName);
|
||||
if (nameError) {
|
||||
setAppNameError(nameError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
startDeployment();
|
||||
|
||||
for await (const event of apiClient.streamDeployment({
|
||||
entity_id: entity.id,
|
||||
resource_group: trimmedResourceGroup,
|
||||
app_name: trimmedAppName,
|
||||
region,
|
||||
ui_mode: "user",
|
||||
})) {
|
||||
addDeploymentLog(event.message);
|
||||
|
||||
if (event.type === "deploy.completed" && event.url && event.auth_token) {
|
||||
setDeploymentResult({
|
||||
url: event.url,
|
||||
authToken: event.auth_token,
|
||||
});
|
||||
} else if (event.type === "deploy.failed") {
|
||||
// Stop deploying but keep logs visible
|
||||
stopDeployment();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addDeploymentLog(`Error: ${error instanceof Error ? error.message : "Deployment failed"}`);
|
||||
stopDeployment();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (template: string, templateName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(template);
|
||||
@@ -64,8 +210,7 @@ export function DeploymentModal({
|
||||
timeoutRef.current = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy template:", err);
|
||||
// Reset state on error
|
||||
// Reset state on error - clipboard write failed
|
||||
setCopiedTemplate(null);
|
||||
}
|
||||
};
|
||||
@@ -149,20 +294,22 @@ openai>=1.0.0
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("azure")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "azure"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Cloud className="h-4 w-4 mr-2 inline" />
|
||||
Azure
|
||||
{activeTab === "azure" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
{deploymentSupported && (
|
||||
<button
|
||||
onClick={() => setActiveTab("azure")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "azure"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Cloud className="h-4 w-4 mr-2 inline" />
|
||||
Azure
|
||||
{activeTab === "azure" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
@@ -360,34 +507,230 @@ openai>=1.0.0
|
||||
Deploy to Azure Container Apps
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Azure Container Apps provides serverless containers with
|
||||
auto-scaling and integrated monitoring.
|
||||
{deploymentSupported
|
||||
? "One-click deployment to Azure with automatic containerization and authentication."
|
||||
: "Azure Container Apps provides serverless containers with auto-scaling and integrated monitoring."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Prerequisites */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<h4 className="font-medium text-sm">Prerequisites</h4>
|
||||
<ul className="text-xs space-y-1 list-disc list-inside text-muted-foreground">
|
||||
<li>Azure subscription</li>
|
||||
<li>
|
||||
Azure CLI installed (
|
||||
<code className="bg-muted px-1 rounded">
|
||||
az --version
|
||||
</code>
|
||||
)
|
||||
</li>
|
||||
{/* Prerequisites Notice */}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
||||
<h4 className="text-sm font-semibold mb-2 text-blue-900 dark:text-blue-100">
|
||||
Prerequisites for Azure Deployment
|
||||
</h4>
|
||||
<ul className="text-xs space-y-1 list-disc list-inside text-blue-800 dark:text-blue-200">
|
||||
<li>Azure CLI installed and authenticated (<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">az login</code>)</li>
|
||||
<li>Docker installed and running</li>
|
||||
<li>
|
||||
Logged in to Azure:{" "}
|
||||
<code className="bg-muted px-1 rounded">az login</code>
|
||||
<li>Azure subscription with the following providers registered:
|
||||
<ul className="ml-4 mt-1 space-y-0.5">
|
||||
<li className="list-none">• <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.App</code> (Container Apps)</li>
|
||||
<li className="list-none">• <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.ContainerRegistry</code> (ACR)</li>
|
||||
<li className="list-none">• <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.OperationalInsights</code> (Logging)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs cursor-pointer hover:underline text-blue-700 dark:text-blue-300">
|
||||
How to register providers?
|
||||
</summary>
|
||||
<div className="mt-2 p-2 bg-blue-100 dark:bg-blue-900 rounded text-xs">
|
||||
<p className="mb-1">Run these commands once per subscription:</p>
|
||||
<code className="block font-mono">
|
||||
az provider register -n Microsoft.App --wait<br/>
|
||||
az provider register -n Microsoft.ContainerRegistry --wait<br/>
|
||||
az provider register -n Microsoft.OperationalInsights --wait
|
||||
</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Step-by-step */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Deployment Steps</h4>
|
||||
{/* Functional Deployment Form (only if supported) */}
|
||||
{deploymentSupported && entity && !lastDeployment && (
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
{!isDeploying ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Resource Group</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full mt-1 px-3 py-2 border rounded-md text-sm"
|
||||
placeholder="my-test-rg"
|
||||
value={resourceGroup}
|
||||
onChange={(e) => setResourceGroup(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">App Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`w-full mt-1 px-3 py-2 border rounded-md text-sm ${
|
||||
appNameError ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="my-agent-app"
|
||||
value={appName}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value;
|
||||
setAppName(newName);
|
||||
// Validate on change to provide immediate feedback
|
||||
// Trim for validation to match what will be sent
|
||||
const error = validateAppName(newName.trim());
|
||||
setAppNameError(error);
|
||||
}}
|
||||
/>
|
||||
{appNameError && (
|
||||
<p className="mt-1 text-xs text-red-600">{appNameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Region</label>
|
||||
<select
|
||||
className="w-full mt-1 px-3 py-2 border rounded-md text-sm"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
>
|
||||
<option value="eastus">East US</option>
|
||||
<option value="westus">West US</option>
|
||||
<option value="westeurope">West Europe</option>
|
||||
<option value="eastasia">East Asia</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDeploy}
|
||||
disabled={!resourceGroup || !appName || !!appNameError}
|
||||
className="w-full"
|
||||
>
|
||||
<Rocket className="h-4 w-4 mr-2" />
|
||||
Deploy to Azure
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Deploying...
|
||||
</div>
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="bg-muted p-3 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1"
|
||||
>
|
||||
{deploymentLogs.map((log, i) => (
|
||||
<div key={i} className={log.includes("failed") || log.includes("Error") ? "text-red-600" : ""}>{log}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show logs after deployment stops (success or failure) */}
|
||||
{!isDeploying && deploymentLogs.length > 0 && !lastDeployment && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-red-600">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Deployment Failed
|
||||
</div>
|
||||
<div className="bg-muted p-3 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1">
|
||||
{deploymentLogs.map((log, i) => (
|
||||
<div key={i} className={log.includes("failed") || log.includes("Error") ? "text-red-600" : ""}>{log}</div>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={clearDeploymentState} variant="outline" className="w-full">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Screen */}
|
||||
{lastDeployment && (
|
||||
<div className="border-2 border-green-200 bg-green-50 dark:bg-green-950/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<h4 className="font-semibold text-green-900 dark:text-green-100">
|
||||
Deployment Successful!
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-green-800 dark:text-green-200">
|
||||
Deployment URL
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<code className="flex-1 bg-white dark:bg-gray-900 px-3 py-2 rounded border text-sm">
|
||||
{lastDeployment.url}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.open(lastDeployment.url, "_blank")}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-green-800 dark:text-green-200">
|
||||
Auth Token (save this - shown only once)
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<code className="flex-1 bg-white dark:bg-gray-900 px-3 py-2 rounded border text-sm font-mono">
|
||||
{lastDeployment.authToken}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigator.clipboard.writeText(lastDeployment.authToken)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={clearDeploymentState} variant="outline" className="w-full">
|
||||
Deploy Another
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deployment Not Supported Warning */}
|
||||
{!deploymentSupported && entity?.deployment_reason && (
|
||||
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded-md p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||
<div className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<strong>Deployment not available:</strong> {entity.deployment_reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CLI Instructions (only show when deployment not supported) */}
|
||||
{!deploymentSupported && (
|
||||
<>
|
||||
{/* Prerequisites */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<h4 className="font-medium text-sm">Prerequisites</h4>
|
||||
<ul className="text-xs space-y-1 list-disc list-inside text-muted-foreground">
|
||||
<li>Azure subscription</li>
|
||||
<li>
|
||||
Azure CLI installed (
|
||||
<code className="bg-muted px-1 rounded">
|
||||
az --version
|
||||
</code>
|
||||
)
|
||||
</li>
|
||||
<li>Docker installed and running</li>
|
||||
<li>
|
||||
Logged in to Azure:{" "}
|
||||
<code className="bg-muted px-1 rounded">az login</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Step-by-step */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Deployment Steps</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Step 1 */}
|
||||
@@ -508,6 +851,8 @@ az acr build --registry myregistry \\
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { AgentInfo, WorkflowInfo } from "@/types";
|
||||
interface EntitySelectorProps {
|
||||
agents: AgentInfo[];
|
||||
workflows: WorkflowInfo[];
|
||||
entities?: (AgentInfo | WorkflowInfo)[]; // Full list in backend order
|
||||
selectedItem?: AgentInfo | WorkflowInfo;
|
||||
onSelect: (item: AgentInfo | WorkflowInfo) => void;
|
||||
onBrowseGallery?: () => void;
|
||||
@@ -33,6 +34,7 @@ const getTypeIcon = (type: "agent" | "workflow") => {
|
||||
export function EntitySelector({
|
||||
agents,
|
||||
workflows,
|
||||
entities,
|
||||
selectedItem,
|
||||
onSelect,
|
||||
onBrowseGallery,
|
||||
@@ -40,9 +42,8 @@ export function EntitySelector({
|
||||
}: 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)
|
||||
);
|
||||
// Use entities if provided (preserves backend order), otherwise combine agents and workflows
|
||||
const allItems = entities || [...agents, ...workflows];
|
||||
|
||||
const handleSelect = (item: AgentInfo | WorkflowInfo) => {
|
||||
onSelect(item);
|
||||
@@ -82,80 +83,125 @@ export function EntitySelector({
|
||||
</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 isAgentLoaded = agent.metadata?.lazy_loaded !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
className="cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 min-w-0 flex-1"
|
||||
onClick={() => handleSelect(agent)}
|
||||
>
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium block">
|
||||
{agent.name || agent.id}
|
||||
</span>
|
||||
{isAgentLoaded && agent.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{agent.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{/* Show items in backend order but with type grouping for clarity */}
|
||||
{(() => {
|
||||
// Group items by type while preserving order within each group
|
||||
const workflowItems = allItems.filter(item => item.type === "workflow");
|
||||
const agentItems = allItems.filter(item => item.type === "agent");
|
||||
|
||||
{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 isWorkflowLoaded = workflow.metadata?.lazy_loaded !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={workflow.id}
|
||||
className="cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 min-w-0 flex-1"
|
||||
onClick={() => handleSelect(workflow)}
|
||||
>
|
||||
<Workflow className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium block">
|
||||
{workflow.name || workflow.id}
|
||||
</span>
|
||||
{isWorkflowLoaded && workflow.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{workflow.description}
|
||||
// Determine which type appears first in backend order
|
||||
const firstItemType = allItems[0]?.type;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Show workflows first if they appear first, otherwise agents */}
|
||||
{firstItemType === "workflow" && workflowItems.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<Workflow className="h-4 w-4" />
|
||||
Workflows ({workflowItems.length})
|
||||
</DropdownMenuLabel>
|
||||
{workflowItems.map((item) => {
|
||||
const isLoaded = item.metadata?.lazy_loaded !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
className="cursor-pointer group"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Workflow className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium block">
|
||||
{item.name || item.id}
|
||||
</span>
|
||||
{isLoaded && item.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Separator if both types exist */}
|
||||
{workflowItems.length > 0 && agentItems.length > 0 && <DropdownMenuSeparator />}
|
||||
|
||||
{/* Agents section */}
|
||||
{agentItems.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
Agents ({agentItems.length})
|
||||
</DropdownMenuLabel>
|
||||
{agentItems.map((item) => {
|
||||
const isLoaded = item.metadata?.lazy_loaded !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
className="cursor-pointer group"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium block">
|
||||
{item.name || item.id}
|
||||
</span>
|
||||
{isLoaded && item.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show workflows last if agents appear first */}
|
||||
{firstItemType === "agent" && workflowItems.length > 0 && (
|
||||
<>
|
||||
{agentItems.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<Workflow className="h-4 w-4" />
|
||||
Workflows ({workflowItems.length})
|
||||
</DropdownMenuLabel>
|
||||
{workflowItems.map((item) => {
|
||||
const isLoaded = item.metadata?.lazy_loaded !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
className="cursor-pointer group"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Workflow className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium block">
|
||||
{item.name || item.id}
|
||||
</span>
|
||||
{isLoaded && item.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{allItems.length === 0 && (
|
||||
<DropdownMenuItem disabled>
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ExternalLink, RotateCcw } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ExternalLink, RotateCcw, Info, ChevronRight } from "lucide-react";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -21,10 +23,26 @@ interface SettingsModalProps {
|
||||
onBackendUrlChange?: (url: string) => void;
|
||||
}
|
||||
|
||||
type Tab = "about" | "settings";
|
||||
type Tab = "general" | "proxy" | "about";
|
||||
|
||||
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("settings");
|
||||
// Preset OpenAI models for quick selection
|
||||
const PRESET_MODELS = [
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"o1",
|
||||
"o1-mini",
|
||||
"o3-mini",
|
||||
] as const;
|
||||
|
||||
export function SettingsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onBackendUrlChange,
|
||||
}: SettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("general");
|
||||
|
||||
// OpenAI proxy mode, Azure deployment, and auth status from store
|
||||
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired } = useDevUIStore();
|
||||
|
||||
// Get current backend URL from localStorage or default
|
||||
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
|
||||
@@ -33,6 +51,10 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
});
|
||||
const [tempUrl, setTempUrl] = useState(backendUrl);
|
||||
|
||||
// Auth token state
|
||||
const [authTokenStored, setAuthTokenStored] = useState(!!localStorage.getItem("devui_auth_token"));
|
||||
const [newAuthToken, setNewAuthToken] = useState("");
|
||||
|
||||
const handleSave = () => {
|
||||
// Validate URL format
|
||||
try {
|
||||
@@ -59,30 +81,63 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleAuthTokenSave = () => {
|
||||
if (!newAuthToken.trim()) return;
|
||||
|
||||
localStorage.setItem("devui_auth_token", newAuthToken.trim());
|
||||
setAuthTokenStored(true);
|
||||
setNewAuthToken("");
|
||||
|
||||
// Reload to apply the auth token
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleClearAuthToken = () => {
|
||||
localStorage.removeItem("devui_auth_token");
|
||||
setAuthTokenStored(false);
|
||||
setNewAuthToken("");
|
||||
|
||||
// Reload to clear auth state
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const isModified = tempUrl !== backendUrl;
|
||||
const isDefault = !localStorage.getItem("devui_backend_url");
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[600px] max-w-[90vw]">
|
||||
<DialogHeader className="p-6 pb-2">
|
||||
<DialogContent className="w-[600px] max-w-[90vw] flex flex-col max-h-[85vh]">
|
||||
<DialogHeader className="p-6 pb-2 flex-shrink-0">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b px-6">
|
||||
<div className="flex border-b px-6 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveTab("settings")}
|
||||
onClick={() => setActiveTab("general")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "settings"
|
||||
activeTab === "general"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
{activeTab === "settings" && (
|
||||
General
|
||||
{activeTab === "general" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("proxy")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "proxy"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
OpenAI Proxy
|
||||
{activeTab === "proxy" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
@@ -101,9 +156,9 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="px-6 pb-6 min-h-[240px]">
|
||||
{activeTab === "settings" && (
|
||||
{/* Tab Content - Scrollable with min-height */}
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1 min-h-[400px]">
|
||||
{activeTab === "general" && (
|
||||
<div className="space-y-6 pt-4">
|
||||
{/* Backend URL Setting */}
|
||||
<div className="space-y-3">
|
||||
@@ -142,11 +197,7 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
<div className="flex gap-2 pt-2 min-h-[36px]">
|
||||
{isModified && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
<Button onClick={handleSave} size="sm" className="flex-1">
|
||||
Apply & Reload
|
||||
</Button>
|
||||
<Button
|
||||
@@ -161,6 +212,371 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Token Setting - Only show if backend requires auth OR token is already stored */}
|
||||
{(authRequired || authTokenStored) && (
|
||||
<div className="space-y-3 border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
Authentication Token
|
||||
</Label>
|
||||
{!authRequired && authTokenStored && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(Not required by current backend)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{authTokenStored ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value="••••••••••••••••••••"
|
||||
disabled
|
||||
className="font-mono text-sm flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleClearAuthToken}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
✓ Token configured and stored locally
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
value={newAuthToken}
|
||||
onChange={(e) => setNewAuthToken(e.target.value)}
|
||||
placeholder="Enter bearer token"
|
||||
className="font-mono text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newAuthToken.trim()) {
|
||||
handleAuthTokenSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAuthTokenSave}
|
||||
size="sm"
|
||||
disabled={!newAuthToken.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
Save & Reload
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{authRequired
|
||||
? "Required by backend (started with --auth flag)"
|
||||
: "Not required by current backend"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deployment Setting */}
|
||||
<div className="space-y-3 border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
Azure Deployment
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable one-click deployment to Azure Container Apps
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={azureDeploymentEnabled}
|
||||
onCheckedChange={setAzureDeploymentEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expandable info section */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||||
Learn more about Azure deployment
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3 pl-4">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
When enabled, agents that support deployment will show a "Deploy to Azure"
|
||||
button. This allows you to deploy your agent to Azure Container Apps directly
|
||||
from DevUI.
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium">When enabled:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
|
||||
<li>Shows "Deploy to Azure" for supported agents</li>
|
||||
<li>Requires Azure CLI and proper authentication</li>
|
||||
<li>Backend must have deployment capabilities enabled</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium">When disabled:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
|
||||
<li>Shows "Deployment Guide" for all agents</li>
|
||||
<li>Provides Docker templates and manual deployment instructions</li>
|
||||
<li>No backend deployment capabilities required</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "proxy" && (
|
||||
<div className="space-y-6 pt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">
|
||||
OpenAI Proxy Mode
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Route requests through DevUI backend to OpenAI API
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={oaiMode.enabled}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setOAIMode({ ...oaiMode, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info box when disabled - prominent */}
|
||||
{!oaiMode.enabled && (
|
||||
<div className="bordder border-muted bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
About OpenAI Proxy Mode
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
When enabled, your chat requests are sent to your
|
||||
DevUI backend{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
({backendUrl})
|
||||
</span>
|
||||
, which then forwards them to OpenAI's API. This keeps
|
||||
your{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
OPENAI_API_KEY
|
||||
</span>{" "}
|
||||
secure on the server instead of exposing it in the
|
||||
browser.
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-xs font-medium">Requirements:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
|
||||
<li>
|
||||
Backend must have{" "}
|
||||
<span className="font-mono">OPENAI_API_KEY</span>{" "}
|
||||
configured
|
||||
</li>
|
||||
<li>
|
||||
Backend must support OpenAI Responses API proxying
|
||||
(DevUI does)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-xs font-medium">Why use this?</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Quickly test and compare OpenAI models directly
|
||||
through the DevUI interface without creating custom
|
||||
agents or exposing API keys in the browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oaiMode.enabled && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||
{/* Model ID Input - Primary control */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Model</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={oaiMode.model}
|
||||
onChange={(e) =>
|
||||
setOAIMode({ ...oaiMode, model: e.target.value })
|
||||
}
|
||||
placeholder="gpt-4.1-mini"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter any OpenAI model ID (e.g., gpt-4.1, o1, o3-mini)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Preset Buttons */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Common presets
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_MODELS.map((model) => (
|
||||
<Button
|
||||
key={model}
|
||||
variant={
|
||||
oaiMode.model === model ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setOAIMode({ ...oaiMode, model })}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
{model}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Parameters */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||||
Advanced Parameters (optional)
|
||||
</summary>
|
||||
<div className="space-y-3 mt-3 pl-4">
|
||||
{/* Temperature */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Temperature</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={oaiMode.temperature ?? ""}
|
||||
onChange={(e) =>
|
||||
setOAIMode({
|
||||
...oaiMode,
|
||||
temperature: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="1.0 (default)"
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls randomness (0-2)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Max Output Tokens */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Max Output Tokens</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={oaiMode.max_output_tokens ?? ""}
|
||||
onChange={(e) =>
|
||||
setOAIMode({
|
||||
...oaiMode,
|
||||
max_output_tokens: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Auto"
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum tokens in response
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Top P */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Top P</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={oaiMode.top_p ?? ""}
|
||||
onChange={(e) =>
|
||||
setOAIMode({
|
||||
...oaiMode,
|
||||
top_p: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="1.0 (default)"
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nucleus sampling (0-1)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reasoning Effort */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Reasoning Effort (o-series models)</Label>
|
||||
<select
|
||||
value={oaiMode.reasoning_effort ?? ""}
|
||||
onChange={(e) =>
|
||||
setOAIMode({
|
||||
...oaiMode,
|
||||
reasoning_effort: e.target.value
|
||||
? (e.target.value as "minimal" | "low" | "medium" | "high")
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">Auto (default)</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Constrains reasoning effort (faster/cheaper vs thorough)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapsed info at bottom when enabled */}
|
||||
{oaiMode.enabled && (
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded">
|
||||
<Info className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
Requests route through{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
{backendUrl}
|
||||
</span>{" "}
|
||||
to OpenAI API. Server must have{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
OPENAI_API_KEY
|
||||
</span>{" "}
|
||||
configured.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -1,126 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AgentSource,
|
||||
Conversation,
|
||||
HealthResponse,
|
||||
MetaResponse,
|
||||
RunAgentRequest,
|
||||
RunWorkflowRequest,
|
||||
WorkflowInfo,
|
||||
@@ -32,6 +33,9 @@ interface BackendEntityInfo {
|
||||
tools?: (string | Record<string, unknown>)[];
|
||||
metadata: Record<string, unknown>;
|
||||
source?: string;
|
||||
// Deployment support
|
||||
deployment_supported?: boolean;
|
||||
deployment_reason?: string;
|
||||
// Agent-specific fields (present when type === "agent")
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
@@ -64,8 +68,8 @@ const DEFAULT_API_BASE_URL =
|
||||
: ""; // Default to relative URLs (same host as frontend)
|
||||
|
||||
// Retry configuration for streaming
|
||||
const RETRY_INTERVAL_MS = 1000; // Retry every second
|
||||
const MAX_RETRY_ATTEMPTS = 600; // Max 600 retries (10 minutes total)
|
||||
const RETRY_INTERVAL_MS = 1000; // Base retry interval (will use exponential backoff)
|
||||
const MAX_RETRY_ATTEMPTS = 10; // Max 10 retries (~30 seconds with exponential backoff)
|
||||
|
||||
// Get backend URL from localStorage or default
|
||||
function getBackendUrl(): string {
|
||||
@@ -82,9 +86,12 @@ function sleep(ms: number): Promise<void> {
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
private authToken: string | null = null;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl || getBackendUrl();
|
||||
// Load auth token from localStorage on initialization
|
||||
this.authToken = localStorage.getItem("devui_auth_token");
|
||||
}
|
||||
|
||||
// Allow updating the base URL at runtime
|
||||
@@ -96,27 +103,68 @@ class ApiClient {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
// Set auth token and persist to localStorage
|
||||
setAuthToken(token: string | null): void {
|
||||
this.authToken = token;
|
||||
if (token) {
|
||||
localStorage.setItem("devui_auth_token", token);
|
||||
} else {
|
||||
localStorage.removeItem("devui_auth_token");
|
||||
}
|
||||
}
|
||||
|
||||
// Get current auth token
|
||||
getAuthToken(): string | null {
|
||||
return this.authToken;
|
||||
}
|
||||
|
||||
// Clear auth token
|
||||
clearAuthToken(): void {
|
||||
this.setAuthToken(null);
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
// Build headers with auth token if available
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (this.authToken) {
|
||||
headers["Authorization"] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle 401 Unauthorized - clear invalid token
|
||||
if (response.status === 401) {
|
||||
this.clearAuthToken();
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
// Try to extract error message from response body
|
||||
let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
// Handle detail as string or object
|
||||
if (errorData.detail) {
|
||||
errorMessage = errorData.detail;
|
||||
if (typeof errorData.detail === "string") {
|
||||
errorMessage = errorData.detail;
|
||||
} else if (typeof errorData.detail === "object" && errorData.detail.error?.message) {
|
||||
// Backend returns detail: { error: { message: "...", type: "...", code: "..." } }
|
||||
errorMessage = errorData.detail.error.message;
|
||||
}
|
||||
} else if (errorData.error?.message) {
|
||||
errorMessage = errorData.error.message;
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, use default message
|
||||
@@ -132,6 +180,11 @@ class ApiClient {
|
||||
return this.request<HealthResponse>("/health");
|
||||
}
|
||||
|
||||
// Server metadata
|
||||
async getMeta(): Promise<MetaResponse> {
|
||||
return this.request<MetaResponse>("/meta");
|
||||
}
|
||||
|
||||
// Entity discovery using new unified endpoint
|
||||
async getEntities(): Promise<{
|
||||
entities: (AgentInfo | WorkflowInfo)[];
|
||||
@@ -140,17 +193,14 @@ class ApiClient {
|
||||
}> {
|
||||
const response = await this.request<DiscoveryResponse>("/v1/entities");
|
||||
|
||||
// Separate agents and workflows
|
||||
const agents: AgentInfo[] = [];
|
||||
const workflows: WorkflowInfo[] = [];
|
||||
|
||||
response.entities.forEach((entity) => {
|
||||
// Transform entities while preserving backend order
|
||||
const entities: (AgentInfo | WorkflowInfo)[] = response.entities.map((entity) => {
|
||||
if (entity.type === "agent") {
|
||||
agents.push({
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
type: "agent",
|
||||
type: "agent" as const,
|
||||
source: (entity.source as AgentSource) || "directory",
|
||||
tools: (entity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
@@ -161,22 +211,26 @@ class ApiClient {
|
||||
? entity.metadata.module_path
|
||||
: undefined,
|
||||
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
|
||||
// Deployment support
|
||||
deployment_supported: entity.deployment_supported,
|
||||
deployment_reason: entity.deployment_reason,
|
||||
// Agent-specific fields
|
||||
instructions: entity.instructions,
|
||||
model: entity.model,
|
||||
chat_client_type: entity.chat_client_type,
|
||||
context_providers: entity.context_providers,
|
||||
middleware: entity.middleware,
|
||||
});
|
||||
} else if (entity.type === "workflow") {
|
||||
};
|
||||
} else {
|
||||
// Workflow
|
||||
const firstTool = entity.tools?.[0];
|
||||
const startExecutorId = typeof firstTool === "string" ? firstTool : "";
|
||||
|
||||
workflows.push({
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
type: "workflow",
|
||||
type: "workflow" as const,
|
||||
source: (entity.source as AgentSource) || "directory",
|
||||
executors: (entity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
@@ -187,17 +241,24 @@ class ApiClient {
|
||||
? entity.metadata.module_path
|
||||
: undefined,
|
||||
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
|
||||
// Deployment support
|
||||
deployment_supported: entity.deployment_supported,
|
||||
deployment_reason: entity.deployment_reason,
|
||||
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 };
|
||||
// Create filtered arrays for backward compatibility
|
||||
const agents = entities.filter((e): e is AgentInfo => e.type === "agent");
|
||||
const workflows = entities.filter((e): e is WorkflowInfo => e.type === "workflow");
|
||||
|
||||
return { entities, agents, workflows };
|
||||
}
|
||||
|
||||
// Legacy methods for compatibility
|
||||
@@ -225,6 +286,16 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async reloadEntity(entityId: string): Promise<{ success: boolean; message: string }> {
|
||||
// Hot reload entity - clears cache and forces reimport on next access
|
||||
return this.request<{ success: boolean; message: string }>(
|
||||
`/v1/entities/${entityId}/reload`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Conversation Management (OpenAI Standard)
|
||||
// ========================================
|
||||
@@ -232,10 +303,23 @@ class ApiClient {
|
||||
async createConversation(
|
||||
metadata?: Record<string, string>
|
||||
): Promise<Conversation> {
|
||||
// Check if OAI proxy mode is enabled
|
||||
const { oaiMode } = await import("@/stores").then((m) => ({
|
||||
oaiMode: m.useDevUIStore.getState().oaiMode,
|
||||
}));
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Add proxy mode header if enabled
|
||||
if (oaiMode.enabled) {
|
||||
headers["X-Proxy-Backend"] = "openai";
|
||||
}
|
||||
|
||||
const response = await this.request<ConversationApiResponse>(
|
||||
"/v1/conversations",
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata }),
|
||||
}
|
||||
);
|
||||
@@ -315,6 +399,19 @@ class ApiClient {
|
||||
return this.request<{ data: unknown[]; has_more: boolean }>(url);
|
||||
}
|
||||
|
||||
async deleteConversationItem(
|
||||
conversationId: string,
|
||||
itemId: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/v1/conversations/${conversationId}/items/${itemId}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete item: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI-compatible streaming methods using /v1/responses endpoint
|
||||
|
||||
// Private helper method that handles the actual streaming with retry logic
|
||||
@@ -323,6 +420,35 @@ class ApiClient {
|
||||
conversationId?: string,
|
||||
resumeResponseId?: string
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
// Check if OpenAI proxy mode is enabled
|
||||
const { oaiMode } = await import("@/stores").then((m) => ({
|
||||
oaiMode: m.useDevUIStore.getState().oaiMode,
|
||||
}));
|
||||
|
||||
// Modify request if OAI mode is enabled
|
||||
if (oaiMode.enabled) {
|
||||
// Override model with OAI model
|
||||
openAIRequest.model = oaiMode.model;
|
||||
|
||||
// Merge optional OpenAI parameters
|
||||
if (oaiMode.temperature !== undefined) {
|
||||
openAIRequest.temperature = oaiMode.temperature;
|
||||
}
|
||||
if (oaiMode.max_output_tokens !== undefined) {
|
||||
openAIRequest.max_output_tokens = oaiMode.max_output_tokens;
|
||||
}
|
||||
if (oaiMode.top_p !== undefined) {
|
||||
openAIRequest.top_p = oaiMode.top_p;
|
||||
}
|
||||
if (oaiMode.instructions !== undefined) {
|
||||
openAIRequest.instructions = oaiMode.instructions;
|
||||
}
|
||||
// Reasoning parameters (for o-series models)
|
||||
if (oaiMode.reasoning_effort !== undefined) {
|
||||
openAIRequest.reasoning = { effort: oaiMode.reasoning_effort };
|
||||
}
|
||||
}
|
||||
|
||||
let lastSequenceNumber = -1;
|
||||
let retryCount = 0;
|
||||
let hasYieldedAnyEvent = false;
|
||||
@@ -367,26 +493,68 @@ class ApiClient {
|
||||
params.set("starting_after", lastSequenceNumber.toString());
|
||||
}
|
||||
const url = `${this.baseUrl}/v1/responses/${currentResponseId}?${params.toString()}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "text/event-stream",
|
||||
};
|
||||
|
||||
// Add auth token if available
|
||||
if (this.authToken) {
|
||||
headers["Authorization"] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
headers,
|
||||
});
|
||||
} else {
|
||||
const url = `${this.baseUrl}/v1/responses`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
};
|
||||
|
||||
// Add proxy header if OAI mode is enabled
|
||||
if (oaiMode.enabled) {
|
||||
headers["X-Proxy-Backend"] = "openai";
|
||||
}
|
||||
|
||||
// Add auth token if available
|
||||
if (this.authToken) {
|
||||
headers["Authorization"] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify(openAIRequest),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to extract detailed error message from response body
|
||||
// Handle authentication errors - don't retry these
|
||||
if (response.status === 401) {
|
||||
this.clearAuthToken(); // Clear invalid token
|
||||
throw new Error("UNAUTHORIZED"); // Special error that won't be retried
|
||||
}
|
||||
|
||||
// Handle other client errors (400-499) - don't retry these either
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
let errorMessage = `Client error ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody.error && errorBody.error.message) {
|
||||
errorMessage = errorBody.error.message;
|
||||
} else if (errorBody.detail) {
|
||||
errorMessage = errorBody.detail;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to generic message
|
||||
}
|
||||
throw new Error(`CLIENT_ERROR: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// Server errors (500-599) - these can be retried
|
||||
let errorMessage = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
@@ -519,18 +687,26 @@ class ApiClient {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch (error) {
|
||||
// Network error occurred - prepare to retry
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Don't retry on auth errors or client errors
|
||||
if (errorMessage === "UNAUTHORIZED" || errorMessage.startsWith("CLIENT_ERROR:")) {
|
||||
throw error; // Re-throw without retrying
|
||||
}
|
||||
|
||||
// Network error or server error occurred - prepare to retry
|
||||
retryCount++;
|
||||
|
||||
if (retryCount > MAX_RETRY_ATTEMPTS) {
|
||||
// Max retries exceeded - give up
|
||||
throw new Error(
|
||||
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${error instanceof Error ? error.message : String(error)}`
|
||||
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${errorMessage}`
|
||||
);
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await sleep(RETRY_INTERVAL_MS);
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
|
||||
const retryDelay = Math.min(RETRY_INTERVAL_MS * Math.pow(2, retryCount - 1), 30000);
|
||||
await sleep(retryDelay);
|
||||
// Loop will retry with GET if we have response_id, otherwise POST
|
||||
}
|
||||
}
|
||||
@@ -559,6 +735,7 @@ class ApiClient {
|
||||
conversationId?: string,
|
||||
resumeResponseId?: string
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
// Proxy mode handling is now inside streamOpenAIResponse
|
||||
yield* this.streamOpenAIResponse(openAIRequest, conversationId, resumeResponseId);
|
||||
}
|
||||
|
||||
@@ -573,6 +750,9 @@ class ApiClient {
|
||||
input: request.input_data || "", // Send dict directly, no stringification needed
|
||||
stream: true,
|
||||
conversation: request.conversation_id, // Include conversation if present
|
||||
extra_body: request.checkpoint_id
|
||||
? { entity_id: workflowId, checkpoint_id: request.checkpoint_id }
|
||||
: undefined, // Pass checkpoint_id if provided
|
||||
};
|
||||
|
||||
yield* this.streamOpenAIResponse(openAIRequest, request.conversation_id);
|
||||
@@ -613,6 +793,139 @@ class ApiClient {
|
||||
clearStreamingState(conversationId: string): void {
|
||||
clearStreamingState(conversationId);
|
||||
}
|
||||
|
||||
// Deployment methods
|
||||
async* streamDeployment(config: {
|
||||
entity_id: string;
|
||||
resource_group: string;
|
||||
app_name: string;
|
||||
region?: string;
|
||||
ui_mode?: string;
|
||||
}): AsyncGenerator<{
|
||||
type: string;
|
||||
message: string;
|
||||
url?: string;
|
||||
auth_token?: string;
|
||||
}> {
|
||||
const response = await fetch(`${this.baseUrl}/v1/deployments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...config, stream: true }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Deployment failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") return;
|
||||
try {
|
||||
yield JSON.parse(data);
|
||||
} catch (e) {
|
||||
// Emit error event for parsing failures
|
||||
yield {
|
||||
type: "deploy.error",
|
||||
message: `Failed to parse deployment event: ${e instanceof Error ? e.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Emit error event before throwing
|
||||
yield {
|
||||
type: "deploy.failed",
|
||||
message: `Stream interrupted: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
throw error;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Workflow Session Management (uses /conversations API)
|
||||
// ============================================================================
|
||||
|
||||
async listWorkflowSessions(entityId: string): Promise<{ data: import("@/types").WorkflowSession[] }> {
|
||||
// Workflow sessions are conversations with entity_id and type metadata
|
||||
const url = `/v1/conversations?entity_id=${encodeURIComponent(entityId)}&type=workflow_session`;
|
||||
const response = await this.request<{
|
||||
object: "list";
|
||||
data: ConversationApiResponse[];
|
||||
has_more: boolean;
|
||||
}>(url);
|
||||
|
||||
// Transform conversations to WorkflowSession format (no checkpoint counting)
|
||||
const sessions = response.data.map((conv) => ({
|
||||
conversation_id: conv.id,
|
||||
entity_id: conv.metadata?.entity_id || entityId,
|
||||
created_at: conv.created_at,
|
||||
metadata: {
|
||||
name: conv.metadata?.name || `Session ${new Date(conv.created_at * 1000).toLocaleString()}`,
|
||||
description: conv.metadata?.description,
|
||||
type: "workflow_session" as const,
|
||||
},
|
||||
}));
|
||||
|
||||
return { data: sessions };
|
||||
}
|
||||
|
||||
async createWorkflowSession(
|
||||
entityId: string,
|
||||
params?: { name?: string; description?: string }
|
||||
): Promise<import("@/types").WorkflowSession> {
|
||||
// Create conversation with workflow session metadata
|
||||
const metadata = {
|
||||
entity_id: entityId,
|
||||
type: "workflow_session" as const,
|
||||
name: params?.name || `Session ${new Date().toLocaleString()}`,
|
||||
...(params?.description && { description: params.description }),
|
||||
};
|
||||
|
||||
const conversation = await this.createConversation(metadata);
|
||||
|
||||
return {
|
||||
conversation_id: conversation.id,
|
||||
entity_id: entityId,
|
||||
created_at: conversation.created_at,
|
||||
metadata: {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
type: "workflow_session" as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async deleteWorkflowSession(_entityId: string, conversationId: string): Promise<void> {
|
||||
// Delete conversation (this also deletes all associated items/checkpoints)
|
||||
const success = await this.deleteConversation(conversationId);
|
||||
if (!success) {
|
||||
throw new Error("Failed to delete workflow session");
|
||||
}
|
||||
}
|
||||
|
||||
// Checkpoint operations now handled through standard conversation items API
|
||||
// Checkpoints are conversation items with type="checkpoint"
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
@@ -11,6 +11,9 @@ import type {
|
||||
ExtendedResponseStreamEvent,
|
||||
Conversation,
|
||||
PendingApproval,
|
||||
OAIProxyMode,
|
||||
WorkflowSession,
|
||||
CheckpointInfo,
|
||||
} from "@/types";
|
||||
import type { ConversationItem } from "@/types/openai";
|
||||
import type { AttachmentItem } from "@/components/ui/attachment-gallery";
|
||||
@@ -23,6 +26,7 @@ interface DevUIState {
|
||||
// Entity Management Slice
|
||||
agents: AgentInfo[];
|
||||
workflows: WorkflowInfo[];
|
||||
entities: (AgentInfo | WorkflowInfo)[]; // Full list in backend order
|
||||
selectedAgent: AgentInfo | WorkflowInfo | undefined;
|
||||
isLoadingEntities: boolean;
|
||||
entityError: string | null;
|
||||
@@ -42,8 +46,16 @@ interface DevUIState {
|
||||
};
|
||||
pendingApprovals: PendingApproval[];
|
||||
|
||||
// Workflow Session Slice (workflow-specific session management)
|
||||
currentSession: WorkflowSession | undefined;
|
||||
availableSessions: WorkflowSession[];
|
||||
sessionCheckpoints: CheckpointInfo[];
|
||||
loadingSessions: boolean;
|
||||
loadingCheckpoints: boolean;
|
||||
|
||||
// UI Slice
|
||||
showDebugPanel: boolean;
|
||||
debugPanelMinimized: boolean;
|
||||
debugPanelWidth: number;
|
||||
debugEvents: ExtendedResponseStreamEvent[];
|
||||
isResizing: boolean;
|
||||
@@ -53,6 +65,34 @@ interface DevUIState {
|
||||
showGallery: boolean;
|
||||
showDeployModal: boolean;
|
||||
showEntityNotFoundToast: boolean;
|
||||
|
||||
// Toast Slice
|
||||
toasts: Array<{
|
||||
id: string;
|
||||
message: string;
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
duration?: number;
|
||||
}>;
|
||||
|
||||
// OpenAI Proxy Mode Slice
|
||||
oaiMode: OAIProxyMode;
|
||||
|
||||
// Server Meta Slice
|
||||
uiMode: "developer" | "user";
|
||||
serverCapabilities: {
|
||||
tracing: boolean;
|
||||
openai_proxy: boolean;
|
||||
};
|
||||
authRequired: boolean;
|
||||
|
||||
// Deployment Slice
|
||||
isDeploying: boolean;
|
||||
deploymentLogs: string[];
|
||||
lastDeployment: {
|
||||
url: string;
|
||||
authToken: string;
|
||||
} | null;
|
||||
azureDeploymentEnabled: boolean; // Feature flag for Azure deployment
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -63,6 +103,7 @@ interface DevUIActions {
|
||||
// Entity Actions
|
||||
setAgents: (agents: AgentInfo[]) => void;
|
||||
setWorkflows: (workflows: WorkflowInfo[]) => void;
|
||||
setEntities: (entities: (AgentInfo | WorkflowInfo)[]) => void;
|
||||
setSelectedAgent: (agent: AgentInfo | WorkflowInfo | undefined) => void;
|
||||
addAgent: (agent: AgentInfo) => void;
|
||||
addWorkflow: (workflow: WorkflowInfo) => void;
|
||||
@@ -84,8 +125,18 @@ interface DevUIActions {
|
||||
updateConversationUsage: (tokens: number) => void;
|
||||
setPendingApprovals: (approvals: PendingApproval[]) => void;
|
||||
|
||||
// Workflow Session Actions
|
||||
setCurrentSession: (session: WorkflowSession | undefined) => void;
|
||||
setAvailableSessions: (sessions: WorkflowSession[]) => void;
|
||||
setSessionCheckpoints: (checkpoints: CheckpointInfo[]) => void;
|
||||
setLoadingSessions: (loading: boolean) => void;
|
||||
setLoadingCheckpoints: (loading: boolean) => void;
|
||||
addSession: (session: WorkflowSession) => void;
|
||||
removeSession: (conversationId: string) => void;
|
||||
|
||||
// UI Actions
|
||||
setShowDebugPanel: (show: boolean) => void;
|
||||
setDebugPanelMinimized: (minimized: boolean) => void;
|
||||
setDebugPanelWidth: (width: number) => void;
|
||||
addDebugEvent: (event: ExtendedResponseStreamEvent) => void;
|
||||
clearDebugEvents: () => void;
|
||||
@@ -97,6 +148,29 @@ interface DevUIActions {
|
||||
setShowDeployModal: (show: boolean) => void;
|
||||
setShowEntityNotFoundToast: (show: boolean) => void;
|
||||
|
||||
// Toast Actions
|
||||
addToast: (toast: {
|
||||
message: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
duration?: number;
|
||||
}) => void;
|
||||
removeToast: (id: string) => void;
|
||||
|
||||
// OpenAI Proxy Mode Actions
|
||||
setOAIMode: (config: OAIProxyMode) => void;
|
||||
toggleOAIMode: () => void;
|
||||
|
||||
// Server Meta Actions
|
||||
setServerMeta: (meta: { uiMode: "developer" | "user"; capabilities: { tracing: boolean; openai_proxy: boolean }; authRequired: boolean }) => void;
|
||||
|
||||
// Deployment Actions
|
||||
startDeployment: () => void;
|
||||
addDeploymentLog: (log: string) => void;
|
||||
setDeploymentResult: (result: { url: string; authToken: string }) => void;
|
||||
stopDeployment: () => void;
|
||||
clearDeploymentState: () => void;
|
||||
setAzureDeploymentEnabled: (enabled: boolean) => void;
|
||||
|
||||
// Combined Actions (handle multiple state updates + side effects)
|
||||
selectEntity: (entity: AgentInfo | WorkflowInfo) => void;
|
||||
}
|
||||
@@ -118,6 +192,7 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
// Entity State
|
||||
agents: [],
|
||||
workflows: [],
|
||||
entities: [],
|
||||
selectedAgent: undefined,
|
||||
isLoadingEntities: true,
|
||||
entityError: null,
|
||||
@@ -134,8 +209,16 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
pendingApprovals: [],
|
||||
|
||||
// Workflow Session State
|
||||
currentSession: undefined,
|
||||
availableSessions: [],
|
||||
sessionCheckpoints: [],
|
||||
loadingSessions: false,
|
||||
loadingCheckpoints: false,
|
||||
|
||||
// UI State
|
||||
showDebugPanel: true,
|
||||
debugPanelMinimized: false,
|
||||
debugPanelWidth: 320,
|
||||
debugEvents: [],
|
||||
isResizing: false,
|
||||
@@ -146,12 +229,36 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
showDeployModal: false,
|
||||
showEntityNotFoundToast: false,
|
||||
|
||||
// Toast State
|
||||
toasts: [],
|
||||
|
||||
// OpenAI Proxy Mode State
|
||||
oaiMode: {
|
||||
enabled: false,
|
||||
model: "gpt-4o-mini", // Default to cheaper model
|
||||
},
|
||||
|
||||
// Server Meta State
|
||||
uiMode: "developer", // Default to developer mode
|
||||
serverCapabilities: {
|
||||
tracing: false,
|
||||
openai_proxy: false,
|
||||
},
|
||||
authRequired: false,
|
||||
|
||||
// Deployment State
|
||||
isDeploying: false,
|
||||
deploymentLogs: [],
|
||||
lastDeployment: null,
|
||||
azureDeploymentEnabled: false, // Default to disabled for safety
|
||||
|
||||
// ========================================
|
||||
// Entity Actions
|
||||
// ========================================
|
||||
|
||||
setAgents: (agents) => set({ agents }),
|
||||
setWorkflows: (workflows) => set({ workflows }),
|
||||
setEntities: (entities) => set({ entities }),
|
||||
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
|
||||
addAgent: (agent) =>
|
||||
set((state) => ({ agents: [...state.agents, agent] })),
|
||||
@@ -216,14 +323,69 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
})),
|
||||
setPendingApprovals: (approvals) => set({ pendingApprovals: approvals }),
|
||||
|
||||
// ========================================
|
||||
// Workflow Session Actions
|
||||
// ========================================
|
||||
|
||||
setCurrentSession: (session) => set({ currentSession: session }),
|
||||
setAvailableSessions: (sessions) => set({ availableSessions: sessions }),
|
||||
setSessionCheckpoints: (checkpoints) =>
|
||||
set({ sessionCheckpoints: checkpoints }),
|
||||
setLoadingSessions: (loading) => set({ loadingSessions: loading }),
|
||||
setLoadingCheckpoints: (loading) => set({ loadingCheckpoints: loading }),
|
||||
addSession: (session) =>
|
||||
set((state) => ({
|
||||
availableSessions: [session, ...state.availableSessions],
|
||||
})),
|
||||
removeSession: (conversationId) =>
|
||||
set((state) => ({
|
||||
availableSessions: state.availableSessions.filter(
|
||||
(s) => s.conversation_id !== conversationId
|
||||
),
|
||||
// Clear current session if it's the one being deleted
|
||||
currentSession:
|
||||
state.currentSession?.conversation_id === conversationId
|
||||
? undefined
|
||||
: state.currentSession,
|
||||
// Clear checkpoints if they belong to deleted session
|
||||
sessionCheckpoints:
|
||||
state.currentSession?.conversation_id === conversationId
|
||||
? []
|
||||
: state.sessionCheckpoints,
|
||||
})),
|
||||
|
||||
// ========================================
|
||||
// UI Actions
|
||||
// ========================================
|
||||
|
||||
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
|
||||
setDebugPanelMinimized: (minimized) => set({ debugPanelMinimized: minimized }),
|
||||
setDebugPanelWidth: (width) => set({ debugPanelWidth: width }),
|
||||
addDebugEvent: (event) =>
|
||||
set((state) => ({ debugEvents: [...state.debugEvents, event] })),
|
||||
set((state) => {
|
||||
// Generate unique timestamp for each event
|
||||
// Use current time + small increment to ensure uniqueness even for rapid events
|
||||
const baseTimestamp = Math.floor(Date.now() / 1000);
|
||||
const lastTimestamp = state.debugEvents.length > 0
|
||||
? (state.debugEvents[state.debugEvents.length - 1] as any)._uiTimestamp || 0
|
||||
: 0;
|
||||
// Ensure new timestamp is always greater than the last one
|
||||
const uniqueTimestamp = Math.max(baseTimestamp, lastTimestamp + 1);
|
||||
|
||||
return {
|
||||
debugEvents: [
|
||||
...state.debugEvents,
|
||||
{
|
||||
...event,
|
||||
// Add UI display timestamp when event is received (Unix seconds)
|
||||
// Each event gets a unique timestamp to preserve chronological order
|
||||
_uiTimestamp: ('created_at' in event && event.created_at)
|
||||
? event.created_at
|
||||
: uniqueTimestamp,
|
||||
} as ExtendedResponseStreamEvent & { _uiTimestamp: number },
|
||||
],
|
||||
};
|
||||
}),
|
||||
clearDebugEvents: () => set({ debugEvents: [] }),
|
||||
setIsResizing: (resizing) => set({ isResizing: resizing }),
|
||||
|
||||
@@ -237,6 +399,153 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
setShowEntityNotFoundToast: (show) =>
|
||||
set({ showEntityNotFoundToast: show }),
|
||||
|
||||
// ========================================
|
||||
// Toast Actions
|
||||
// ========================================
|
||||
|
||||
addToast: (toast) =>
|
||||
set((state) => ({
|
||||
toasts: [
|
||||
...state.toasts,
|
||||
{
|
||||
id: `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: toast.type || "info",
|
||||
duration: toast.duration || 4000,
|
||||
...toast,
|
||||
},
|
||||
],
|
||||
})),
|
||||
|
||||
removeToast: (id) =>
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
})),
|
||||
|
||||
// ========================================
|
||||
// OpenAI Proxy Mode Actions
|
||||
// ========================================
|
||||
|
||||
setOAIMode: (config) =>
|
||||
set((state) => {
|
||||
// If enabling OAI mode, clear conversation state
|
||||
if (config.enabled && !state.oaiMode.enabled) {
|
||||
// Clear ALL conversation localStorage caches
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith('devui_convs_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
oaiMode: config,
|
||||
// Clear conversation state when switching to OAI mode
|
||||
currentConversation: undefined,
|
||||
availableConversations: [],
|
||||
chatItems: [],
|
||||
inputValue: "",
|
||||
attachments: [],
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
debugEvents: [],
|
||||
};
|
||||
}
|
||||
// If disabling OAI mode, also clear state
|
||||
if (!config.enabled && state.oaiMode.enabled) {
|
||||
// Clear ALL conversation localStorage caches
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith('devui_convs_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
oaiMode: config,
|
||||
// Clear conversation state when switching back to local mode
|
||||
currentConversation: undefined,
|
||||
availableConversations: [],
|
||||
chatItems: [],
|
||||
inputValue: "",
|
||||
attachments: [],
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
debugEvents: [],
|
||||
};
|
||||
}
|
||||
// Just update config (model, temperature, etc.) without clearing state
|
||||
return { oaiMode: config };
|
||||
}),
|
||||
|
||||
toggleOAIMode: () =>
|
||||
set((state) => {
|
||||
const newEnabled = !state.oaiMode.enabled;
|
||||
return {
|
||||
oaiMode: { ...state.oaiMode, enabled: newEnabled },
|
||||
// Clear conversation state when toggling
|
||||
currentConversation: undefined,
|
||||
availableConversations: [],
|
||||
chatItems: [],
|
||||
inputValue: "",
|
||||
attachments: [],
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
debugEvents: [],
|
||||
};
|
||||
}),
|
||||
|
||||
// ========================================
|
||||
// Server Meta Actions
|
||||
// ========================================
|
||||
|
||||
setServerMeta: (meta) =>
|
||||
set({
|
||||
uiMode: meta.uiMode,
|
||||
serverCapabilities: meta.capabilities,
|
||||
authRequired: meta.authRequired,
|
||||
}),
|
||||
|
||||
// ========================================
|
||||
// Deployment Actions
|
||||
// ========================================
|
||||
|
||||
startDeployment: () =>
|
||||
set({
|
||||
isDeploying: true,
|
||||
deploymentLogs: [],
|
||||
lastDeployment: null,
|
||||
}),
|
||||
|
||||
addDeploymentLog: (log) =>
|
||||
set((state) => ({
|
||||
deploymentLogs: [...state.deploymentLogs, log],
|
||||
})),
|
||||
|
||||
setDeploymentResult: (result) =>
|
||||
set({
|
||||
isDeploying: false,
|
||||
lastDeployment: result,
|
||||
}),
|
||||
|
||||
stopDeployment: () =>
|
||||
set({
|
||||
isDeploying: false,
|
||||
}),
|
||||
|
||||
clearDeploymentState: () =>
|
||||
set({
|
||||
isDeploying: false,
|
||||
deploymentLogs: [],
|
||||
lastDeployment: null,
|
||||
}),
|
||||
|
||||
setAzureDeploymentEnabled: (enabled) =>
|
||||
set({ azureDeploymentEnabled: enabled }),
|
||||
|
||||
// ========================================
|
||||
// Combined Actions
|
||||
// ========================================
|
||||
@@ -245,6 +554,7 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
* Select an entity (agent/workflow) and handle all side effects:
|
||||
* - Update selected entity
|
||||
* - Clear conversation state (FIXES THE BUG!)
|
||||
* - Clear session state (for workflows)
|
||||
* - Clear debug events
|
||||
* - Update URL
|
||||
*/
|
||||
@@ -261,6 +571,10 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
// Clear workflow session state when switching entities
|
||||
currentSession: undefined,
|
||||
availableSessions: [], // Let WorkflowView reload sessions
|
||||
sessionCheckpoints: [],
|
||||
// Clear debug events when switching
|
||||
debugEvents: [],
|
||||
});
|
||||
@@ -276,7 +590,10 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
// Only persist UI preferences, not runtime state
|
||||
partialize: (state) => ({
|
||||
showDebugPanel: state.showDebugPanel,
|
||||
debugPanelMinimized: state.debugPanelMinimized,
|
||||
debugPanelWidth: state.debugPanelWidth,
|
||||
oaiMode: state.oaiMode, // Persist OpenAI proxy mode settings
|
||||
azureDeploymentEnabled: state.azureDeploymentEnabled, // Persist Azure deployment preference
|
||||
}),
|
||||
}
|
||||
),
|
||||
|
||||
@@ -68,6 +68,7 @@ export type ResponseInputParam = ResponseInputItem[];
|
||||
// Agent Framework extension fields (matches backend AgentFrameworkExtraBody)
|
||||
export interface AgentFrameworkExtraBody {
|
||||
entity_id: string;
|
||||
checkpoint_id?: string; // Optional checkpoint ID for workflow resume
|
||||
// input_data removed - now using standard input field for all data
|
||||
}
|
||||
|
||||
@@ -85,8 +86,15 @@ export interface AgentFrameworkRequest {
|
||||
metadata?: Record<string, unknown>;
|
||||
temperature?: number;
|
||||
max_output_tokens?: number;
|
||||
top_p?: number;
|
||||
tools?: Record<string, unknown>[];
|
||||
|
||||
// Reasoning parameters (for o-series models)
|
||||
reasoning?: {
|
||||
effort?: "minimal" | "low" | "medium" | "high";
|
||||
summary?: "auto" | "concise" | "detailed";
|
||||
};
|
||||
|
||||
// Agent Framework extension - strongly typed
|
||||
extra_body?: AgentFrameworkExtraBody;
|
||||
entity_id?: string; // Allow entity_id as top-level field too
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface AgentInfo {
|
||||
module_path?: string;
|
||||
required_env_vars?: EnvVarRequirement[];
|
||||
metadata?: Record<string, unknown>; // Backend metadata including lazy_loaded flag
|
||||
// Deployment support
|
||||
deployment_supported?: boolean;
|
||||
deployment_reason?: string;
|
||||
// Agent-specific fields
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
@@ -71,6 +74,7 @@ export interface WorkflowInfo extends Omit<AgentInfo, "tools"> {
|
||||
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
|
||||
// Note: DevUI provides runtime checkpoint storage for ALL workflows via conversations
|
||||
}
|
||||
|
||||
// OpenAI Conversations API (standard)
|
||||
@@ -89,6 +93,22 @@ export interface RunAgentRequest {
|
||||
export interface RunWorkflowRequest {
|
||||
input_data: Record<string, unknown>;
|
||||
conversation_id?: string;
|
||||
checkpoint_id?: string;
|
||||
}
|
||||
|
||||
// OpenAI Proxy Mode Configuration
|
||||
export interface OAIProxyMode {
|
||||
enabled: boolean;
|
||||
model: string; // Model ID like "gpt-4o", "gpt-4o-mini", or custom
|
||||
|
||||
// Optional OpenAI Responses API parameters
|
||||
temperature?: number;
|
||||
max_output_tokens?: number;
|
||||
top_p?: number;
|
||||
instructions?: string;
|
||||
|
||||
// Reasoning parameters (for o-series models)
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
// Legacy types - DEPRECATED - use new structured events from openai.ts instead
|
||||
@@ -133,6 +153,17 @@ export interface HealthResponse {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface MetaResponse {
|
||||
ui_mode: "developer" | "user";
|
||||
version: string;
|
||||
framework: string;
|
||||
capabilities: {
|
||||
tracing: boolean;
|
||||
openai_proxy: boolean;
|
||||
};
|
||||
auth_required: boolean;
|
||||
}
|
||||
|
||||
// Chat message types matching Agent Framework
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
@@ -175,3 +206,54 @@ export interface PendingApproval {
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// Deployment types
|
||||
export interface DeploymentConfig {
|
||||
entity_id: string;
|
||||
resource_group: string;
|
||||
app_name: string;
|
||||
region?: string;
|
||||
ui_mode?: string;
|
||||
ui_enabled?: boolean;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export interface DeploymentEvent {
|
||||
type: string;
|
||||
message: string;
|
||||
url?: string;
|
||||
auth_token?: string;
|
||||
}
|
||||
|
||||
export interface Deployment {
|
||||
id: string;
|
||||
entity_id: string;
|
||||
resource_group: string;
|
||||
app_name: string;
|
||||
region: string;
|
||||
url: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Workflow Session Management Types
|
||||
export interface WorkflowSession {
|
||||
conversation_id: string;
|
||||
entity_id: string;
|
||||
created_at: number;
|
||||
metadata: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
type: "workflow_session";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CheckpointInfo {
|
||||
checkpoint_id: string;
|
||||
workflow_id: string;
|
||||
timestamp: number;
|
||||
iteration_count: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface ResponseFailedEvent {
|
||||
|
||||
// Custom Agent Framework OpenAI event types with structured data
|
||||
export interface ResponseWorkflowEventComplete {
|
||||
type: "response.workflow_event.complete";
|
||||
type: "response.workflow_event.completed";
|
||||
data: {
|
||||
event_type: string;
|
||||
data?: Record<string, unknown>;
|
||||
@@ -125,6 +125,32 @@ export interface ResponseFunctionToolCall {
|
||||
status?: "in_progress" | "completed" | "incomplete";
|
||||
}
|
||||
|
||||
// DevUI Extension: Output item types for response.output_item.added events
|
||||
export interface ResponseOutputImageItem {
|
||||
id: string;
|
||||
type: "output_image";
|
||||
image_url: string;
|
||||
alt_text?: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface ResponseOutputFileItem {
|
||||
id: string;
|
||||
type: "output_file";
|
||||
filename: string;
|
||||
file_url?: string;
|
||||
file_data?: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface ResponseOutputDataItem {
|
||||
id: string;
|
||||
type: "output_data";
|
||||
data: string;
|
||||
mime_type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Workflow Item Types - flexible interface for any workflow item
|
||||
export interface WorkflowItem {
|
||||
type: string; // "executor_action", "workflow_action", "message", or any future type
|
||||
@@ -147,24 +173,34 @@ export function isExecutorAction(item: WorkflowItem): item is ExecutorActionItem
|
||||
return item.type === "executor_action" && "executor_id" in item;
|
||||
}
|
||||
|
||||
// OpenAI Responses API - Output Item Events
|
||||
// Union of all possible output items
|
||||
export type ResponseOutputItem =
|
||||
| ResponseFunctionToolCall
|
||||
| ResponseOutputImageItem
|
||||
| ResponseOutputFileItem
|
||||
| ResponseOutputDataItem
|
||||
| ExecutorActionItem
|
||||
| WorkflowItem;
|
||||
|
||||
// OpenAI Responses API - Output Item Added Event
|
||||
// OpenAI standard: Output item added event (extended to support our output types)
|
||||
export interface ResponseOutputItemAddedEvent {
|
||||
type: "response.output_item.added";
|
||||
item: WorkflowItem | ResponseFunctionToolCall | any; // Flexible to support various item types
|
||||
item: ResponseOutputItem;
|
||||
output_index: number;
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
export interface ResponseOutputItemDoneEvent {
|
||||
type: "response.output_item.done";
|
||||
item: WorkflowItem | ResponseFunctionToolCall | any;
|
||||
item: ResponseOutputItem;
|
||||
output_index: number;
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
// Trace event - matching actual backend output
|
||||
export interface ResponseTraceEventComplete {
|
||||
type: "response.trace_event.complete";
|
||||
type: "response.trace.completed";
|
||||
data: {
|
||||
operation_name?: string;
|
||||
duration_ms?: number;
|
||||
@@ -179,7 +215,7 @@ export interface ResponseTraceEventComplete {
|
||||
|
||||
// New trace event format from backend
|
||||
export interface ResponseTraceComplete {
|
||||
type: "response.trace.complete";
|
||||
type: "response.trace.completed";
|
||||
data: {
|
||||
type?: string;
|
||||
span_id?: string;
|
||||
@@ -244,6 +280,20 @@ export interface ResponseFunctionResultComplete {
|
||||
timestamp?: string; // Optional ISO timestamp for UI display
|
||||
}
|
||||
|
||||
// DevUI Extension: Workflow Requests Human Input (HIL)
|
||||
export interface ResponseRequestInfoEvent {
|
||||
type: "response.request_info.requested";
|
||||
request_id: string;
|
||||
source_executor_id: string;
|
||||
request_type: string;
|
||||
request_data: Record<string, unknown>;
|
||||
request_schema: Record<string, unknown>;
|
||||
item_id: string;
|
||||
output_index: number;
|
||||
sequence_number: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// DevUI Extension: Turn Separator (UI-only event for grouping)
|
||||
export interface TurnSeparatorEvent {
|
||||
type: "debug.turn_separator";
|
||||
@@ -266,6 +316,7 @@ export type StructuredEvent =
|
||||
| ResponseFunctionCallDelta
|
||||
| ResponseFunctionCallArgumentsDelta
|
||||
| ResponseFunctionResultComplete
|
||||
| ResponseRequestInfoEvent
|
||||
| ResponseErrorEvent
|
||||
| ResponseFunctionApprovalRequestedEvent
|
||||
| ResponseFunctionApprovalRespondedEvent
|
||||
@@ -374,6 +425,18 @@ export interface MessageInputFile {
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
// DevUI Extension: Function approval request content (shown in chat)
|
||||
export interface MessageFunctionApprovalRequestContent {
|
||||
type: "function_approval_request";
|
||||
request_id: string;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
function_call: {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// DevUI Extension: Function approval response content
|
||||
export interface MessageFunctionApprovalResponseContent {
|
||||
type: "function_approval_response";
|
||||
@@ -386,12 +449,45 @@ export interface MessageFunctionApprovalResponseContent {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DevUI Extension: Output Content Types (Agent-Generated Media/Data)
|
||||
// ============================================================================
|
||||
// These extend the OpenAI Responses API to support rich content outputs
|
||||
// that aren't natively supported (images, files, data). They mirror the
|
||||
// input types but for agent outputs.
|
||||
|
||||
export interface MessageOutputImage {
|
||||
type: "output_image";
|
||||
image_url: string; // URL or data URI (data:image/png;base64,...)
|
||||
alt_text?: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface MessageOutputFile {
|
||||
type: "output_file";
|
||||
filename: string;
|
||||
file_url?: string;
|
||||
file_data?: string; // base64
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface MessageOutputData {
|
||||
type: "output_data";
|
||||
data: string;
|
||||
mime_type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type MessageContent =
|
||||
| MessageTextContent
|
||||
| MessageInputTextContent
|
||||
| MessageOutputTextContent
|
||||
| MessageInputImage
|
||||
| MessageInputFile
|
||||
| MessageOutputImage
|
||||
| MessageOutputFile
|
||||
| MessageOutputData
|
||||
| MessageFunctionApprovalRequestContent
|
||||
| MessageFunctionApprovalResponseContent;
|
||||
|
||||
// Message item (user/assistant messages with content)
|
||||
@@ -401,6 +497,7 @@ export interface ConversationMessage {
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: MessageContent[];
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
created_at?: number; // Unix timestamp in seconds - when this message was created
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
@@ -416,6 +513,7 @@ export interface ConversationFunctionCall {
|
||||
name: string;
|
||||
arguments: string;
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
created_at?: number; // Unix timestamp in seconds - when this function call was made
|
||||
}
|
||||
|
||||
// Function call output item
|
||||
@@ -425,6 +523,7 @@ export interface ConversationFunctionCallOutput {
|
||||
call_id: string;
|
||||
output: string;
|
||||
status?: "in_progress" | "completed" | "incomplete";
|
||||
created_at?: number; // Unix timestamp in seconds - when this function result was received
|
||||
}
|
||||
|
||||
// Union of all conversation item types
|
||||
|
||||
@@ -164,6 +164,8 @@ export function convertWorkflowDumpToEdges(
|
||||
id: `${connection.source}-${connection.target}`,
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourceHandle: "source",
|
||||
targetHandle: "target",
|
||||
type: "default",
|
||||
animated: false,
|
||||
style: {
|
||||
@@ -307,7 +309,7 @@ export function applyDagreLayout(
|
||||
|
||||
/**
|
||||
* Process workflow events and extract node updates
|
||||
* Handles both new standard OpenAI events and legacy workflow events
|
||||
* Handles both standard OpenAI events and fallback workflow_event format
|
||||
*/
|
||||
export function processWorkflowEvents(
|
||||
events: ExtendedResponseStreamEvent[],
|
||||
@@ -316,12 +318,29 @@ export function processWorkflowEvents(
|
||||
const nodeUpdates: Record<string, NodeUpdate> = {};
|
||||
let hasWorkflowStarted = false;
|
||||
|
||||
// Track the latest item ID for each executor to handle multiple runs
|
||||
const latestItemIds: Record<string, string> = {};
|
||||
|
||||
events.forEach((event) => {
|
||||
// Handle new standard OpenAI events
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
||||
const item = (event as any).item;
|
||||
if (item && item.type === "executor_action" && item.executor_id) {
|
||||
const executorId = item.executor_id;
|
||||
const itemId = item.id;
|
||||
|
||||
// Track the latest item ID for this executor
|
||||
if (event.type === "response.output_item.added") {
|
||||
latestItemIds[executorId] = itemId;
|
||||
}
|
||||
|
||||
// Only process this event if it's for the latest item ID of this executor
|
||||
// This prevents older "done" events from overwriting newer "added" events
|
||||
const isLatestItem = latestItemIds[executorId] === itemId;
|
||||
|
||||
if (!isLatestItem && event.type === "response.output_item.done") {
|
||||
return; // Skip this old completion event
|
||||
}
|
||||
|
||||
let state: ExecutorState = "pending";
|
||||
let error: string | undefined;
|
||||
@@ -352,9 +371,9 @@ export function processWorkflowEvents(
|
||||
else if (event.type === "response.created" || event.type === "response.in_progress") {
|
||||
hasWorkflowStarted = true;
|
||||
}
|
||||
// Legacy support for older backends
|
||||
// Handle workflow event format
|
||||
else if (
|
||||
event.type === "response.workflow_event.complete" &&
|
||||
event.type === "response.workflow_event.completed" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
) {
|
||||
@@ -400,16 +419,38 @@ export function processWorkflowEvents(
|
||||
}
|
||||
});
|
||||
|
||||
// If workflow has started and we have a start executor, set it to running
|
||||
// (unless it already has a specific state from an ExecutorInvokedEvent)
|
||||
// FALLBACK LOGIC: If workflow has started and we have a start executor, set it to running
|
||||
// ONLY if it hasn't received any explicit executor events
|
||||
// This prevents overwriting the actual state after the executor has run
|
||||
if (hasWorkflowStarted && startExecutorId && !nodeUpdates[startExecutorId]) {
|
||||
nodeUpdates[startExecutorId] = {
|
||||
nodeId: startExecutorId,
|
||||
state: "running",
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
// Additional check: only set to running if we don't have completion/failure events for this executor
|
||||
// This prevents setting to "running" after the executor has already completed
|
||||
const hasCompletionEvent = events.some((event) => {
|
||||
if (event.type === "response.output_item.done") {
|
||||
const item = (event as any).item;
|
||||
return item && item.type === "executor_action" && item.executor_id === startExecutorId;
|
||||
}
|
||||
if (event.type === "response.workflow_event.completed" && "data" in event && event.data) {
|
||||
const data = event.data as any;
|
||||
return data.executor_id === startExecutorId &&
|
||||
(data.event_type === "ExecutorCompletedEvent" ||
|
||||
data.event_type === "ExecutorFailedEvent" ||
|
||||
data.event_type?.includes("Error") ||
|
||||
data.event_type?.includes("Failed"));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Only set to running if the executor hasn't completed yet
|
||||
if (!hasCompletionEvent) {
|
||||
nodeUpdates[startExecutorId] = {
|
||||
nodeId: startExecutorId,
|
||||
state: "running",
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return nodeUpdates;
|
||||
@@ -466,9 +507,9 @@ export function getCurrentlyExecutingExecutors(
|
||||
};
|
||||
}
|
||||
}
|
||||
// Legacy support for older backends
|
||||
// Handle workflow event format
|
||||
else if (
|
||||
event.type === "response.workflow_event.complete" &&
|
||||
event.type === "response.workflow_event.completed" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
) {
|
||||
@@ -515,7 +556,7 @@ export function updateEdgesWithSequenceAnalysis(
|
||||
|
||||
events.forEach((event) => {
|
||||
if (
|
||||
event.type === "response.workflow_event.complete" &&
|
||||
event.type === "response.workflow_event.completed" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
) {
|
||||
@@ -584,3 +625,67 @@ export function updateEdgesWithSequenceAnalysis(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidate bidirectional edges into single edges with arrows on both ends
|
||||
* This reduces visual clutter when edges go in both directions between nodes
|
||||
*
|
||||
* Smart handle selection algorithm:
|
||||
* The current implementation keeps whichever edge was encountered first in the array.
|
||||
* Since edges are typically created in workflow definition order (following the primary flow),
|
||||
* this naturally keeps the "forward" edge and discards the "backward" one.
|
||||
*
|
||||
* For example, if the workflow defines:
|
||||
* 1. coordinator → planner (primary flow)
|
||||
* 2. planner → coordinator (feedback loop)
|
||||
*
|
||||
* We keep edge #1 and add bidirectional arrows. This ensures the edge follows
|
||||
* the natural output→input handle connection of the primary flow direction.
|
||||
*
|
||||
* React Flow will automatically route the edge to avoid overlaps, and the
|
||||
* bidirectional arrows indicate that communication flows both ways.
|
||||
*/
|
||||
export function consolidateBidirectionalEdges(edges: Edge[]): Edge[] {
|
||||
const edgeMap = new Map<string, Edge>();
|
||||
const bidirectionalKeys = new Set<string>();
|
||||
|
||||
edges.forEach(edge => {
|
||||
const forwardKey = `${edge.source}-${edge.target}`;
|
||||
const reverseKey = `${edge.target}-${edge.source}`;
|
||||
|
||||
// Check if we already have the reverse edge
|
||||
if (edgeMap.has(reverseKey)) {
|
||||
// Mark both keys as bidirectional
|
||||
bidirectionalKeys.add(reverseKey);
|
||||
bidirectionalKeys.add(forwardKey);
|
||||
|
||||
// Update the existing reverse edge to be bidirectional
|
||||
const existingEdge = edgeMap.get(reverseKey)!;
|
||||
|
||||
// Keep the existing edge's handles (they follow the primary workflow direction)
|
||||
// Add bidirectional arrows to show two-way communication
|
||||
edgeMap.set(reverseKey, {
|
||||
...existingEdge,
|
||||
markerStart: {
|
||||
type: 'arrow' as const,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
markerEnd: {
|
||||
type: 'arrow' as const,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
data: {
|
||||
...existingEdge.data,
|
||||
isBidirectional: true,
|
||||
},
|
||||
});
|
||||
} else if (!bidirectionalKeys.has(forwardKey)) {
|
||||
// Only add if this isn't the reverse of a bidirectional pair
|
||||
edgeMap.set(forwardKey, edge);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(edgeMap.values());
|
||||
}
|
||||
|
||||
@@ -584,13 +584,33 @@
|
||||
aria-hidden "^1.2.4"
|
||||
react-remove-scroll "^2.6.3"
|
||||
|
||||
"@radix-ui/react-slot@^1.2.3", "@radix-ui/react-slot@1.2.3":
|
||||
"@radix-ui/react-separator@^1.1.7":
|
||||
version "1.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz#a18bd7fd07c10fda1bba14f2a3032e7b1a2b3470"
|
||||
integrity sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==
|
||||
dependencies:
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
|
||||
"@radix-ui/react-slot@1.2.3", "@radix-ui/react-slot@^1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz"
|
||||
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
|
||||
"@radix-ui/react-switch@^1.2.6":
|
||||
version "1.2.6"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz#ff79acb831f0d5ea9216cfcc5b939912571358e3"
|
||||
integrity sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
"@radix-ui/react-use-previous" "1.1.1"
|
||||
"@radix-ui/react-use-size" "1.1.1"
|
||||
|
||||
"@radix-ui/react-tabs@^1.1.13":
|
||||
version "1.1.13"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz"
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for checkpoint-as-conversation-items implementation."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
InMemoryCheckpointStorage,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
|
||||
from agent_framework_devui._conversations import (
|
||||
CheckpointConversationManager,
|
||||
InMemoryConversationStore,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowTestData:
|
||||
"""Simple test data."""
|
||||
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowHILRequest:
|
||||
"""HIL request for testing."""
|
||||
|
||||
question: str
|
||||
|
||||
|
||||
class WorkflowTestExecutor(Executor):
|
||||
"""Test executor with HIL."""
|
||||
|
||||
@handler
|
||||
async def process(self, data: WorkflowTestData, ctx: WorkflowContext) -> None:
|
||||
"""Process data and request approval."""
|
||||
await ctx.set_executor_state({"data_value": data.value})
|
||||
|
||||
# Request HIL (checkpoint created here)
|
||||
await ctx.request_info(request_data=WorkflowHILRequest(question=f"Approve {data.value}?"), response_type=str)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self, original_request: WorkflowHILRequest, response: str, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
"""Handle HIL response."""
|
||||
state = await ctx.get_executor_state() or {}
|
||||
value = state.get("data_value", "")
|
||||
await ctx.send_message(f"{value}_approved" if response.lower() == "yes" else f"{value}_rejected")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conversation_store():
|
||||
"""Create in-memory conversation store."""
|
||||
return InMemoryConversationStore()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def checkpoint_manager(conversation_store):
|
||||
"""Create checkpoint manager."""
|
||||
return CheckpointConversationManager(conversation_store)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_workflow():
|
||||
"""Create test workflow with checkpointing."""
|
||||
executor = WorkflowTestExecutor(id="test_executor")
|
||||
checkpoint_storage = InMemoryCheckpointStorage()
|
||||
|
||||
return (
|
||||
WorkflowBuilder(name="Test Workflow", description="Test checkpoint behavior")
|
||||
.set_start_executor(executor)
|
||||
.with_checkpointing(checkpoint_storage)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
class TestCheckpointConversationManager:
|
||||
"""Test CheckpointConversationManager functionality - CONVERSATION-SCOPED."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_scoped_checkpoint_save(self, checkpoint_manager, test_workflow):
|
||||
"""Test checkpoint save in a specific conversation."""
|
||||
entity_id = "test_entity"
|
||||
conversation_id = f"conv_{entity_id}_test123"
|
||||
|
||||
# Create conversation first
|
||||
checkpoint_manager.conversation_store.create_conversation(
|
||||
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
# Create test checkpoint
|
||||
import uuid
|
||||
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, shared_state={"test": "data"}
|
||||
)
|
||||
|
||||
# Get checkpoint storage for this conversation and save
|
||||
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
|
||||
checkpoint_id = await storage.save_checkpoint(checkpoint)
|
||||
|
||||
assert checkpoint_id == checkpoint.checkpoint_id
|
||||
|
||||
# Verify checkpoint stored in THIS conversation only
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
assert len(checkpoints) == 1
|
||||
assert checkpoints[0].checkpoint_id == checkpoint.checkpoint_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_isolation(self, checkpoint_manager, test_workflow):
|
||||
"""Test that conversations are isolated - checkpoints don't leak between conversations."""
|
||||
entity_id = "test_entity"
|
||||
conv_a = f"conv_{entity_id}_aaa"
|
||||
conv_b = f"conv_{entity_id}_bbb"
|
||||
|
||||
# Create two conversations
|
||||
checkpoint_manager.conversation_store.create_conversation(
|
||||
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conv_a
|
||||
)
|
||||
checkpoint_manager.conversation_store.create_conversation(
|
||||
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conv_b
|
||||
)
|
||||
|
||||
# Save checkpoint to conversation A
|
||||
import uuid
|
||||
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
|
||||
checkpoint_a = WorkflowCheckpoint(
|
||||
checkpoint_id=str(uuid.uuid4()),
|
||||
workflow_id=test_workflow.id,
|
||||
messages={},
|
||||
shared_state={"conversation": "A"},
|
||||
)
|
||||
storage_a = checkpoint_manager.get_checkpoint_storage(conv_a)
|
||||
await storage_a.save_checkpoint(checkpoint_a)
|
||||
|
||||
# Verify conversation A has checkpoint
|
||||
checkpoints_a = await storage_a.list_checkpoints()
|
||||
assert len(checkpoints_a) == 1
|
||||
|
||||
# Verify conversation B has NO checkpoints (isolation)
|
||||
storage_b = checkpoint_manager.get_checkpoint_storage(conv_b)
|
||||
checkpoints_b = await storage_b.list_checkpoints()
|
||||
assert len(checkpoints_b) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_checkpoints_in_session(self, checkpoint_manager, test_workflow):
|
||||
"""Test listing checkpoints within a session."""
|
||||
entity_id = "test_entity"
|
||||
conversation_id = f"session_{entity_id}_test456"
|
||||
|
||||
# Create session
|
||||
checkpoint_manager.conversation_store.create_conversation(
|
||||
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
# Save multiple checkpoints
|
||||
import uuid
|
||||
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
|
||||
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
|
||||
checkpoint_ids = []
|
||||
for i in range(3):
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id=str(uuid.uuid4()),
|
||||
workflow_id=test_workflow.id,
|
||||
messages={},
|
||||
shared_state={"iteration": i},
|
||||
)
|
||||
saved_id = await storage.save_checkpoint(checkpoint)
|
||||
checkpoint_ids.append(saved_id)
|
||||
|
||||
# List checkpoints using the storage
|
||||
checkpoints_list = await storage.list_checkpoints()
|
||||
assert len(checkpoints_list) == 3
|
||||
|
||||
# Verify all checkpoint IDs are present
|
||||
loaded_ids = [cp.checkpoint_id for cp in checkpoints_list]
|
||||
for saved_id in checkpoint_ids:
|
||||
assert saved_id in loaded_ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkpoints_appear_as_conversation_items(self, checkpoint_manager, test_workflow):
|
||||
"""Test that checkpoints appear as conversation items through the standard API."""
|
||||
entity_id = "test_entity"
|
||||
conversation_id = f"session_{entity_id}_items_test"
|
||||
|
||||
# Create session
|
||||
checkpoint_manager.conversation_store.create_conversation(
|
||||
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
# Save multiple checkpoints
|
||||
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
|
||||
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
|
||||
checkpoint_ids = []
|
||||
for i in range(2):
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id=f"checkpoint_{i}",
|
||||
workflow_id=test_workflow.id,
|
||||
messages={},
|
||||
shared_state={"iteration": i},
|
||||
)
|
||||
saved_id = await storage.save_checkpoint(checkpoint)
|
||||
checkpoint_ids.append(saved_id)
|
||||
|
||||
# List conversation items - should include checkpoints
|
||||
items, has_more = await checkpoint_manager.conversation_store.list_items(conversation_id)
|
||||
|
||||
# Filter for checkpoint items
|
||||
checkpoint_items = [item for item in items if (isinstance(item, dict) and item.get("type") == "checkpoint")]
|
||||
|
||||
# Verify we have the correct number of checkpoint items
|
||||
assert len(checkpoint_items) == 2, f"Expected 2 checkpoint items, got {len(checkpoint_items)}"
|
||||
|
||||
# Verify checkpoint items have correct structure
|
||||
for item in checkpoint_items:
|
||||
assert item.get("type") == "checkpoint"
|
||||
assert item.get("checkpoint_id") in checkpoint_ids
|
||||
assert item.get("workflow_id") == test_workflow.id
|
||||
assert "timestamp" in item
|
||||
assert item.get("id").startswith("checkpoint_") # ID format: checkpoint_{checkpoint_id}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_checkpoint_from_session(self, checkpoint_manager, test_workflow):
|
||||
"""Test loading checkpoint from a specific session."""
|
||||
entity_id = "test_entity"
|
||||
conversation_id = f"session_{entity_id}_test789"
|
||||
|
||||
# Create session
|
||||
checkpoint_manager.conversation_store.create_conversation(
|
||||
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
# Create and save a checkpoint
|
||||
import uuid
|
||||
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
|
||||
original_checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id=str(uuid.uuid4()),
|
||||
workflow_id=test_workflow.id,
|
||||
messages={},
|
||||
shared_state={"test_key": "test_value"},
|
||||
)
|
||||
|
||||
# Save to this session
|
||||
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
|
||||
await storage.save_checkpoint(original_checkpoint)
|
||||
|
||||
# Load checkpoint from this session
|
||||
loaded_checkpoint = await storage.load_checkpoint(original_checkpoint.checkpoint_id)
|
||||
|
||||
assert loaded_checkpoint is not None
|
||||
assert loaded_checkpoint.checkpoint_id == original_checkpoint.checkpoint_id
|
||||
assert loaded_checkpoint.workflow_id == original_checkpoint.workflow_id
|
||||
assert loaded_checkpoint.shared_state == {"test_key": "test_value"}
|
||||
|
||||
|
||||
class TestCheckpointStorage:
|
||||
"""Test InMemoryCheckpointStorage per conversation - SESSION-SCOPED."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkpoint_storage_protocol(self, checkpoint_manager, test_workflow):
|
||||
"""Test that adapter implements CheckpointStorage protocol."""
|
||||
entity_id = "test_entity"
|
||||
conversation_id = f"session_{entity_id}_adapter_test"
|
||||
|
||||
# Create session
|
||||
checkpoint_manager.conversation_store.create_conversation(
|
||||
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
# Get storage adapter for this session
|
||||
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
|
||||
|
||||
# Create test checkpoint
|
||||
import uuid
|
||||
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, shared_state={"test": "data"}
|
||||
)
|
||||
|
||||
# Test save_checkpoint
|
||||
checkpoint_id = await storage.save_checkpoint(checkpoint)
|
||||
assert checkpoint_id == checkpoint.checkpoint_id
|
||||
|
||||
# Test load_checkpoint
|
||||
loaded = await storage.load_checkpoint(checkpoint_id)
|
||||
assert loaded is not None
|
||||
assert loaded.checkpoint_id == checkpoint_id
|
||||
|
||||
# Test list_checkpoint_ids
|
||||
ids = await storage.list_checkpoint_ids(workflow_id=test_workflow.id)
|
||||
assert checkpoint_id in ids
|
||||
|
||||
# Test list_checkpoints
|
||||
checkpoints_list = await storage.list_checkpoints(workflow_id=test_workflow.id)
|
||||
assert len(checkpoints_list) >= 1
|
||||
assert any(cp.checkpoint_id == checkpoint_id for cp in checkpoints_list)
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for checkpoint workflow execution."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_checkpoint_save_via_injected_storage(self, checkpoint_manager, test_workflow):
|
||||
"""Test manual checkpoint save via build-time storage injection."""
|
||||
entity_id = "test_entity"
|
||||
conversation_id = f"session_{entity_id}_integration_test1"
|
||||
|
||||
# Create session conversation
|
||||
checkpoint_manager.conversation_store.create_conversation(
|
||||
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
# Get checkpoint storage for this session
|
||||
checkpoint_storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
|
||||
|
||||
# Set build-time storage (equivalent to .with_checkpointing() at build time)
|
||||
# Note: In production, DevUI uses runtime injection via run_stream() parameter
|
||||
if hasattr(test_workflow, "_runner") and hasattr(test_workflow._runner, "context"):
|
||||
test_workflow._runner.context._checkpoint_storage = checkpoint_storage
|
||||
|
||||
# Create and save a checkpoint via injected storage
|
||||
import uuid
|
||||
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, shared_state={"injected": True}
|
||||
)
|
||||
await checkpoint_storage.save_checkpoint(checkpoint)
|
||||
|
||||
# Verify checkpoint is accessible via storage (in this session)
|
||||
storage_checkpoints = await checkpoint_storage.list_checkpoints()
|
||||
assert len(storage_checkpoints) > 0
|
||||
assert storage_checkpoints[0].checkpoint_id == checkpoint.checkpoint_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkpoint_roundtrip_via_storage(self, checkpoint_manager, test_workflow):
|
||||
"""Test checkpoint save/load roundtrip via storage adapter."""
|
||||
entity_id = "test_entity"
|
||||
conversation_id = f"session_{entity_id}_integration_test2"
|
||||
|
||||
# Create session conversation
|
||||
checkpoint_manager.conversation_store.create_conversation(
|
||||
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
# Set build-time storage for testing
|
||||
checkpoint_storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
|
||||
test_workflow._runner.context._checkpoint_storage = checkpoint_storage
|
||||
|
||||
# Create checkpoint
|
||||
import uuid
|
||||
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id=str(uuid.uuid4()),
|
||||
workflow_id=test_workflow.id,
|
||||
messages={},
|
||||
shared_state={"ready_to_resume": True},
|
||||
)
|
||||
checkpoint_id = await checkpoint_storage.save_checkpoint(checkpoint)
|
||||
|
||||
# Verify checkpoint can be loaded for resume
|
||||
loaded = await checkpoint_storage.load_checkpoint(checkpoint_id)
|
||||
assert loaded is not None
|
||||
assert loaded.checkpoint_id == checkpoint_id
|
||||
assert loaded.shared_state == {"ready_to_resume": True}
|
||||
|
||||
# Verify checkpoint is accessible via storage (for UI to list checkpoints)
|
||||
checkpoints = await checkpoint_storage.list_checkpoints()
|
||||
assert len(checkpoints) > 0
|
||||
assert checkpoints[0].checkpoint_id == checkpoint_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_auto_saves_checkpoints_to_injected_storage(self, checkpoint_manager, test_workflow):
|
||||
"""Test that workflows automatically save checkpoints to our conversation-backed storage.
|
||||
|
||||
This is the critical end-to-end test that verifies the entire checkpoint flow:
|
||||
1. Storage is set as build-time storage (simulates .with_checkpointing())
|
||||
2. Workflow runs and pauses at HIL point (IDLE_WITH_PENDING_REQUESTS status)
|
||||
3. Framework automatically saves checkpoint to our storage
|
||||
4. Checkpoint is accessible via manager for UI to list/resume
|
||||
|
||||
Note: In production, DevUI passes checkpoint_storage to run_stream() as runtime parameter.
|
||||
This test uses build-time injection to verify framework's checkpoint auto-save behavior.
|
||||
"""
|
||||
entity_id = "test_entity"
|
||||
conversation_id = f"session_{entity_id}_integration_test3"
|
||||
|
||||
# Create session conversation
|
||||
checkpoint_manager.conversation_store.create_conversation(
|
||||
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
# Set build-time storage to test automatic checkpoint saves
|
||||
checkpoint_storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
|
||||
test_workflow._runner.context._checkpoint_storage = checkpoint_storage
|
||||
|
||||
# Verify no checkpoints initially
|
||||
checkpoints_before = await checkpoint_storage.list_checkpoints()
|
||||
assert len(checkpoints_before) == 0
|
||||
|
||||
# Run workflow until it reaches IDLE_WITH_PENDING_REQUESTS (after checkpoint is created)
|
||||
saw_request_event = False
|
||||
async for event in test_workflow.run_stream(WorkflowTestData(value="test")):
|
||||
if hasattr(event, "__class__"):
|
||||
if event.__class__.__name__ == "RequestInfoEvent":
|
||||
saw_request_event = True
|
||||
# Wait for IDLE_WITH_PENDING_REQUESTS status (comes after checkpoint creation)
|
||||
is_status_event = event.__class__.__name__ == "WorkflowStatusEvent"
|
||||
has_pending_status = hasattr(event, "status") and "IDLE_WITH_PENDING_REQUESTS" in str(event.status)
|
||||
if is_status_event and has_pending_status:
|
||||
break
|
||||
|
||||
assert saw_request_event, "Test workflow should have emitted RequestInfoEvent"
|
||||
|
||||
# Verify checkpoint was AUTOMATICALLY saved to our storage by the framework
|
||||
checkpoints_after = await checkpoint_storage.list_checkpoints()
|
||||
assert len(checkpoints_after) > 0, "Workflow should have auto-saved checkpoint at HIL pause"
|
||||
|
||||
# Verify checkpoint has correct workflow_id
|
||||
checkpoint = checkpoints_after[0]
|
||||
assert checkpoint.workflow_id == test_workflow.id
|
||||
@@ -0,0 +1,365 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for cleanup hook registration and execution."""
|
||||
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentRunResponse, ChatMessage, Role, TextContent
|
||||
|
||||
from agent_framework_devui import register_cleanup
|
||||
from agent_framework_devui._discovery import EntityDiscovery
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_registry():
|
||||
"""Clear the cleanup registry before each test."""
|
||||
import agent_framework_devui
|
||||
|
||||
agent_framework_devui._cleanup_registry.clear()
|
||||
yield
|
||||
agent_framework_devui._cleanup_registry.clear()
|
||||
|
||||
|
||||
class MockAgent:
|
||||
"""Mock agent for testing."""
|
||||
|
||||
def __init__(self, name: str = "TestAgent"):
|
||||
self.id = f"test-{name.lower()}"
|
||||
self.name = name
|
||||
self.description = "Test agent for cleanup hooks"
|
||||
self.cleanup_called = False
|
||||
self.async_cleanup_called = False
|
||||
|
||||
async def run_stream(self, messages=None, *, thread=None, **kwargs):
|
||||
"""Mock streaming run method."""
|
||||
yield AgentRunResponse(
|
||||
messages=[ChatMessage(role=Role.ASSISTANT, content=[TextContent(text="Test response")])],
|
||||
inner_messages=[],
|
||||
)
|
||||
|
||||
|
||||
class MockCredential:
|
||||
"""Mock credential object for testing cleanup."""
|
||||
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
async def close(self):
|
||||
"""Mock async close method."""
|
||||
self.closed = True
|
||||
|
||||
|
||||
class MockSyncResource:
|
||||
"""Mock synchronous resource for testing cleanup."""
|
||||
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
"""Mock sync close method."""
|
||||
self.closed = True
|
||||
|
||||
|
||||
# Test 1: Register single cleanup hook
|
||||
async def test_register_cleanup_single_hook():
|
||||
"""Test registering a single cleanup hook for an entity."""
|
||||
agent = MockAgent("SingleHook")
|
||||
credential = MockCredential()
|
||||
|
||||
# Register cleanup
|
||||
register_cleanup(agent, credential.close)
|
||||
|
||||
# Verify credential not closed yet
|
||||
assert not credential.closed
|
||||
|
||||
# Simulate discovery and registration
|
||||
discovery = EntityDiscovery()
|
||||
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
|
||||
discovery.register_entity(entity_info.id, entity_info, agent)
|
||||
|
||||
# Get cleanup hooks
|
||||
hooks = discovery.get_cleanup_hooks(entity_info.id)
|
||||
assert len(hooks) == 1
|
||||
|
||||
# Execute hook
|
||||
await hooks[0]()
|
||||
assert credential.closed
|
||||
|
||||
|
||||
# Test 2: Register multiple cleanup hooks
|
||||
async def test_register_cleanup_multiple_hooks():
|
||||
"""Test registering multiple cleanup hooks for a single entity."""
|
||||
agent = MockAgent("MultipleHooks")
|
||||
credential1 = MockCredential()
|
||||
credential2 = MockCredential()
|
||||
sync_resource = MockSyncResource()
|
||||
|
||||
# Register multiple hooks at once
|
||||
register_cleanup(agent, credential1.close, credential2.close, sync_resource.close)
|
||||
|
||||
# Verify nothing closed yet
|
||||
assert not credential1.closed
|
||||
assert not credential2.closed
|
||||
assert not sync_resource.closed
|
||||
|
||||
# Simulate discovery and registration
|
||||
discovery = EntityDiscovery()
|
||||
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
|
||||
discovery.register_entity(entity_info.id, entity_info, agent)
|
||||
|
||||
# Get and execute hooks
|
||||
hooks = discovery.get_cleanup_hooks(entity_info.id)
|
||||
assert len(hooks) == 3
|
||||
|
||||
# Execute all hooks
|
||||
for hook in hooks:
|
||||
if asyncio.iscoroutinefunction(hook):
|
||||
await hook()
|
||||
else:
|
||||
hook()
|
||||
|
||||
assert credential1.closed
|
||||
assert credential2.closed
|
||||
assert sync_resource.closed
|
||||
|
||||
|
||||
# Test 3: Register cleanup hooks incrementally
|
||||
async def test_register_cleanup_incremental():
|
||||
"""Test registering cleanup hooks in multiple calls."""
|
||||
agent = MockAgent("IncrementalHooks")
|
||||
credential1 = MockCredential()
|
||||
credential2 = MockCredential()
|
||||
|
||||
# Register hooks incrementally
|
||||
register_cleanup(agent, credential1.close)
|
||||
register_cleanup(agent, credential2.close)
|
||||
|
||||
# Simulate discovery and registration
|
||||
discovery = EntityDiscovery()
|
||||
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
|
||||
discovery.register_entity(entity_info.id, entity_info, agent)
|
||||
|
||||
# Should have both hooks
|
||||
hooks = discovery.get_cleanup_hooks(entity_info.id)
|
||||
assert len(hooks) == 2
|
||||
|
||||
# Execute all hooks
|
||||
for hook in hooks:
|
||||
await hook()
|
||||
|
||||
assert credential1.closed
|
||||
assert credential2.closed
|
||||
|
||||
|
||||
# Test 4: Test with no cleanup hooks
|
||||
async def test_no_cleanup_hooks():
|
||||
"""Test entity without any cleanup hooks registered."""
|
||||
agent = MockAgent("NoHooks")
|
||||
|
||||
# Don't register any cleanup hooks
|
||||
discovery = EntityDiscovery()
|
||||
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
|
||||
discovery.register_entity(entity_info.id, entity_info, agent)
|
||||
|
||||
# Should return empty list
|
||||
hooks = discovery.get_cleanup_hooks(entity_info.id)
|
||||
assert len(hooks) == 0
|
||||
|
||||
|
||||
# Test 5: Test cleanup with async and sync hooks mixed
|
||||
async def test_mixed_async_sync_hooks():
|
||||
"""Test that both async and sync cleanup hooks work together."""
|
||||
agent = MockAgent("MixedHooks")
|
||||
async_resource = MockCredential()
|
||||
sync_resource = MockSyncResource()
|
||||
|
||||
# Register both types
|
||||
register_cleanup(agent, async_resource.close, sync_resource.close)
|
||||
|
||||
# Simulate discovery and registration
|
||||
discovery = EntityDiscovery()
|
||||
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
|
||||
discovery.register_entity(entity_info.id, entity_info, agent)
|
||||
|
||||
# Get and execute hooks with proper async/sync handling
|
||||
hooks = discovery.get_cleanup_hooks(entity_info.id)
|
||||
assert len(hooks) == 2
|
||||
|
||||
import inspect
|
||||
|
||||
for hook in hooks:
|
||||
if inspect.iscoroutinefunction(hook):
|
||||
await hook()
|
||||
else:
|
||||
hook()
|
||||
|
||||
assert async_resource.closed
|
||||
assert sync_resource.closed
|
||||
|
||||
|
||||
# Test 6: Test error handling in cleanup hooks
|
||||
async def test_cleanup_hook_error_handling():
|
||||
"""Test that errors in cleanup hooks don't break execution."""
|
||||
agent = MockAgent("ErrorHooks")
|
||||
credential = MockCredential()
|
||||
|
||||
def failing_hook():
|
||||
raise RuntimeError("Intentional error for testing")
|
||||
|
||||
# Register failing hook and valid hook
|
||||
register_cleanup(agent, failing_hook, credential.close)
|
||||
|
||||
# Simulate discovery and registration
|
||||
discovery = EntityDiscovery()
|
||||
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
|
||||
discovery.register_entity(entity_info.id, entity_info, agent)
|
||||
|
||||
# Get hooks
|
||||
hooks = discovery.get_cleanup_hooks(entity_info.id)
|
||||
assert len(hooks) == 2
|
||||
|
||||
# Execute hooks with error handling (like _server.py does)
|
||||
import inspect
|
||||
|
||||
for hook in hooks:
|
||||
try:
|
||||
if inspect.iscoroutinefunction(hook):
|
||||
await hook()
|
||||
else:
|
||||
hook()
|
||||
except Exception:
|
||||
pass # Ignore errors like the server does
|
||||
|
||||
# Second hook should still execute despite first one failing
|
||||
await credential.close()
|
||||
assert credential.closed
|
||||
|
||||
|
||||
# Test 7: Test ValueError when no hooks provided
|
||||
def test_register_cleanup_no_hooks_error():
|
||||
"""Test that register_cleanup raises ValueError when no hooks provided."""
|
||||
agent = MockAgent("NoHooksError")
|
||||
|
||||
with pytest.raises(ValueError, match="At least one cleanup hook required"):
|
||||
register_cleanup(agent)
|
||||
|
||||
|
||||
# Test 8: Test file-based discovery with cleanup hooks
|
||||
async def test_cleanup_with_file_based_discovery():
|
||||
"""Test that cleanup hooks work with file-based entity discovery."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create agent directory
|
||||
agent_dir = temp_path / "test_agent"
|
||||
agent_dir.mkdir()
|
||||
|
||||
# Write agent module with cleanup registration
|
||||
agent_file = agent_dir / "__init__.py"
|
||||
agent_file.write_text("""
|
||||
from agent_framework import AgentRunResponse, ChatMessage, Role, TextContent
|
||||
from agent_framework_devui import register_cleanup
|
||||
|
||||
class MockCredential:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
# Create credential and agent
|
||||
credential = MockCredential()
|
||||
|
||||
class TestAgent:
|
||||
id = "test-agent"
|
||||
name = "Test Agent"
|
||||
description = "Test agent with cleanup"
|
||||
|
||||
async def run_stream(self, messages=None, *, thread=None, **kwargs):
|
||||
yield AgentRunResponse(
|
||||
messages=[ChatMessage(role=Role.ASSISTANT, content=[TextContent(text="Test")])],
|
||||
inner_messages=[],
|
||||
)
|
||||
|
||||
agent = TestAgent()
|
||||
|
||||
# Register cleanup at module level
|
||||
register_cleanup(agent, credential.close)
|
||||
""")
|
||||
|
||||
# Discover entities
|
||||
discovery = EntityDiscovery(str(temp_path))
|
||||
await discovery.discover_entities()
|
||||
|
||||
# Load the entity (triggers module import)
|
||||
await discovery.load_entity("test_agent")
|
||||
|
||||
# Verify cleanup hooks were registered
|
||||
hooks = discovery.get_cleanup_hooks("test_agent")
|
||||
assert len(hooks) == 1
|
||||
|
||||
|
||||
# Test 9: Test cleanup execution order
|
||||
async def test_cleanup_execution_order():
|
||||
"""Test that cleanup hooks execute in registration order."""
|
||||
agent = MockAgent("OrderTest")
|
||||
execution_order = []
|
||||
|
||||
def hook1():
|
||||
execution_order.append(1)
|
||||
|
||||
def hook2():
|
||||
execution_order.append(2)
|
||||
|
||||
def hook3():
|
||||
execution_order.append(3)
|
||||
|
||||
# Register in specific order
|
||||
register_cleanup(agent, hook1, hook2, hook3)
|
||||
|
||||
# Simulate discovery and registration
|
||||
discovery = EntityDiscovery()
|
||||
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
|
||||
discovery.register_entity(entity_info.id, entity_info, agent)
|
||||
|
||||
# Execute hooks
|
||||
hooks = discovery.get_cleanup_hooks(entity_info.id)
|
||||
for hook in hooks:
|
||||
hook()
|
||||
|
||||
# Verify execution order
|
||||
assert execution_order == [1, 2, 3]
|
||||
|
||||
|
||||
# Test 10: Test custom cleanup logic
|
||||
async def test_custom_cleanup_logic():
|
||||
"""Test registering custom cleanup function with complex logic."""
|
||||
agent = MockAgent("CustomCleanup")
|
||||
cleanup_executed = False
|
||||
resources_closed = []
|
||||
|
||||
async def custom_cleanup():
|
||||
nonlocal cleanup_executed
|
||||
cleanup_executed = True
|
||||
resources_closed.append("credential")
|
||||
resources_closed.append("session")
|
||||
resources_closed.append("cache")
|
||||
|
||||
register_cleanup(agent, custom_cleanup)
|
||||
|
||||
# Simulate discovery and registration
|
||||
discovery = EntityDiscovery()
|
||||
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
|
||||
discovery.register_entity(entity_info.id, entity_info, agent)
|
||||
|
||||
# Execute hooks
|
||||
hooks = discovery.get_cleanup_hooks(entity_info.id)
|
||||
assert len(hooks) == 1
|
||||
|
||||
await hooks[0]()
|
||||
|
||||
assert cleanup_executed
|
||||
assert resources_closed == ["credential", "session", "cache"]
|
||||
@@ -415,6 +415,56 @@ async def test_executor_action_events(mapper: MessageMapper, test_request: Agent
|
||||
assert "Executor failed" in str(events[0].item["error"]["message"])
|
||||
|
||||
|
||||
async def test_magentic_agent_delta_creates_message_container(
|
||||
mapper: MessageMapper, test_request: AgentFrameworkRequest
|
||||
) -> None:
|
||||
"""Test that MagenticAgentDeltaEvent creates message containers (Option A implementation)."""
|
||||
|
||||
# Create mock MagenticAgentDeltaEvent that mimics the real class
|
||||
from dataclasses import dataclass
|
||||
|
||||
try:
|
||||
from agent_framework import WorkflowEvent
|
||||
|
||||
@dataclass
|
||||
class MagenticAgentDeltaEvent(WorkflowEvent): # Inherit from WorkflowEvent
|
||||
agent_id: str
|
||||
text: str | None = None
|
||||
|
||||
except ImportError:
|
||||
# Fallback if WorkflowEvent is not available
|
||||
@dataclass
|
||||
class MagenticAgentDeltaEvent: # Use the expected name directly
|
||||
agent_id: str
|
||||
text: str | None = None
|
||||
|
||||
# First delta should create message container
|
||||
first_delta = MagenticAgentDeltaEvent(agent_id="test_agent", text="Hello ")
|
||||
events = await mapper.convert_event(first_delta, test_request)
|
||||
|
||||
# Should emit 3 events: message container, content part, and text delta
|
||||
assert len(events) == 3
|
||||
assert events[0].type == "response.output_item.added"
|
||||
assert events[0].item.type == "message" # Message, not executor_action!
|
||||
assert events[0].item.metadata["agent_id"] == "test_agent"
|
||||
assert events[0].item.metadata["source"] == "magentic"
|
||||
message_id = events[0].item.id
|
||||
|
||||
# Check text delta references the message ID
|
||||
assert events[2].type == "response.output_text.delta"
|
||||
assert events[2].item_id == message_id
|
||||
assert events[2].delta == "Hello "
|
||||
|
||||
# Second delta should NOT create new container
|
||||
second_delta = MagenticAgentDeltaEvent(agent_id="test_agent", text="world!")
|
||||
events = await mapper.convert_event(second_delta, test_request)
|
||||
|
||||
# Only text delta, no new container
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "response.output_text.delta"
|
||||
assert events[0].item_id == message_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Simple test runner
|
||||
async def run_all_tests() -> None:
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
|
||||
# Add parent package to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from agent_framework_devui._utils import generate_input_schema
|
||||
from agent_framework_devui._utils import extract_response_type_from_executor, generate_input_schema
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -132,6 +133,99 @@ def test_schema_generation_error_handling():
|
||||
pass
|
||||
|
||||
|
||||
def test_extract_response_type_from_executor():
|
||||
"""Test extraction of response type from @response_handler methods."""
|
||||
try:
|
||||
from agent_framework import Executor, WorkflowContext, handler, response_handler
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# Define test request and response types
|
||||
@dataclass
|
||||
class TestApprovalRequest:
|
||||
"""Test request for approval."""
|
||||
|
||||
prompt: str
|
||||
context: str
|
||||
|
||||
class TestDecision(BaseModel):
|
||||
"""Test decision response."""
|
||||
|
||||
decision: Literal["approve", "reject"] = Field(description="User's decision")
|
||||
reason: str = Field(description="Reason for decision", default="")
|
||||
|
||||
# Create test executor with @response_handler
|
||||
class TestExecutor(Executor):
|
||||
"""Test executor with response handler."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext) -> None:
|
||||
"""Regular handler to satisfy executor requirements."""
|
||||
# Request info that will be handled by response_handler
|
||||
request = TestApprovalRequest(prompt="Test", context="Test context")
|
||||
await ctx.request_info(request, TestDecision)
|
||||
|
||||
@response_handler
|
||||
async def handle_approval(
|
||||
self, original_request: TestApprovalRequest, response: TestDecision, ctx: WorkflowContext
|
||||
) -> None:
|
||||
"""Handle approval response."""
|
||||
pass
|
||||
|
||||
# Test extraction
|
||||
executor = TestExecutor()
|
||||
extracted_type = extract_response_type_from_executor(executor, TestApprovalRequest)
|
||||
|
||||
# Verify correct type was extracted
|
||||
assert extracted_type is not None, "Should extract response type from @response_handler"
|
||||
assert extracted_type == TestDecision, f"Expected TestDecision, got {extracted_type}"
|
||||
|
||||
# Test full schema generation pipeline
|
||||
schema = generate_input_schema(extracted_type)
|
||||
assert schema is not None
|
||||
assert isinstance(schema, dict)
|
||||
assert "properties" in schema
|
||||
assert "decision" in schema["properties"]
|
||||
assert "enum" in schema["properties"]["decision"]
|
||||
assert schema["properties"]["decision"]["enum"] == ["approve", "reject"]
|
||||
|
||||
except ImportError as e:
|
||||
pytest.skip(f"Required dependencies not available: {e}")
|
||||
|
||||
|
||||
def test_extract_response_type_no_match():
|
||||
"""Test that extraction returns None when no matching handler exists."""
|
||||
try:
|
||||
from agent_framework import Executor, WorkflowContext, handler
|
||||
|
||||
@dataclass
|
||||
class UnmatchedRequest:
|
||||
"""Request type with no handler."""
|
||||
|
||||
data: str
|
||||
|
||||
class MinimalExecutor(Executor):
|
||||
"""Executor with a handler but no matching response_handler."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="minimal_executor")
|
||||
|
||||
@handler
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext) -> None:
|
||||
"""Regular handler."""
|
||||
pass
|
||||
|
||||
executor = MinimalExecutor()
|
||||
extracted_type = extract_response_type_from_executor(executor, UnmatchedRequest)
|
||||
|
||||
assert extracted_type is None, "Should return None when no matching handler exists"
|
||||
|
||||
except ImportError as e:
|
||||
pytest.skip(f"Required dependencies not available: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Simple test runner for manual execution
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -241,6 +241,100 @@ async def test_multiple_credential_attributes() -> None:
|
||||
assert mock_cred2.close.called, "Async credential should be closed"
|
||||
|
||||
|
||||
def test_ui_mode_configuration():
|
||||
"""Test UI mode configuration."""
|
||||
dev_server = DevServer(mode="developer")
|
||||
assert dev_server.mode == "developer"
|
||||
|
||||
user_server = DevServer(mode="user")
|
||||
assert user_server.mode == "user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_restrictions_in_user_mode():
|
||||
"""Test that developer APIs are restricted in user mode."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Create servers with different modes
|
||||
dev_server = DevServer(mode="developer")
|
||||
user_server = DevServer(mode="user")
|
||||
|
||||
dev_app = dev_server.create_app()
|
||||
user_app = user_server.create_app()
|
||||
|
||||
dev_client = TestClient(dev_app)
|
||||
user_client = TestClient(user_app)
|
||||
|
||||
# Test 1: Health endpoint should work in both modes
|
||||
assert dev_client.get("/health").status_code == 200
|
||||
assert user_client.get("/health").status_code == 200
|
||||
|
||||
# Test 2: Meta endpoint should reflect correct mode
|
||||
dev_meta = dev_client.get("/meta").json()
|
||||
assert dev_meta["ui_mode"] == "developer"
|
||||
|
||||
user_meta = user_client.get("/meta").json()
|
||||
assert user_meta["ui_mode"] == "user"
|
||||
|
||||
# Test 3: Entity listing should work in both modes
|
||||
assert dev_client.get("/v1/entities").status_code == 200
|
||||
assert user_client.get("/v1/entities").status_code == 200
|
||||
|
||||
# Test 4: Entity info should be restricted in user mode
|
||||
dev_response = dev_client.get("/v1/entities/test_agent/info")
|
||||
assert dev_response.status_code in [200, 404, 500] # Not 403
|
||||
|
||||
user_response = user_client.get("/v1/entities/test_agent/info")
|
||||
assert user_response.status_code == 403
|
||||
error_data = user_response.json()
|
||||
# FastAPI wraps HTTPException detail in 'detail' field
|
||||
error = error_data.get("detail", {}).get("error") or error_data.get("error")
|
||||
assert error is not None
|
||||
assert "developer mode" in error["message"].lower()
|
||||
assert error["code"] == "developer_mode_required"
|
||||
|
||||
# Test 5: Hot reload should be restricted in user mode
|
||||
dev_response = dev_client.post("/v1/entities/test_agent/reload")
|
||||
assert dev_response.status_code in [200, 404, 500] # Not 403
|
||||
|
||||
user_response = user_client.post("/v1/entities/test_agent/reload")
|
||||
assert user_response.status_code == 403
|
||||
error_data = user_response.json()
|
||||
error = error_data.get("detail", {}).get("error") or error_data.get("error")
|
||||
assert "developer mode" in error["message"].lower()
|
||||
|
||||
# Test 6: Deployment endpoints should be restricted in user mode
|
||||
# List deployments (simplest test - no payload needed)
|
||||
user_response = user_client.get("/v1/deployments")
|
||||
assert user_response.status_code == 403
|
||||
error_data = user_response.json()
|
||||
error = error_data.get("detail", {}).get("error") or error_data.get("error")
|
||||
assert "developer mode" in error["message"].lower()
|
||||
|
||||
# Get deployment
|
||||
user_response = user_client.get("/v1/deployments/test-id")
|
||||
assert user_response.status_code == 403
|
||||
|
||||
# Delete deployment
|
||||
user_response = user_client.delete("/v1/deployments/test-id")
|
||||
assert user_response.status_code == 403
|
||||
|
||||
# Test 7: Conversation endpoints should work in both modes
|
||||
dev_response = dev_client.post("/v1/conversations", json={})
|
||||
assert dev_response.status_code == 200
|
||||
|
||||
user_response = user_client.post("/v1/conversations", json={})
|
||||
assert user_response.status_code == 200
|
||||
|
||||
# Test 8: Chat endpoint should work in both modes
|
||||
chat_payload = {"model": "test_agent", "input": "Hello"}
|
||||
dev_response = dev_client.post("/v1/responses", json=chat_payload)
|
||||
assert dev_response.status_code in [200, 404] # 404 if agent doesn't exist
|
||||
|
||||
user_response = user_client.post("/v1/responses", json=chat_payload)
|
||||
assert user_response.status_code in [200, 404]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Simple test runner
|
||||
async def run_tests():
|
||||
@@ -273,3 +367,44 @@ class WeatherAgent:
|
||||
await executor.execute_sync(request)
|
||||
|
||||
asyncio.run(run_tests())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkpoint_api_endpoints(test_entities_dir):
|
||||
"""Test checkpoint list and delete API endpoints."""
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
|
||||
server = DevServer(entities_dir=test_entities_dir)
|
||||
executor = await server._ensure_executor()
|
||||
|
||||
# Create a conversation
|
||||
conversation = executor.conversation_store.create_conversation(metadata={"name": "Test Session"})
|
||||
conv_id = conversation.id
|
||||
|
||||
# Get checkpoint storage and add a checkpoint
|
||||
storage = executor.checkpoint_manager.get_checkpoint_storage(conv_id)
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id="test_checkpoint_1",
|
||||
workflow_id="test_workflow",
|
||||
shared_state={"key": "value"},
|
||||
iteration_count=1,
|
||||
)
|
||||
await storage.save_checkpoint(checkpoint)
|
||||
|
||||
# Test list checkpoints endpoint
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
assert len(checkpoints) == 1
|
||||
assert checkpoints[0].checkpoint_id == "test_checkpoint_1"
|
||||
assert checkpoints[0].workflow_id == "test_workflow"
|
||||
|
||||
# Test delete checkpoint endpoint
|
||||
deleted = await storage.delete_checkpoint("test_checkpoint_1")
|
||||
assert deleted is True
|
||||
|
||||
# Verify checkpoint was deleted
|
||||
remaining = await storage.list_checkpoints()
|
||||
assert len(remaining) == 0
|
||||
|
||||
# Test delete non-existent checkpoint
|
||||
deleted = await storage.delete_checkpoint("nonexistent")
|
||||
assert deleted is False
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Auto-generated Dockerfiles from DevUI deployment
|
||||
*/Dockerfile
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Environment files (may contain secrets)
|
||||
.env
|
||||
*.env
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
"""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.
|
||||
The following sample demonstrates a comprehensive 4-step workflow with multiple executors
|
||||
that process, detect spam, and handle email messages. This workflow illustrates
|
||||
complex branching logic with human-in-the-loop approval and realistic processing delays.
|
||||
|
||||
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
|
||||
2. Spam Detector - Analyzes content and determines if the message is spam (with human approval)
|
||||
3a. Spam Handler - Processes spam messages (quarantine, log, remove)
|
||||
3b. Message Responder - Handles legitimate messages (validate, respond)
|
||||
4. Final Processor - Completes the workflow with logging and cleanup
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Annotated
|
||||
|
||||
from agent_framework import (
|
||||
Case,
|
||||
@@ -26,10 +26,18 @@ from agent_framework import (
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import Never
|
||||
|
||||
# Define response model with clear user guidance
|
||||
class SpamDecision(BaseModel):
|
||||
"""User's decision on whether the email is spam."""
|
||||
decision: Literal["spam", "not spam"] = Field(
|
||||
description="Enter 'spam' to mark as spam, or 'not spam' to mark as legitimate"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailContent:
|
||||
@@ -41,25 +49,17 @@ class EmailContent:
|
||||
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
|
||||
email_content: EmailContent
|
||||
is_spam: bool = False
|
||||
confidence_score: float = 0.0
|
||||
spam_reasons: list[str] | None = None
|
||||
human_reviewed: bool = False
|
||||
human_decision: str | None = None
|
||||
ai_original_classification: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize spam_reasons list if None."""
|
||||
@@ -67,6 +67,16 @@ class SpamDetectorResponse:
|
||||
self.spam_reasons = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpamApprovalRequest:
|
||||
"""Human-in-the-loop approval request for spam classification."""
|
||||
|
||||
email_message: str = ""
|
||||
detected_as_spam: bool = False
|
||||
confidence: float = 0.0
|
||||
reasons: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessingResult:
|
||||
"""A data class to hold the final processing result."""
|
||||
@@ -78,6 +88,9 @@ class ProcessingResult:
|
||||
is_spam: bool
|
||||
confidence_score: float
|
||||
spam_reasons: list[str]
|
||||
was_human_reviewed: bool = False
|
||||
human_override: str | None = None
|
||||
ai_original_decision: bool = False
|
||||
|
||||
|
||||
class EmailRequest(BaseModel):
|
||||
@@ -115,18 +128,27 @@ class EmailPreprocessor(Executor):
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class ContentAnalyzer(Executor):
|
||||
"""Step 2: An executor that analyzes email content and structure."""
|
||||
|
||||
|
||||
class SpamDetector(Executor):
|
||||
"""Step 2: An executor that analyzes content and determines if a message is spam."""
|
||||
|
||||
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_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
|
||||
async def handle_email_content(self, email_content: EmailContent, ctx: WorkflowContext[SpamApprovalRequest]) -> None:
|
||||
"""Analyze email content and determine if the message is spam, then request human approval."""
|
||||
await asyncio.sleep(2.0) # Simulate analysis and detection time
|
||||
|
||||
# Simulate content analysis
|
||||
email_text = email_content.cleaned_message
|
||||
|
||||
# Analyze content for risk indicators
|
||||
contains_links = "http" in email_text or "www" in email_text
|
||||
has_attachments = "attachment" in email_text
|
||||
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: list[str] = []
|
||||
@@ -139,32 +161,7 @@ class ContentAnalyzer(Executor):
|
||||
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
|
||||
@@ -175,29 +172,100 @@ class SpamDetector(Executor):
|
||||
spam_score += 0.4
|
||||
spam_reasons.append(f"spam_keywords: {keyword_matches}")
|
||||
|
||||
if analysis.email_content.has_suspicious_patterns:
|
||||
if email_content.has_suspicious_patterns:
|
||||
spam_score += 0.3
|
||||
spam_reasons.append("suspicious_patterns")
|
||||
|
||||
if len(analysis.risk_indicators) >= 3:
|
||||
if len(risk_indicators) >= 3:
|
||||
spam_score += 0.2
|
||||
spam_reasons.append("high_risk_indicators")
|
||||
|
||||
if analysis.sentiment_score < 0.4:
|
||||
if 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
|
||||
# Store detection result in executor state for later use
|
||||
# Store minimal data needed (not complex objects that don't serialize well)
|
||||
await ctx.set_executor_state({
|
||||
"original_message": email_content.original_message,
|
||||
"cleaned_message": email_content.cleaned_message,
|
||||
"word_count": email_content.word_count,
|
||||
"has_suspicious_patterns": email_content.has_suspicious_patterns,
|
||||
"is_spam": is_spam,
|
||||
"ai_original_classification": is_spam, # Store original AI decision
|
||||
"confidence_score": spam_score,
|
||||
"spam_reasons": spam_reasons
|
||||
})
|
||||
|
||||
# Request human approval before proceeding using new API
|
||||
approval_request = SpamApprovalRequest(
|
||||
email_message=email_text[:200], # First 200 chars
|
||||
detected_as_spam=is_spam,
|
||||
confidence=spam_score,
|
||||
reasons=", ".join(spam_reasons) if spam_reasons else "no specific reasons"
|
||||
)
|
||||
|
||||
await ctx.request_info(
|
||||
request_data=approval_request,
|
||||
response_type=SpamDecision,
|
||||
)
|
||||
|
||||
@response_handler
|
||||
async def handle_human_response(
|
||||
self,
|
||||
original_request: SpamApprovalRequest,
|
||||
response: SpamDecision,
|
||||
ctx: WorkflowContext[SpamDetectorResponse]
|
||||
) -> None:
|
||||
"""Process human approval response and continue workflow."""
|
||||
print(f"[SpamDetector] handle_human_response called with response: {response}")
|
||||
|
||||
# Get stored detection result
|
||||
state = await ctx.get_executor_state() or {}
|
||||
print(f"[SpamDetector] Retrieved state: {state}")
|
||||
ai_original = state.get("ai_original_classification", False)
|
||||
confidence_score = state.get("confidence_score", 0.0)
|
||||
spam_reasons = state.get("spam_reasons", [])
|
||||
|
||||
# Parse human decision from the response model
|
||||
human_decision = response.decision.strip().lower()
|
||||
|
||||
# Determine final classification based on human input
|
||||
if human_decision in ["not spam"]:
|
||||
is_spam = False
|
||||
elif human_decision in ["spam"]:
|
||||
is_spam = True
|
||||
else:
|
||||
# Default to AI decision if unclear
|
||||
is_spam = ai_original
|
||||
|
||||
# Reconstruct EmailContent from stored primitives
|
||||
email_content = EmailContent(
|
||||
original_message=state.get("original_message", ""),
|
||||
cleaned_message=state.get("cleaned_message", ""),
|
||||
word_count=state.get("word_count", 0),
|
||||
has_suspicious_patterns=state.get("has_suspicious_patterns", False)
|
||||
)
|
||||
|
||||
result = SpamDetectorResponse(
|
||||
email_content=email_content,
|
||||
is_spam=is_spam,
|
||||
confidence_score=confidence_score,
|
||||
spam_reasons=spam_reasons,
|
||||
human_reviewed=True,
|
||||
human_decision=response.decision,
|
||||
ai_original_classification=ai_original
|
||||
)
|
||||
|
||||
print(f"[SpamDetector] Sending SpamDetectorResponse: is_spam={is_spam}, confidence={confidence_score}, human_reviewed=True")
|
||||
await ctx.send_message(result)
|
||||
print(f"[SpamDetector] Message sent successfully")
|
||||
|
||||
|
||||
class SpamHandler(Executor):
|
||||
"""Step 4a: An executor that handles spam messages with quarantine and logging."""
|
||||
"""Step 3a: An executor that handles spam messages with quarantine and logging."""
|
||||
|
||||
@handler
|
||||
async def handle_spam_detection(
|
||||
@@ -212,20 +280,23 @@ class SpamHandler(Executor):
|
||||
await asyncio.sleep(2.2) # Simulate spam handling time
|
||||
|
||||
result = ProcessingResult(
|
||||
original_message=spam_result.analysis.email_content.original_message,
|
||||
original_message=spam_result.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 [],
|
||||
was_human_reviewed=spam_result.human_reviewed,
|
||||
human_override=spam_result.human_decision,
|
||||
ai_original_decision=spam_result.ai_original_classification,
|
||||
)
|
||||
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class MessageResponder(Executor):
|
||||
"""Step 4b: An executor that responds to legitimate messages."""
|
||||
class LegitimateMessageHandler(Executor):
|
||||
"""Step 3b: An executor that handles legitimate (non-spam) messages."""
|
||||
|
||||
@handler
|
||||
async def handle_spam_detection(
|
||||
@@ -240,20 +311,23 @@ class MessageResponder(Executor):
|
||||
await asyncio.sleep(2.5) # Simulate response time
|
||||
|
||||
result = ProcessingResult(
|
||||
original_message=spam_result.analysis.email_content.original_message,
|
||||
action_taken="responded_and_filed",
|
||||
original_message=spam_result.email_content.original_message,
|
||||
action_taken="delivered_to_inbox",
|
||||
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 [],
|
||||
was_human_reviewed=spam_result.human_reviewed,
|
||||
human_override=spam_result.human_decision,
|
||||
ai_original_decision=spam_result.ai_original_classification,
|
||||
)
|
||||
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class FinalProcessor(Executor):
|
||||
"""Step 5: An executor that completes the workflow with final logging and cleanup."""
|
||||
"""Step 4: An executor that completes the workflow with final logging and cleanup."""
|
||||
|
||||
@handler
|
||||
async def handle_processing_result(
|
||||
@@ -266,50 +340,98 @@ class FinalProcessor(Executor):
|
||||
|
||||
total_time = result.processing_time + 1.5
|
||||
|
||||
# Include classification details in completion message
|
||||
# Build classification status with human review info
|
||||
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"
|
||||
)
|
||||
# Add human review context
|
||||
review_status = ""
|
||||
if result.was_human_reviewed:
|
||||
if result.ai_original_decision != result.is_spam:
|
||||
review_status = " (human-overridden)"
|
||||
else:
|
||||
review_status = " (human-verified)"
|
||||
|
||||
# Build appropriate message based on classification
|
||||
if result.is_spam:
|
||||
# For spam messages
|
||||
spam_indicators = ", ".join(result.spam_reasons) if result.spam_reasons else "none detected"
|
||||
|
||||
if result.was_human_reviewed:
|
||||
ai_status = "SPAM" if result.ai_original_decision else "LEGITIMATE"
|
||||
human_decision = result.human_override if result.human_override else "unknown"
|
||||
|
||||
completion_message = (
|
||||
f"Email classified as {classification}{review_status}.\n"
|
||||
f"AI detected: {ai_status} (confidence: {result.confidence_score:.2f})\n"
|
||||
f"Human reviewer: {human_decision}\n"
|
||||
f"Spam indicators: {spam_indicators}\n"
|
||||
f"Action: Message quarantined for review\n"
|
||||
f"Processing time: {total_time:.1f}s"
|
||||
)
|
||||
else:
|
||||
completion_message = (
|
||||
f"Email classified as {classification} (confidence: {result.confidence_score:.2f}).\n"
|
||||
f"Spam indicators: {spam_indicators}\n"
|
||||
f"Action: Message quarantined for review\n"
|
||||
f"Processing time: {total_time:.1f}s"
|
||||
)
|
||||
else:
|
||||
# For legitimate messages
|
||||
if result.was_human_reviewed:
|
||||
ai_status = "SPAM" if result.ai_original_decision else "LEGITIMATE"
|
||||
human_decision = result.human_override if result.human_override else "unknown"
|
||||
|
||||
completion_message = (
|
||||
f"Email classified as {classification}{review_status}.\n"
|
||||
f"AI detected: {ai_status} (confidence: {result.confidence_score:.2f})\n"
|
||||
f"Human reviewer: {human_decision}\n"
|
||||
f"Action: Delivered to inbox\n"
|
||||
f"Processing time: {total_time:.1f}s"
|
||||
)
|
||||
else:
|
||||
completion_message = (
|
||||
f"Email classified as {classification} (confidence: {result.confidence_score:.2f}).\n"
|
||||
f"Action: Delivered to inbox\n"
|
||||
f"Processing time: {total_time:.1f}s"
|
||||
)
|
||||
|
||||
await ctx.yield_output(completion_message)
|
||||
|
||||
|
||||
# DevUI will provide checkpoint storage automatically via the new workflow API
|
||||
# No need to create checkpoint storage here anymore!
|
||||
|
||||
# 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
|
||||
# Create all the executors for the 4-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")
|
||||
legitimate_message_handler = LegitimateMessageHandler(id="legitimate_message_handler")
|
||||
final_processor = FinalProcessor(id="final_processor")
|
||||
|
||||
# Build the comprehensive 5-step workflow with branching logic
|
||||
# Build the comprehensive 4-step workflow with branching logic and HIL support
|
||||
# Note: No .with_checkpointing() call - DevUI will pass checkpoint_storage at runtime
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
name="Email Spam Detector",
|
||||
description="5-step email classification workflow with spam/legitimate routing",
|
||||
description="4-step email classification workflow with human-in-the-loop spam approval",
|
||||
)
|
||||
.set_start_executor(email_preprocessor)
|
||||
.add_edge(email_preprocessor, content_analyzer)
|
||||
.add_edge(content_analyzer, spam_detector)
|
||||
.add_edge(email_preprocessor, spam_detector)
|
||||
# HIL handled within spam_detector via @response_handler
|
||||
# Continue with branching logic after human approval
|
||||
# Only route SpamDetectorResponse messages (not SpamApprovalRequest)
|
||||
.add_switch_case_edge_group(
|
||||
spam_detector,
|
||||
[
|
||||
Case(condition=lambda x: x.is_spam, target=spam_handler),
|
||||
Default(target=message_responder),
|
||||
Case(condition=lambda x: isinstance(x, SpamDetectorResponse) and x.is_spam, target=spam_handler),
|
||||
Default(target=legitimate_message_handler), # Default handles non-spam and non-SpamDetectorResponse messages
|
||||
],
|
||||
)
|
||||
.add_edge(spam_handler, final_processor)
|
||||
.add_edge(message_responder, final_processor)
|
||||
.add_edge(legitimate_message_handler, final_processor)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Sample weather agent for Agent Framework Debug UI."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
@@ -14,8 +15,20 @@ from agent_framework import (
|
||||
Role,
|
||||
chat_middleware,
|
||||
function_middleware,
|
||||
ai_function
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework_devui import register_cleanup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cleanup_resources():
|
||||
"""Cleanup function that runs when DevUI shuts down."""
|
||||
logger.info("=" * 60)
|
||||
logger.info(" Cleaning up resources...")
|
||||
logger.info(" (In production, this would close credentials, sessions, etc.)")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
@chat_middleware
|
||||
@@ -93,6 +106,14 @@ def get_forecast(
|
||||
|
||||
return f"Weather forecast for {location}:\n" + "\n".join(forecast)
|
||||
|
||||
@ai_function(approval_mode="always_require")
|
||||
def send_email(
|
||||
recipient: Annotated[str, "The email address of the recipient."],
|
||||
subject: Annotated[str, "The subject of the email."],
|
||||
body: Annotated[str, "The body content of the email."],
|
||||
) -> str:
|
||||
"""Simulate sending an email."""
|
||||
return f"Email sent to {recipient} with subject '{subject}'."
|
||||
|
||||
# Agent instance following Agent Framework conventions
|
||||
agent = ChatAgent(
|
||||
@@ -106,10 +127,13 @@ agent = ChatAgent(
|
||||
chat_client=AzureOpenAIChatClient(
|
||||
api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""),
|
||||
),
|
||||
tools=[get_weather, get_forecast],
|
||||
tools=[get_weather, get_forecast, send_email],
|
||||
middleware=[security_filter_middleware, atlantis_location_filter_middleware],
|
||||
)
|
||||
|
||||
# Register cleanup hook - demonstrates resource cleanup on shutdown
|
||||
register_cleanup(agent, cleanup_resources)
|
||||
|
||||
|
||||
def main():
|
||||
"""Launch the Azure weather agent in DevUI."""
|
||||
|
||||
Reference in New Issue
Block a user