chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Test artifacts
tests/captured_messages/
# Python cache
__pycache__/
*.py[cod]
*$py.class
# Local development files
.env
*.log
# IDE files
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db
+49
View File
@@ -0,0 +1,49 @@
# DevUI Package (agent-framework-devui)
Interactive developer UI for testing and debugging agents and workflows.
## Main Classes
- **`serve()`** - Launch the DevUI server
- **`DevServer`** - The FastAPI-based development server
- **`register_cleanup()`** - Register cleanup hooks for entities
- **`CheckpointConversationManager`** - Manages conversation checkpoints
## Models
- **`AgentFrameworkRequest`** - Request model for agent invocations
- **`OpenAIResponse`** / **`OpenAIError`** - OpenAI-compatible response models
- **`DiscoveryResponse`** / **`EntityInfo`** - Entity discovery models
## Usage
```python
from agent_framework.devui import serve
agent = Agent(...)
serve(entities=[agent], port=8080, auto_open=True)
```
## CLI
```bash
# Run with auto-discovery
devui ./agents
# Run with specific entities
devui --entities my_agent.py
```
## Security Posture
DevUI is a development-only sample app, not a production hosting surface. Authentication is enabled by default.
Unauthenticated mode is allowed only on `localhost` / `127.0.0.1`; `0.0.0.0`, LAN IPs, and hostnames require
`DEVUI_AUTH_TOKEN` or `--auth-token`.
## Import Path
```python
from agent_framework.devui import serve, register_cleanup
# or directly:
from agent_framework_devui import serve
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+397
View File
@@ -0,0 +1,397 @@
# DevUI - A Sample App for Running Agents and Workflows
A lightweight, standalone sample app interface for running entities (agents/workflows) in the Microsoft Agent Framework supporting **directory-based discovery**, **in-memory entity registration**, and **sample entity gallery**.
> [!IMPORTANT]
> DevUI is a **sample app** to help you get started with the Agent Framework. It is **not** intended for production use. For production, or for features beyond what is provided in this sample app, it is recommended that you build your own custom interface and API server using the Agent Framework SDK.
![DevUI Screenshot](./docs/devuiscreen.png)
## Quick Start
```bash
# Install
pip install agent-framework-devui --pre
```
You can also launch it programmatically
```python
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
from agent_framework.devui import serve
def get_weather(location: str) -> str:
"""Get weather for a location."""
return f"Weather in {location}: 72°F and sunny"
# Create your agent
agent = Agent(
name="WeatherAgent",
client=OpenAIChatClient(),
tools=[get_weather]
)
# Launch debug UI - that's it!
serve(entities=[agent], auto_open=True)
# → Opens browser to http://localhost:8080
```
In addition, if you have agents/workflows defined in a specific directory structure (see below), you can launch DevUI from the _cli_ to discover and run them.
```bash
# Launch web UI + API server
devui ./agents --port 8080
# → Web UI: http://localhost:8080
# → API: http://localhost:8080/v1/*
```
DevUI is auth-enabled by default. Localhost starts with a generated development token logged at startup; pass it as
`Authorization: Bearer <token>` for direct API calls.
When DevUI starts with no discovered entities, it displays a **sample entity gallery** with curated examples from the Agent Framework repository. You can download these samples, review them, and run them locally to get started quickly.
## Using MCP Tools
**Important:** Don't use `async with` context managers when creating agents with MCP tools for DevUI - connections will close before execution.
```python
# ✅ Correct - DevUI handles cleanup automatically
mcp_tool = MCPStreamableHTTPTool(url="http://localhost:8011/mcp", client=client)
agent = Agent(tools=mcp_tool)
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 Agent
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework_devui import register_cleanup, serve
credential = DefaultAzureCredential()
client = OpenAIChatCompletionClient()
agent = Agent(name="MyAgent", 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`).
**Note**: `.env` files are optional but will be automatically loaded if present in the agent/workflow directory or parent entities directory. Use them to store API keys, configuration variables, and other environment-specific settings.
```
agents/
├── weather_agent/
│ ├── __init__.py # Must export: agent = Agent(...)
│ ├── agent.py
│ └── .env # Optional: API keys, config vars
├── my_workflow/
│ ├── __init__.py # Must export: workflow = WorkflowBuilder(start_executor=...)...
│ ├── workflow.py
│ └── .env # Optional: environment variables
└── .env # Optional: shared environment variables
```
### Importing from External Modules
If your agents import tools or utilities from sibling directories (e.g., `from tools.helpers import my_tool`), you must set `PYTHONPATH` to include the parent directory:
```bash
# Project structure:
# backend/
# ├── agents/
# │ └── my_agent/
# │ └── agent.py # contains: from tools.helpers import my_tool
# └── tools/
# └── helpers.py
# Run from project root with PYTHONPATH
cd backend
PYTHONPATH=. devui ./agents --port 8080
```
Without `PYTHONPATH`, Python cannot find modules in sibling directories and DevUI will report an import error.
## Viewing Telemetry (Otel Traces) in DevUI
Agent Framework emits OpenTelemetry (Otel) traces for various operations. You can view these traces in DevUI by enabling instrumentation when starting the server.
```bash
devui ./agents --instrumentation
```
## OpenAI-Compatible API
For convenience, DevUI provides an OpenAI Responses backend API. This means you can run the backend and also use the OpenAI client sdk to connect to it. Use **agent/workflow name as the entity_id in metadata**, and set streaming to `True` as needed.
```bash
# Simple - use your entity name as the entity_id in metadata
curl -X POST http://localhost:8080/v1/responses \
-H "Authorization: Bearer <devui-token>" \
-H "Content-Type: application/json" \
-d @- << 'EOF'
{
"metadata": {"entity_id": "weather_agent"},
"input": "Hello world"
}
EOF
```
Or use the OpenAI Python SDK:
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8080/v1",
api_key="<devui-token>"
)
response = client.responses.create(
metadata={"entity_id": "weather_agent"}, # Your agent/workflow name
input="What's the weather in Seattle?"
)
# Extract text from response
print(response.output[0].content[0].text)
# Supports streaming with stream=True
```
### Multi-turn Conversations
Use the standard OpenAI `conversation` parameter for multi-turn conversations:
```python
# Create a conversation
conversation = client.conversations.create(
metadata={"agent_id": "weather_agent"}
)
# Use it across multiple turns
response1 = client.responses.create(
metadata={"entity_id": "weather_agent"},
input="What's the weather in Seattle?",
conversation=conversation.id
)
response2 = client.responses.create(
metadata={"entity_id": "weather_agent"},
input="How about tomorrow?",
conversation=conversation.id # Continues the conversation!
)
```
**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 "Authorization: Bearer <devui-token>" \
-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
devui [directory] [options]
Options:
--port, -p Port (default: 8080)
--host Host (default: 127.0.0.1; non-loopback hosts require auth)
--headless API only, no UI
--no-open Don't automatically open browser
--instrumentation Enable OpenTelemetry instrumentation
--reload Enable auto-reload
--mode developer|user (default: developer)
--no-auth Disable auth for loopback-only local development
--auth-token Custom authentication token (required for non-loopback hosts unless DEVUI_AUTH_TOKEN is set)
```
### 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
# Local-only no-auth development
devui ./agents --no-auth
```
## Key Endpoints
## API Mapping
Given that DevUI offers an OpenAI Responses API, it internally maps messages and events from Agent Framework to OpenAI Responses API events (in `_mapper.py`). For transparency, this mapping is shown below:
| OpenAI Event/Type | Agent Framework Content | Status |
| ------------------------------------------------------------ | --------------------------------- | -------- |
| | **Lifecycle Events** | |
| `response.created` + `response.in_progress` | `AgentStartedEvent` | OpenAI |
| `response.completed` | `AgentCompletedEvent` | OpenAI |
| `response.failed` | `AgentFailedEvent` | OpenAI |
| `response.created` + `response.in_progress` | `WorkflowEvent (type='started')` | OpenAI |
| `response.completed` | `WorkflowEvent (type='status')` | OpenAI |
| `response.failed` | `WorkflowEvent (type='failed')` | OpenAI |
| | **Content Types** | |
| `response.content_part.added` + `response.output_text.delta` | `TextContent` | OpenAI |
| `response.reasoning_text.delta` | `TextReasoningContent` | OpenAI |
| `response.output_item.added` | `FunctionCallContent` (initial) | OpenAI |
| `response.function_call_arguments.delta` | `FunctionCallContent` (args) | OpenAI |
| `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)* | `WorkflowEvent (type='executor_invoked')` | OpenAI |
| `response.output_item.done` (ExecutorActionItem)* | `WorkflowEvent (type='executor_completed')` | OpenAI |
| `response.output_item.done` (ExecutorActionItem with error)* | `WorkflowEvent (type='executor_failed')` | OpenAI |
| `response.output_item.added` (ResponseOutputMessage) | `WorkflowEvent (type='output')` | OpenAI |
| `response.workflow_event.complete` | `WorkflowEvent` (other types) | DevUI |
| `response.trace.complete` | `WorkflowEvent (type='status')` | DevUI |
| `response.trace.complete` | `WorkflowEvent (type='warning')` | DevUI |
| | **Trace Content** | |
| `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 |
\*Uses standard OpenAI event structure but carries DevUI-specific `ExecutorActionItem` payload
- **OpenAI** = Standard OpenAI Responses API event types
- **DevUI** = Custom event types specific to Agent Framework (e.g., workflows, traces, function approvals)
### OpenAI Responses API Compliance
DevUI follows the OpenAI Responses API specification for maximum compatibility:
**OpenAI Standard Event Types Used:**
- `ResponseOutputItemAddedEvent` - Output item notifications (function calls, images, files, data)
- `ResponseOutputItemDoneEvent` - Output item completion notifications
- `Response.usage` - Token usage (in final response, not streamed)
**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)
These custom extensions are clearly namespaced and can be safely ignored by standard OpenAI clients. Note that DevUI also uses standard OpenAI events with custom payloads (e.g., `ExecutorActionItem` within `response.output_item.added`).
### Entity Management
- `GET /v1/entities` - List discovered agents/workflows
- `GET /v1/entities/{entity_id}/info` - Get detailed entity information
- `POST /v1/entities/{entity_id}/reload` - Hot reload entity (for development)
### Execution (OpenAI Responses API)
- `POST /v1/responses` - Execute agent/workflow (streaming or sync)
### Conversations (OpenAI Standard)
- `POST /v1/conversations` - Create conversation
- `GET /v1/conversations/{id}` - Get conversation
- `POST /v1/conversations/{id}` - Update conversation metadata
- `DELETE /v1/conversations/{id}` - Delete conversation
- `GET /v1/conversations?agent_id={id}` - List conversations _(DevUI extension)_
- `POST /v1/conversations/{id}/items` - Add items to conversation
- `GET /v1/conversations/{id}/items` - List conversation items
- `GET /v1/conversations/{id}/items/{item_id}` - Get conversation item
### Health
- `GET /health` - Health check
## Security
DevUI is designed as a **sample application for local development** and is not intended for production use. For
production, or for features beyond this sample app, build a custom interface and API server using the Agent Framework SDK.
Auth is enabled by default. Unauthenticated mode is allowed only when DevUI is bound to `localhost` or `127.0.0.1`.
Network-reachable binds such as `0.0.0.0`, LAN IPs, and hostnames require Bearer token authentication with an explicit
token.
**For shared development hosts:**
```bash
# Set a token explicitly before binding beyond loopback
DEVUI_AUTH_TOKEN="<secure-dev-token>" devui ./agents --mode user --host 0.0.0.0
# Or pass the token on the command line
devui ./agents --mode user --host 0.0.0.0 --auth-token "<secure-dev-token>"
```
Do not use `--no-auth` with `0.0.0.0`, LAN IPs, or hostnames. That configuration fails closed before startup.
**Security features:**
- User mode restricts developer-facing APIs
- Bearer token authentication is enabled by default
- Unauthenticated mode is loopback-only (`localhost` / `127.0.0.1`)
- Non-loopback binds require `DEVUI_AUTH_TOKEN` or `--auth-token`
- Only loads entities from local directories or in-memory registration
- No remote code execution capabilities
- Binds to localhost (127.0.0.1) by default
**Best practices:**
- Do not use DevUI as a production deployment surface
- Use `--mode user` plus `DEVUI_AUTH_TOKEN` or `--auth-token` for shared development hosts
- Review all agent/workflow code before running
- Only load entities from trusted sources
- Use `.env` files for sensitive credentials (never commit them)
## Implementation
- **Discovery**: `agent_framework_devui/_discovery.py`
- **Execution**: `agent_framework_devui/_executor.py`
- **Message Mapping**: `agent_framework_devui/_mapper.py`
- **Conversations**: `agent_framework_devui/_conversations.py`
- **API Server**: `agent_framework_devui/_server.py`
- **CLI**: `agent_framework_devui/_cli.py`
## Examples
See working implementations in `python/samples/02-agents/devui/`
## License
MIT
@@ -0,0 +1,223 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent Framework DevUI - Debug interface with OpenAI compatible API server."""
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 = Agent(...)
>>> 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 = Agent(...)
>>> 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]]: # type: ignore[reportUnusedFunction]
"""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,
port: int = 8080,
host: str = "127.0.0.1",
auto_open: bool = False,
cors_origins: list[str] | None = None,
ui_enabled: bool = True,
instrumentation_enabled: bool = False,
mode: str = "developer",
auth_enabled: bool = True,
auth_token: str | None = None,
) -> None:
"""Launch Agent Framework DevUI with simple API.
Args:
entities: List of entities for in-memory registration (IDs auto-generated)
entities_dir: Directory to scan for entities
port: Port to run server on
host: Host to bind server to
auto_open: Whether to automatically open browser
cors_origins: List of allowed CORS origins
ui_enabled: Whether to enable the UI
instrumentation_enabled: Whether to enable OpenTelemetry instrumentation
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
import uvicorn
# Validate host parameter early for security
if not re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0|[a-zA-Z0-9.-]+)$", host):
raise ValueError(f"Invalid host: {host}. Must be localhost, IP address, or valid hostname")
# Validate port parameter
if not isinstance(port, int) or not (1 <= port <= 65535):
raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535")
# Enable instrumentation if requested
if instrumentation_enabled:
from agent_framework.observability import enable_instrumentation
enable_instrumentation(enable_sensitive_data=True)
logger.info("Enabled Agent Framework instrumentation with sensitive data")
# Create server with direct parameters
server = DevServer(
entities_dir=entities_dir,
port=port,
host=host,
cors_origins=cors_origins,
ui_enabled=ui_enabled,
mode=mode,
auth_enabled=auth_enabled,
auth_token=auth_token,
)
# Register in-memory entities if provided
if entities:
logger.info(f"Registering {len(entities)} in-memory entities")
# Store entities for later registration during server startup
server.set_pending_entities(entities)
app = server.get_app()
if auto_open:
def open_browser() -> None:
import http.client
import re
import time
# Validate host and port for security
if not re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0|[a-zA-Z0-9.-]+)$", host):
logger.warning(f"Invalid host for auto-open: {host}")
return
if not isinstance(port, int) or not (1 <= port <= 65535):
logger.warning(f"Invalid port for auto-open: {port}")
return
# Wait for server to be ready by checking health endpoint
browser_url = f"http://{host}:{port}"
for _ in range(30): # 15 second timeout (30 * 0.5s)
try:
# Use http.client for safe connection handling (standard library)
conn = http.client.HTTPConnection(host, port, timeout=1)
try:
conn.request("GET", "/health")
response = conn.getresponse()
if response.status == 200:
webbrowser.open(browser_url)
return
finally:
conn.close()
except (http.client.HTTPException, OSError, TimeoutError):
pass
time.sleep(0.5)
# Fallback: open browser anyway after timeout
webbrowser.open(browser_url)
import threading
threading.Thread(target=open_browser, daemon=True).start()
logger.info(f"Starting Agent Framework DevUI on {host}:{port}")
uvicorn.run(app, host=host, port=port, log_level="info")
def main() -> None:
"""CLI entry point for devui command."""
from ._cli import main as cli_main
cli_main()
# Export main public API
__all__ = [
"AgentFrameworkRequest",
"CheckpointConversationManager",
"DevServer",
"DiscoveryResponse",
"EntityInfo",
"EnvVarRequirement",
"OpenAIError",
"OpenAIResponse",
"ResponseStreamEvent",
"main",
"register_cleanup",
"serve",
]
@@ -0,0 +1,203 @@
# Copyright (c) Microsoft. All rights reserved.
"""Command line interface for Agent Framework DevUI."""
import argparse
import logging
import os
import sys
logger = logging.getLogger(__name__)
def setup_logging(level: str = "INFO") -> None:
"""Configure logging for the server."""
log_format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
logging.basicConfig(level=getattr(logging, level.upper()), format=log_format, datefmt="%Y-%m-%d %H:%M:%S")
def create_cli_parser() -> argparse.ArgumentParser:
"""Create the command line argument parser."""
parser = argparse.ArgumentParser(
prog="devui",
description="Launch Agent Framework DevUI - Debug interface with OpenAI compatible API",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
devui # Scan current directory
devui ./agents # Scan specific directory
devui --port 8000 # Custom port
devui --headless # API only, no UI
devui --instrumentation # Enable OpenTelemetry instrumentation
""",
)
parser.add_argument(
"directory", nargs="?", default=".", help="Directory to scan for entities (default: current directory)"
)
parser.add_argument("--port", "-p", type=int, default=8080, help="Port to run server on (default: 8080)")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind server to (default: 127.0.0.1)")
parser.add_argument("--no-open", action="store_true", help="Don't automatically open browser")
parser.add_argument("--headless", action="store_true", help="Run without UI (API only)")
parser.add_argument(
"--log-level",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
default="INFO",
help="Logging level (default: INFO)",
)
parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
parser.add_argument("--instrumentation", action="store_true", help="Enable OpenTelemetry instrumentation")
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(
"--no-auth",
action="store_true",
help=(
"Disable Bearer token authentication for loopback-only local development. Non-loopback hosts require auth."
),
)
parser.add_argument(
"--auth-token",
type=str,
help="Custom Bearer token. Required for non-loopback hosts when DEVUI_AUTH_TOKEN is not set.",
)
parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}")
return parser
def get_version() -> str:
"""Get the package version."""
try:
from . import __version__
return __version__
except ImportError:
return "unknown"
def validate_directory(directory: str) -> str:
"""Validate and normalize the entities directory."""
if not directory:
directory = "."
abs_dir = os.path.abspath(directory)
if not os.path.exists(abs_dir):
print(f"Error: Directory '{directory}' does not exist", file=sys.stderr) # noqa: T201
sys.exit(1)
if not os.path.isdir(abs_dir):
print(f"Error: '{directory}' is not a directory", file=sys.stderr) # noqa: T201
sys.exit(1)
return abs_dir
def print_startup_info(
entities_dir: str, host: str, port: int, ui_enabled: bool, reload: bool, auth_token: str | None = None
) -> None:
"""Print startup information."""
print("Agent Framework DevUI") # noqa: T201
print("=" * 50) # noqa: T201
print(f"Entities directory: {entities_dir}") # noqa: T201
print(f"Server URL: http://{host}:{port}") # noqa: T201
print(f"UI enabled: {'Yes' if ui_enabled else 'No'}") # noqa: T201
print(f"Auto-reload: {'Yes' if reload else 'No'}") # noqa: T201
# 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
def main() -> None:
"""Main CLI entry point."""
parser = create_cli_parser()
args = parser.parse_args()
# Setup logging
setup_logging(args.log_level)
# Validate directory
entities_dir = validate_directory(args.directory)
# Extract parameters directly from args
ui_enabled = not args.headless
# 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:
from . import serve
serve(
entities_dir=entities_dir,
port=args.port,
host=args.host,
auto_open=not args.no_open,
ui_enabled=ui_enabled,
instrumentation_enabled=args.instrumentation,
mode=mode,
auth_enabled=not args.no_auth,
auth_token=args.auth_token, # Pass through explicit token only
)
except KeyboardInterrupt:
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
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,717 @@
# Copyright (c) Microsoft. All rights reserved.
"""Conversation storage abstraction for OpenAI Conversations API.
This module provides a clean abstraction layer for managing conversations
with in-memory message storage.
"""
from __future__ import annotations
import time
import uuid
from abc import ABC, abstractmethod
from collections.abc import MutableSequence
from typing import Any, Literal, cast
from agent_framework import AgentSession, Message
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage, WorkflowCheckpoint
from openai.types.conversations import Conversation, ConversationDeletedResource
from openai.types.conversations.conversation_item import ConversationItem
from openai.types.conversations.message import Content as OpenAIContent
from openai.types.conversations.message import Message as OpenAIMessage
from openai.types.conversations.text_content import TextContent
from openai.types.responses import (
ResponseFunctionToolCallItem,
ResponseFunctionToolCallOutputItem,
ResponseInputFile,
ResponseInputImage,
)
# 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.
Provides OpenAI Conversations API interface while managing
message storage internally.
"""
@abstractmethod
def create_conversation(
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
) -> Conversation:
"""Create a new conversation.
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 or provided ID
"""
pass
@abstractmethod
def get_conversation(self, conversation_id: str) -> Conversation | None:
"""Retrieve conversation metadata.
Args:
conversation_id: Conversation ID
Returns:
Conversation object or None if not found
"""
pass
@abstractmethod
def update_conversation(self, conversation_id: str, metadata: dict[str, str]) -> Conversation:
"""Update conversation metadata.
Args:
conversation_id: Conversation ID
metadata: New metadata dict
Returns:
Updated Conversation object
Raises:
ValueError: If conversation not found
"""
pass
@abstractmethod
def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource:
"""Delete conversation.
Args:
conversation_id: Conversation ID
Returns:
ConversationDeletedResource object
Raises:
ValueError: If conversation not found
"""
pass
@abstractmethod
async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]:
"""Add items to conversation.
Args:
conversation_id: Conversation ID
items: List of conversation items to add
Returns:
List of added ConversationItem objects
Raises:
ValueError: If conversation not found
"""
pass
@abstractmethod
async def list_items(
self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
) -> tuple[list[ConversationItem], bool]:
"""List conversation items.
Args:
conversation_id: Conversation ID
limit: Maximum number of items to return
after: Cursor for pagination (item_id)
order: Sort order ("asc" or "desc")
Returns:
Tuple of (items list, has_more boolean)
Raises:
ValueError: If conversation not found
"""
pass
@abstractmethod
async def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
"""Get a specific conversation item by ID.
Supports checkpoint items - will load full checkpoint state from storage.
For checkpoints, the full state is included in metadata.full_checkpoint.
Args:
conversation_id: Conversation ID
item_id: Item ID
Returns:
ConversationItem or None if not found
"""
pass
@abstractmethod
def get_session(self, conversation_id: str) -> AgentSession | None:
"""Get AgentSession for agent execution.
This is the critical method that allows the executor to get the
AgentSession for running agents with conversation context.
Args:
conversation_id: Conversation ID
Returns:
AgentSession object or None if not found
"""
pass
@abstractmethod
async def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
"""Filter conversations by metadata (e.g., agent_id).
Args:
metadata_filter: Metadata key-value pairs to match
Returns:
List of matching Conversation objects
"""
pass
@abstractmethod
def add_trace(self, conversation_id: str, trace_event: dict[str, Any]) -> None:
"""Add a trace event to the conversation for context inspection.
Traces capture execution metadata like token usage, timing, and LLM context
that is useful for debugging.
Args:
conversation_id: Conversation ID
trace_event: Trace event data (from ResponseTraceEvent.data)
"""
pass
@abstractmethod
def get_traces(self, conversation_id: str) -> list[dict[str, Any]]:
"""Get all trace events for a conversation.
Args:
conversation_id: Conversation ID
Returns:
List of trace event dicts, or empty list if not found
"""
pass
class InMemoryConversationStore(ConversationStore):
"""In-memory conversation storage.
This implementation stores conversations in memory with their
underlying message lists and AgentSession instances for execution.
"""
def __init__(self) -> None:
"""Initialize in-memory conversation storage.
Storage structure maps conversation IDs to conversation data including
messages, metadata, and cached ConversationItems.
"""
self._conversations: dict[str, dict[str, Any]] = {}
# 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_id: str | None = None
) -> Conversation:
"""Create a new conversation with message storage and checkpoint storage."""
conv_id = conversation_id or f"conv_{uuid.uuid4().hex}"
created_at = int(time.time())
# Create message list for internal storage and AgentSession for execution
messages: list[Message] = []
session = AgentSession(session_id=conv_id)
# Create session-scoped checkpoint storage (one per conversation)
checkpoint_storage = InMemoryCheckpointStorage()
self._conversations[conv_id] = {
"id": conv_id,
"messages": messages,
"session": session,
"checkpoint_storage": checkpoint_storage,
"metadata": metadata or {},
"created_at": created_at,
"items": [],
"traces": [], # Trace events for context inspection (token usage, timing, etc.)
}
# Initialize item index for this conversation
self._item_index[conv_id] = {}
return Conversation(id=conv_id, object="conversation", created_at=created_at, metadata=metadata)
def get_conversation(self, conversation_id: str) -> Conversation | None:
"""Retrieve conversation metadata."""
conv_data = self._conversations.get(conversation_id)
if not conv_data:
return None
return Conversation(
id=conv_data["id"],
object="conversation",
created_at=conv_data["created_at"],
metadata=conv_data.get("metadata"),
)
def update_conversation(self, conversation_id: str, metadata: dict[str, str]) -> Conversation:
"""Update conversation metadata."""
conv_data = self._conversations.get(conversation_id)
if not conv_data:
raise ValueError(f"Conversation {conversation_id} not found")
conv_data["metadata"] = metadata
return Conversation(
id=conv_data["id"],
object="conversation",
created_at=conv_data["created_at"],
metadata=metadata,
)
def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource:
"""Delete conversation."""
if conversation_id not in self._conversations:
raise ValueError(f"Conversation {conversation_id} not found")
del self._conversations[conversation_id]
# Cleanup item index
self._item_index.pop(conversation_id, None)
return ConversationDeletedResource(id=conversation_id, object="conversation.deleted", deleted=True)
async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]:
"""Add items to conversation."""
conv_data = self._conversations.get(conversation_id)
if not conv_data:
raise ValueError(f"Conversation {conversation_id} not found")
stored_messages: list[Message] = conv_data["messages"]
# Convert items to Messages and add to storage
chat_messages: list[Message] = []
for item in items:
# Simple conversion - assume text content for now
role = item.get("role", "user")
content = item.get("content", [])
first_content = cast(
dict[str, Any],
content[0] if content and isinstance(content, list) and isinstance(content[0], dict) else {},
)
text_obj = first_content.get("text", "")
text = text_obj if isinstance(text_obj, str) else str(text_obj)
chat_msg = Message(role=role, contents=[text])
chat_messages.append(chat_msg)
# Add messages to internal storage
stored_messages.extend(chat_messages)
# Create Message objects (ConversationItem is a Union - use concrete Message type)
conv_items: list[ConversationItem] = []
for msg in chat_messages:
item_id = f"item_{uuid.uuid4().hex}"
# Convert Message contents to OpenAI TextContent format
message_content: MutableSequence[OpenAIContent] = []
for content_item in msg.contents:
if content_item.type == "text":
# Extract text from TextContent object
message_content.append(TextContent(type="text", text=content_item.text or ""))
# Create Message object (concrete type from ConversationItem union)
message = OpenAIMessage(
id=item_id,
type="message", # Required discriminator for union
role=cast(MessageRole, msg.role), # Safe: Agent Framework roles match OpenAI roles,
content=message_content,
status="completed", # Required field
)
conv_items.append(message)
# Cache items
conv_data["items"].extend(conv_items)
# Update item index for O(1) lookup
if conversation_id not in self._item_index:
self._item_index[conversation_id] = {}
for conv_item in conv_items:
if conv_item.id: # Guard against None
self._item_index[conversation_id][conv_item.id] = conv_item
return conv_items
async def list_items(
self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
) -> tuple[list[ConversationItem], bool]:
"""List conversation items.
Converts stored Messages to proper OpenAI ConversationItem types:
- Messages with text/images/files → Message
- Function calls → ResponseFunctionToolCallItem
- Function results → ResponseFunctionToolCallOutputItem
"""
conv_data = self._conversations.get(conversation_id)
if not conv_data:
raise ValueError(f"Conversation {conversation_id} not found")
stored_messages: list[Message] = conv_data["messages"]
# Convert stored messages to ConversationItem types
items: list[ConversationItem] = []
af_messages = stored_messages
# Convert each AgentFramework Message to appropriate ConversationItem type(s)
for i, msg in enumerate(af_messages):
item_id = f"item_{i}"
role_str = msg.role if hasattr(msg.role, "value") else str(msg.role)
role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles
# Process each content item in the message
# A single Message may produce multiple ConversationItems
# (e.g., a message with both text and a function call)
message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = []
function_calls: list[ResponseFunctionToolCallItem] = []
function_results: list[ResponseFunctionToolCallOutputItem] = []
for content in msg.contents:
content_type = getattr(content, "type", None)
if content_type == "text":
# Text content for Message
text_value = getattr(content, "text", "")
message_contents.append(TextContent(type="text", text=text_value))
elif content_type == "data":
# Data content (images, files, PDFs)
uri = getattr(content, "uri", "")
media_type = getattr(content, "media_type", None)
if media_type and media_type.startswith("image/"):
# Convert to ResponseInputImage
message_contents.append(ResponseInputImage(type="input_image", image_url=uri, detail="auto"))
else:
# Convert to ResponseInputFile
# Extract filename from URI if possible
filename = None
if media_type == "application/pdf":
filename = "document.pdf"
message_contents.append(ResponseInputFile(type="input_file", file_url=uri, filename=filename))
elif content_type == "function_call":
# Function call - create separate ConversationItem
call_id = getattr(content, "call_id", None)
name = getattr(content, "name", "")
arguments = getattr(content, "arguments", "")
if call_id and name:
function_calls.append(
ResponseFunctionToolCallItem(
id=f"{item_id}_call_{call_id}",
call_id=call_id,
name=name,
arguments=arguments,
type="function_call",
status="completed",
)
)
elif content_type == "function_result":
# Function result - create separate ConversationItem
call_id = getattr(content, "call_id", None)
# Output is stored in the 'result' field of FunctionResultContent
result_value = getattr(content, "result", None)
# Convert result to string (it could be dict, list, or other types)
if result_value is None:
output = ""
elif isinstance(result_value, str):
output = result_value
else:
import json
try:
output = json.dumps(result_value)
except (TypeError, ValueError):
output = str(result_value)
if call_id:
function_results.append(
ResponseFunctionToolCallOutputItem(
id=f"{item_id}_result_{call_id}",
call_id=call_id,
output=output,
type="function_call_output",
status="completed",
)
)
# Create ConversationItems based on what we found
# If message has text/images/files, create a Message item
if message_contents:
message = OpenAIMessage(
id=item_id,
type="message",
role=role,
content=message_contents, # type: ignore
status="completed",
)
items.append(message)
# Add function call items
items.extend(function_calls)
# 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 = self._list_all_checkpoints(checkpoint_storage)
for checkpoint in checkpoints:
# Create a conversation item for each checkpoint with summary metadata
# Full checkpoint state is NOT included here (too large for list view)
# Use get_item() to retrieve full checkpoint details
# Calculate approximate size of checkpoint
import json
checkpoint_json = json.dumps(checkpoint.to_dict())
checkpoint_size = len(checkpoint_json.encode("utf-8"))
checkpoint_item = {
"id": f"checkpoint_{checkpoint.checkpoint_id}",
"type": "checkpoint",
"checkpoint_id": checkpoint.checkpoint_id,
# Keep workflow_id for backward compatibility with existing UI payloads.
"workflow_id": checkpoint.workflow_name,
"workflow_name": checkpoint.workflow_name,
"timestamp": checkpoint.timestamp,
"status": "completed",
"metadata": {
# Summary metrics for list view
"iteration_count": checkpoint.iteration_count,
"pending_hil_count": len(checkpoint.pending_request_info_events),
"has_pending_hil": len(checkpoint.pending_request_info_events) > 0,
"message_count": sum(len(msgs) for msgs in checkpoint.messages.values()),
"size_bytes": checkpoint_size,
"version": checkpoint.version,
"graph_signature_hash": checkpoint.graph_signature_hash,
},
}
items.append(cast(ConversationItem, checkpoint_item))
# Apply pagination
if order == "desc":
items = items[::-1]
start_idx = 0
if after:
# Find the index after the cursor
for i, item in enumerate(items):
if item.id == after:
start_idx = i + 1
break
paginated_items = items[start_idx : start_idx + limit]
has_more = len(items) > start_idx + limit
return paginated_items, has_more
async def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
"""Get a specific conversation item by ID.
Supports checkpoint items - will load full checkpoint state from storage.
For checkpoints, the full state is included in metadata.full_checkpoint.
"""
# First check item index for messages, function calls, etc. (O(1) lookup)
conv_items = self._item_index.get(conversation_id, {})
item = conv_items.get(item_id)
if item:
return item
# If not found and ID is a checkpoint, load from checkpoint storage
if item_id.startswith("checkpoint_"):
checkpoint_id = item_id[len("checkpoint_") :] # Remove "checkpoint_" prefix
conv_data = self._conversations.get(conversation_id)
if not conv_data:
return None
checkpoint_storage = conv_data.get("checkpoint_storage")
if not checkpoint_storage:
return None
# Load full checkpoint from storage
try:
checkpoint = await checkpoint_storage.load(checkpoint_id)
except Exception:
return None
# Calculate size of checkpoint
import json
checkpoint_json = json.dumps(checkpoint.to_dict())
checkpoint_size = len(checkpoint_json.encode("utf-8"))
# Build checkpoint item with FULL state in metadata
checkpoint_item = {
"id": item_id,
"type": "checkpoint",
"checkpoint_id": checkpoint.checkpoint_id,
# Keep workflow_id for backward compatibility with existing UI payloads.
"workflow_id": checkpoint.workflow_name,
"workflow_name": checkpoint.workflow_name,
"timestamp": checkpoint.timestamp,
"status": "completed",
"metadata": {
# Summary metrics (same as list view)
"iteration_count": checkpoint.iteration_count,
"pending_hil_count": len(checkpoint.pending_request_info_events),
"has_pending_hil": len(checkpoint.pending_request_info_events) > 0,
"message_count": sum(len(msgs) for msgs in checkpoint.messages.values()),
"size_bytes": checkpoint_size,
"version": checkpoint.version,
"graph_signature_hash": checkpoint.graph_signature_hash,
# 🔥 FULL checkpoint state (lazy loaded)
"full_checkpoint": checkpoint.to_dict(),
},
}
return cast(ConversationItem, checkpoint_item)
return None
def get_session(self, conversation_id: str) -> AgentSession | None:
"""Get AgentSession for execution - CRITICAL for agent.run()."""
conv_data = self._conversations.get(conversation_id)
return conv_data["session"] if conv_data else None
def add_trace(self, conversation_id: str, trace_event: dict[str, Any]) -> None:
"""Add a trace event to the conversation for context inspection.
Traces capture execution metadata like token usage, timing, and LLM context
that is useful for debugging.
Args:
conversation_id: Conversation ID
trace_event: Trace event data (from ResponseTraceEvent.data)
"""
conv_data = self._conversations.get(conversation_id)
if conv_data:
traces = conv_data.get("traces", [])
traces.append(trace_event)
conv_data["traces"] = traces
def get_traces(self, conversation_id: str) -> list[dict[str, Any]]:
"""Get all trace events for a conversation.
Args:
conversation_id: Conversation ID
Returns:
List of trace event dicts, or empty list if not found
"""
conv_data = self._conversations.get(conversation_id)
return conv_data.get("traces", []) if conv_data else []
async def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
"""Filter conversations by metadata (e.g., agent_id)."""
results: list[Conversation] = []
for conv_data in self._conversations.values():
conv_meta = conv_data.get("metadata", {}).copy() # Copy to avoid mutating original
# Check if all filter items match
if all(conv_meta.get(k) == v for k, v in metadata_filter.items()):
# Enrich workflow sessions with checkpoint summary
if conv_meta.get("type") == "workflow_session":
checkpoint_storage = conv_data.get("checkpoint_storage")
if checkpoint_storage:
checkpoints = self._list_all_checkpoints(checkpoint_storage)
latest = max(checkpoints, key=lambda cp: cp.timestamp) if checkpoints else None
conv_meta["checkpoint_summary"] = {
"count": len(checkpoints),
"latest_iteration": latest.iteration_count if latest else 0,
"has_pending_hil": len(latest.pending_request_info_events) > 0 if latest else False,
"pending_hil_count": len(latest.pending_request_info_events) if latest else 0,
}
results.append(
Conversation(
id=conv_data["id"],
object="conversation",
created_at=conv_data["created_at"],
metadata=conv_meta,
)
)
# Sort by created_at descending (most recent first)
results.sort(key=lambda c: c.created_at, reverse=True)
return results
@staticmethod
def _list_all_checkpoints(checkpoint_storage: Any) -> list[WorkflowCheckpoint]:
"""Return all checkpoints from a conversation-scoped storage instance.
DevUI uses one checkpoint storage per conversation. Core storage APIs now
require workflow_name filters, so we gather directly from in-memory storage
internals to provide conversation-wide listing for UI views.
"""
checkpoint_map = getattr(checkpoint_storage, "_checkpoints", None)
if isinstance(checkpoint_map, dict):
return list(cast(dict[str, WorkflowCheckpoint], checkpoint_map).values())
return []
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)
conversations_dict = cast(dict[str, dict[str, Any]], getattr(self._store, "_conversations", {}))
conv_data = conversations_dict.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,604 @@
# 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 typing import cast
from urllib.parse import urlparse
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
yield await asyncio.wait_for(event_queue.get(), timeout=0.1)
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: list[str] = []
# 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_raw = json.loads(stdout.decode())
registered: list[str] = []
if isinstance(registered_raw, list):
for item_obj in cast(list[object], registered_raw):
if isinstance(item_obj, str):
registered.append(item_obj)
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. Auth is enabled by default and reads the token from the environment.
CMD ["devui", "/app/entity", "--mode", "{config.ui_mode}", "--host", "0.0.0.0", "--port", "8080"]
"""
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: list[str] = []
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:
# Try to extract all URLs and check if any is on azurecontainerapps.io
urls = re.findall(r'https://[^\s<>"]+', line_text)
for url in urls:
# Strip common trailing punctuation to ensure clean URL parsing
url_clean = url.rstrip(".,;:!?'\")}]")
parsed_url = urlparse(str(url_clean))
host = parsed_url.hostname
if isinstance(host, str) and (
host == "azurecontainerapps.io" or host.endswith(".azurecontainerapps.io")
):
await event_queue.put(
DeploymentEvent(type="deploy.progress", message="Deployment URL generated!")
)
break
# 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")
@@ -0,0 +1,968 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent Framework entity discovery implementation."""
from __future__ import annotations
import ast
import importlib
import importlib.util
import logging
import sys
import uuid
from pathlib import Path
from typing import Any, cast
from dotenv import load_dotenv
from .models._discovery_models import EntityInfo
logger = logging.getLogger(__name__)
class EntityDiscovery:
"""Discovery for Agent Framework entities - agents and workflows."""
def __init__(self, entities_dir: str | None = None):
"""Initialize entity discovery.
Args:
entities_dir: Directory to scan for entities (optional)
"""
self.entities_dir = entities_dir
self._entities: dict[str, EntityInfo] = {}
self._loaded_objects: dict[str, Any] = {}
self._cleanup_hooks: dict[str, list[Any]] = {}
async def discover_entities(self) -> list[EntityInfo]:
"""Scan for Agent Framework entities.
Returns:
List of discovered entities
"""
if not self.entities_dir:
logger.info("No Agent Framework entities directory configured")
return []
entities_dir = Path(self.entities_dir).resolve() # noqa: ASYNC240
await self._scan_entities_directory(entities_dir)
logger.info(f"Discovered {len(self._entities)} Agent Framework entities")
return self.list_entities()
def get_entity_info(self, entity_id: str) -> EntityInfo | None:
"""Get entity metadata.
Args:
entity_id: Entity identifier
Returns:
Entity information or None if not found
"""
return self._entities.get(entity_id)
def get_entity_object(self, entity_id: str) -> Any | None:
"""Get the actual loaded entity object.
Args:
entity_id: Entity identifier
Returns:
Entity object or None if not found
"""
return self._loaded_objects.get(entity_id)
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
Raises:
ValueError: If entity not found or cannot be loaded
"""
# Check if already loaded (includes in-memory entities)
if entity_id in self._loaded_objects:
logger.debug(f"Entity {entity_id} already loaded (cache hit)")
return self._loaded_objects[entity_id]
# Get entity metadata
entity_info = self._entities.get(entity_id)
if not entity_info:
raise ValueError(f"Entity {entity_id} not found in registry")
# In-memory entities should never reach here (they're pre-loaded)
if entity_info.source == "in_memory":
raise ValueError(f"In-memory entity {entity_id} missing from loaded objects cache")
logger.info(f"Lazy loading entity: {entity_id} (source: {entity_info.source})")
# Load based on source - only directory and in-memory are supported
if entity_info.source == "directory":
entity_obj = await self._load_directory_entity(entity_id, entity_info)
else:
raise ValueError(
f"Unsupported entity source: {entity_info.source}. "
f"Only 'directory' and 'in-memory' sources are supported."
)
# Note: Checkpoint storage is now injected at runtime via run() 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(
entity_obj,
entity_type=entity_info.type if entity_info.type != "unknown" else None,
source=entity_info.source,
)
# IMPORTANT: Preserve the original entity_id (enrichment generates a new one)
enriched_info.id = entity_id
# 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 # type: ignore[reportPrivateUsage]
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
async def _load_directory_entity(self, entity_id: str, entity_info: EntityInfo) -> Any:
"""Load entity from directory (imports module).
Args:
entity_id: Entity identifier
entity_info: Entity metadata
Returns:
Loaded entity object
"""
# Get directory path from metadata
dir_path = Path(entity_info.metadata.get("path", ""))
if not dir_path.exists(): # noqa: ASYNC240
raise ValueError(f"Entity directory not found: {dir_path}")
# Load .env if it exists
if dir_path.is_dir(): # noqa: ASYNC240
self._load_env_for_entity(dir_path)
else:
self._load_env_for_entity(dir_path.parent)
# Import the module
if dir_path.is_dir(): # noqa: ASYNC240
# Directory-based entity - try different import patterns
import_patterns = [
entity_id,
f"{entity_id}.agent",
f"{entity_id}.workflow",
]
# Track import errors to provide meaningful feedback
import_errors: list[tuple[str, Exception]] = []
for pattern in import_patterns:
module, error = self._load_module_from_pattern(pattern)
if error:
import_errors.append((pattern, error))
if module:
# Find entity in module - pass entity_id so registration uses correct ID
entity_obj = await self._find_entity_in_module(module, entity_id, str(dir_path))
if entity_obj:
return entity_obj
# If we have import errors, raise the most informative one
if import_errors:
# Prefer errors from the main module pattern (entity_id) or agent submodule
for pattern, error in import_errors:
if pattern == entity_id or pattern.endswith(".agent"):
raise ValueError(f"Failed to load entity '{entity_id}': {error}") from error
# Fall back to first error
pattern, error = import_errors[0]
raise ValueError(f"Failed to load entity '{entity_id}': {error}") from error
raise ValueError(f"No valid entity found in {dir_path}")
# File-based entity
module = self._load_module_from_file(dir_path, entity_id)
if module:
entity_obj = await self._find_entity_in_module(module, entity_id, str(dir_path))
if entity_obj:
return entity_obj
raise ValueError(f"No valid entity found in {dir_path}")
def list_entities(self) -> list[EntityInfo]:
"""List all discovered entities.
Returns:
List of all entity information
"""
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.
This removes the entity from the loaded objects cache and clears its module
from Python's sys.modules cache. The entity metadata remains, so it will be
reimported on next access.
Args:
entity_id: Entity identifier to invalidate
"""
# Check if entity is in-memory - these cannot be invalidated
entity_info = self._entities.get(entity_id)
if entity_info and entity_info.source == "in_memory":
logger.warning(
f"Attempted to invalidate in-memory entity {entity_id} - ignoring "
f"(in-memory entities cannot be reloaded)"
)
return
# Remove from loaded objects cache
if entity_id in self._loaded_objects:
del self._loaded_objects[entity_id]
logger.info(f"Cleared loaded object cache for: {entity_id}")
# Clear from Python's module cache (including submodules)
keys_to_delete = [
module_name
for module_name in sys.modules
if module_name == entity_id or module_name.startswith(f"{entity_id}.")
]
for key in keys_to_delete:
del sys.modules[key]
logger.debug(f"Cleared module cache: {key}")
# Reset lazy_loaded flag in metadata
entity_info = self._entities.get(entity_id)
if entity_info and "lazy_loaded" in entity_info.metadata:
entity_info.metadata["lazy_loaded"] = False
logger.info(f"Entity invalidated: {entity_id} (will reload on next access)")
def invalidate_all(self) -> None:
"""Invalidate all cached entities.
Useful for forcing a complete reload of all entities.
"""
entity_ids = list(self._loaded_objects.keys())
for entity_id in entity_ids:
self.invalidate_entity(entity_id)
logger.info(f"Invalidated {len(entity_ids)} entities")
def register_entity(self, entity_id: str, entity_info: EntityInfo, entity_object: Any) -> None:
"""Register an entity with both metadata and object.
Args:
entity_id: Unique entity identifier
entity_info: Entity metadata
entity_object: Actual entity object for execution
"""
self._entities[entity_id] = entity_info
self._loaded_objects[entity_id] = entity_object
# Check module-level registry for cleanup hooks
from . import _get_registered_cleanup_hooks # type: ignore[reportPrivateUsage]
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(
self, entity_object: Any, entity_type: str | None = None, source: str = "in_memory"
) -> EntityInfo:
"""Create EntityInfo from Agent Framework entity object.
Args:
entity_object: Agent Framework entity object
entity_type: Optional entity type override
source: Source of entity (directory, in_memory, remote)
Returns:
EntityInfo with Agent Framework specific metadata
"""
# Determine entity type if not provided
if entity_type is None:
entity_type = "agent"
# Check if it's a workflow
if hasattr(entity_object, "get_executors_list") or hasattr(entity_object, "executors"):
entity_type = "workflow"
# Extract metadata with improved fallback naming
name = getattr(entity_object, "name", None)
if not name:
# In-memory entities: use class name as it's more readable than UUID
class_name = entity_object.__class__.__name__
name = f"{entity_type.title()} {class_name}"
description = getattr(entity_object, "description", "")
# Generate entity ID using Agent Framework specific naming
entity_id = self._generate_entity_id(entity_object, entity_type, source)
# Extract tools/executors using Agent Framework specific logic
tools_list = await self._extract_tools_from_object(entity_object, entity_type)
# Extract agent-specific fields (for agents only)
instructions = None
model = None
chat_client_type = None
context_provider_list = None
middlewares_list = None
if entity_type == "agent":
from ._utils import extract_agent_metadata
agent_meta = extract_agent_metadata(entity_object)
instructions = agent_meta["instructions"]
model = agent_meta["model"]
chat_client_type = agent_meta["chat_client_type"]
context_provider_list = agent_meta["context_provider"]
middlewares_list = agent_meta["middleware"]
# Log helpful info about agent capabilities (before creating EntityInfo)
if entity_type == "agent":
has_run = hasattr(entity_object, "run")
if not has_run:
logger.warning(f"Agent '{entity_id}' lacks run() method. 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)"
class_name = type(entity_object).__name__
# Create EntityInfo with Agent Framework specifics
return EntityInfo(
id=entity_id,
name=name,
description=description,
type=entity_type,
framework="agent_framework",
source=source, # IMPORTANT: Pass the source parameter
tools=[str(tool) for tool in (tools_list or [])],
instructions=instructions,
model=model,
chat_client_type=chat_client_type,
context_provider=context_provider_list,
middleware=middlewares_list,
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": class_name,
},
)
async def _scan_entities_directory(self, entities_dir: Path) -> None:
"""Scan the entities directory for Agent Framework entities (lazy loading).
This method scans the filesystem WITHOUT importing modules, creating sparse
metadata that will be enriched on-demand when entities are accessed.
Args:
entities_dir: Directory to scan for entities
"""
if not entities_dir.exists(): # noqa: ASYNC240
logger.warning(f"Entities directory not found: {entities_dir}")
return
logger.info(f"Scanning {entities_dir} for Agent Framework entities (lazy mode)...")
# Add entities directory to Python path if not already there
entities_dir_str = str(entities_dir)
if entities_dir_str not in sys.path:
sys.path.insert(0, entities_dir_str)
# Scan for directories and Python files WITHOUT importing
for item in entities_dir.iterdir(): # noqa: ASYNC240
if item.name.startswith(".") or item.name == "__pycache__":
continue
if item.is_dir() and self._looks_like_entity(item):
# Directory-based entity - create sparse metadata
self._register_sparse_entity(item)
elif item.is_file() and item.suffix == ".py" and not item.name.startswith("_"):
# Single file entity - create sparse metadata
self._register_sparse_file_entity(item)
def _looks_like_entity(self, dir_path: Path) -> bool:
"""Check if directory contains an entity (without importing).
Args:
dir_path: Directory to check
Returns:
True if directory appears to contain an entity
"""
return (
(dir_path / "agent.py").exists()
or (dir_path / "workflow.py").exists()
or (dir_path / "__init__.py").exists()
)
def _detect_entity_type(self, dir_path: Path) -> str:
"""Detect entity type from directory structure (without importing).
Uses filename conventions to determine entity type:
- workflow.py → "workflow"
- agent.py → "agent"
- both or neither → "unknown"
Args:
dir_path: Directory to analyze
Returns:
Entity type: "workflow", "agent", or "unknown"
"""
has_agent = (dir_path / "agent.py").exists()
has_workflow = (dir_path / "workflow.py").exists()
if has_agent and has_workflow:
# Both files exist - ambiguous, mark as unknown
return "unknown"
if has_workflow:
return "workflow"
if has_agent:
return "agent"
# 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).
Args:
dir_path: Entity directory
"""
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(),
type=entity_type,
framework="agent_framework",
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,
"lazy_loaded": False,
},
)
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 = Agent(...)
- workflow = WorkflowBuilder(start_executor=...)...
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,
name=entity_id.replace("_", " ").title(),
type="unknown", # Will be determined on load
framework="agent_framework",
tools=[],
description="",
source="directory",
deployment_supported=deployment_supported,
deployment_reason=deployment_reason,
metadata={
"path": str(file_path),
"discovered": True,
"lazy_loaded": False,
},
)
self._entities[entity_id] = entity_info
logger.debug(f"Registered sparse file entity: {entity_id}")
def _load_env_for_entity(self, entity_path: Path) -> bool:
"""Load .env file for an entity.
Args:
entity_path: Path to entity directory
Returns:
True if .env was loaded successfully
"""
# Check for .env in the entity folder first
env_file = entity_path / ".env"
if self._load_env_file(env_file):
return True
# Check one level up (the entities directory) for safety
if self.entities_dir:
entities_dir = Path(self.entities_dir).resolve()
entities_env = entities_dir / ".env"
if self._load_env_file(entities_env):
return True
return False
def _load_env_file(self, env_path: Path) -> bool:
"""Load environment variables from .env file.
Args:
env_path: Path to .env file
Returns:
True if file was loaded successfully
"""
if env_path.exists():
load_dotenv(env_path, override=True)
logger.debug(f"Loaded .env from {env_path}")
return True
return False
def _load_module_from_pattern(self, pattern: str) -> tuple[Any | None, Exception | None]:
"""Load module using import pattern.
Args:
pattern: Import pattern to try
Returns:
Tuple of (loaded module or None, error or None)
"""
try:
# Check if module exists first
spec = importlib.util.find_spec(pattern)
if spec is None:
return None, None
module = importlib.import_module(pattern)
logger.debug(f"Successfully imported {pattern}")
return module, None
except ModuleNotFoundError as e:
# Distinguish between "module pattern doesn't exist" vs "module has import errors"
# If the missing module is the pattern itself, it's just not found (try next pattern)
# If the missing module is something else (a dependency), capture the error
missing_module = getattr(e, "name", None)
if missing_module and missing_module != pattern and not pattern.endswith(f".{missing_module}"):
# The module exists but has an import error (missing dependency)
logger.warning(f"Error importing {pattern}: {e}")
return None, e
# The module pattern itself doesn't exist - this is expected, try next pattern
logger.debug(f"Import pattern {pattern} not found")
return None, None
except Exception as e:
# Capture the actual error for better error messages
logger.warning(f"Error importing {pattern}: {e}")
return None, e
def _load_module_from_file(self, file_path: Path, module_name: str) -> Any | None:
"""Load module directly from file path.
Args:
file_path: Path to Python file
module_name: Name to assign to module
Returns:
Loaded module or None if failed
"""
try:
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
return None
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module # Add to sys.modules for proper imports
spec.loader.exec_module(module)
logger.debug(f"Successfully loaded module from {file_path}")
return module
except Exception as e:
logger.warning(f"Error loading module from {file_path}: {e}")
return None
async def _find_entity_in_module(self, module: Any, entity_id: str, module_path: str) -> Any:
"""Find agent or workflow entity in a loaded module.
Args:
module: Loaded Python module
entity_id: Expected entity identifier to register with
module_path: Path to module for metadata
Returns:
Loaded entity object, or None if not found
"""
# Look for explicit variable names first
candidates = [
("agent", getattr(module, "agent", None)),
("workflow", getattr(module, "workflow", None)),
]
for obj_type, obj in candidates:
if obj is None:
continue
if self._is_valid_entity(obj, obj_type):
# Register with the correct entity_id (from directory name)
# Store the object directly in _loaded_objects so we can return it
self._loaded_objects[entity_id] = obj
return obj
return None
def _is_valid_entity(self, obj: Any, expected_type: str) -> bool:
"""Check if object is a valid agent or workflow using duck typing.
Args:
obj: Object to validate
expected_type: Expected type ("agent" or "workflow")
Returns:
True if object is valid for the expected type
"""
if expected_type == "agent":
return self._is_valid_agent(obj)
if expected_type == "workflow":
return self._is_valid_workflow(obj)
return False
def _is_valid_agent(self, obj: Any) -> bool:
"""Check if object is a valid Agent Framework agent.
Args:
obj: Object to validate
Returns:
True if object appears to be a valid agent
"""
try:
# Try to import SupportsAgentRun for proper type checking
try:
from agent_framework import SupportsAgentRun
if isinstance(obj, SupportsAgentRun):
return True
except ImportError:
pass
# Fallback to duck typing for agent protocol
# Agent must have run() method, plus id and name
has_run = hasattr(obj, "run")
if has_run and hasattr(obj, "id") and hasattr(obj, "name"):
return True
except (TypeError, AttributeError):
pass
return False
def _is_valid_workflow(self, obj: Any) -> bool:
"""Check if object is a valid Agent Framework workflow.
Args:
obj: Object to validate
Returns:
True if object appears to be a valid workflow
"""
# Check for workflow - must have run (streaming via stream=True) and executors
has_run = hasattr(obj, "run")
return has_run and (hasattr(obj, "executors") or hasattr(obj, "get_executors_list"))
async def _register_entity_from_object(
self, obj: Any, obj_type: str, module_path: str, source: str = "directory"
) -> None:
"""Register an entity from a live object.
Args:
obj: Entity object
obj_type: Type of entity ("agent" or "workflow")
module_path: Path to module for metadata
source: Source of entity (directory, in_memory, remote)
"""
try:
# Generate entity ID with source information
entity_id = self._generate_entity_id(obj, obj_type, source)
# Extract metadata from the live object with improved fallback naming
name = getattr(obj, "name", None)
if not name:
# Use class name as it's more readable than UUID
class_name = obj.__class__.__name__
name = f"{obj_type.title()} {class_name}"
description = getattr(obj, "description", None)
tools = await self._extract_tools_from_object(obj, obj_type)
# Create EntityInfo
tools_union: list[str | dict[str, Any]] | None = None
if tools:
tools_union = [tool for tool in tools]
# Extract agent-specific fields (for agents only)
instructions = None
model = None
chat_client_type = None
context_provider_list = None
middlewares_list = None
if obj_type == "agent":
from ._utils import extract_agent_metadata
agent_meta = extract_agent_metadata(obj)
instructions = agent_meta["instructions"]
model = agent_meta["model"]
chat_client_type = agent_meta["chat_client_type"]
context_provider_list = agent_meta["context_provider"]
middlewares_list = agent_meta["middleware"]
entity_info = EntityInfo(
id=entity_id,
type=obj_type,
name=name,
framework="agent_framework",
description=description,
tools=tools_union,
instructions=instructions,
model=model,
chat_client_type=chat_client_type,
context_provider=context_provider_list,
middleware=middlewares_list,
metadata={
"module_path": module_path,
"entity_type": obj_type,
"source": source,
"class_name": type(obj).__name__,
},
)
# Register the entity
self.register_entity(entity_id, entity_info, obj)
except Exception as e:
logger.error(f"Error registering entity from {source}: {e}")
async def _extract_tools_from_object(self, obj: Any, obj_type: str) -> list[str]:
"""Extract tool/executor names from a live object.
Args:
obj: Entity object
obj_type: Type of entity
Returns:
List of tool/executor names
"""
tools: list[str] = []
try:
if obj_type == "agent":
chat_options = getattr(obj, "default_options", None)
chat_options_tools: object | None = None
if isinstance(chat_options, dict):
chat_options_dict = cast(dict[str, Any], chat_options)
chat_options_tools = chat_options_dict.get("tools")
if chat_options_tools is not None:
tool_iterable: list[object] = (
cast(list[object], chat_options_tools)
if isinstance(chat_options_tools, list)
else [chat_options_tools]
)
for tool_obj in tool_iterable:
tool_name = getattr(tool_obj, "__name__", None)
if isinstance(tool_name, str):
tools.append(tool_name)
continue
named_tool = getattr(tool_obj, "name", None)
if isinstance(named_tool, str):
tools.append(named_tool)
else:
tools.append(str(tool_obj))
else:
agent_tools = getattr(obj, "tools", None)
if isinstance(agent_tools, list):
for tool_obj in cast(list[object], agent_tools):
tool_name = getattr(tool_obj, "__name__", None)
if isinstance(tool_name, str):
tools.append(tool_name)
continue
named_tool = getattr(tool_obj, "name", None)
if isinstance(named_tool, str):
tools.append(named_tool)
else:
tools.append(str(tool_obj))
elif obj_type == "workflow":
if hasattr(obj, "get_executors_list"):
executor_objects = obj.get_executors_list()
if isinstance(executor_objects, list):
for executor_obj in cast(list[object], executor_objects):
tools.append(str(getattr(executor_obj, "id", executor_obj)))
elif hasattr(obj, "executors"):
executors = obj.executors
if isinstance(executors, list):
for executor_obj in cast(list[object], executors):
tools.append(str(getattr(executor_obj, "id", executor_obj)))
elif isinstance(executors, dict):
executors_dict = cast(dict[str, Any], executors)
for key_obj in executors_dict:
tools.append(str(key_obj))
except Exception as e:
logger.debug(f"Error extracting tools from {obj_type} {type(obj)}: {e}")
return tools
def _generate_entity_id(self, entity: Any, entity_type: str, source: str = "directory") -> str:
"""Generate unique entity ID with UUID suffix for collision resistance.
Args:
entity: Entity object
entity_type: Type of entity (agent, workflow, etc.)
source: Source of entity (directory, in_memory, remote)
Returns:
Unique entity ID with format: {type}_{source}_{name}_{uuid}
"""
import re
# Extract base name with priority: name -> id -> class_name
if hasattr(entity, "name") and entity.name:
base_name = str(entity.name).lower().replace(" ", "-").replace("_", "-")
elif hasattr(entity, "id") and entity.id:
base_name = str(entity.id).lower().replace(" ", "-").replace("_", "-")
elif hasattr(entity, "__class__"):
class_name = entity.__class__.__name__
# Convert CamelCase to kebab-case
base_name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", class_name).lower()
else:
base_name = "entity"
# Generate full UUID for guaranteed uniqueness
full_uuid = uuid.uuid4().hex
return f"{entity_type}_{source}_{base_name}_{full_uuid}"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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,288 @@
# 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.
"""
from __future__ import annotations
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__)
def _extract_error_details(body: Any) -> tuple[str | None, str | None, str | None]:
"""Extract typed OpenAI error fields from error body payload."""
if not isinstance(body, dict):
return None, None, None
error_dict: dict[str, Any] = body.get("error") # type: ignore[assignment, reportUnknownVariableType]
if not isinstance(error_dict, dict):
return None, None, None
message = error_dict.get("message")
error_type = error_dict.get("type")
code = error_dict.get("code")
return (
message if isinstance(message, str) else None,
error_type if isinstance(error_type, str) else None,
code if isinstance(code, str) else None,
)
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]:
"""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)
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": message or str(e),
"type": error_type or "authentication_error",
"code": code or "invalid_api_key",
},
},
}
except PermissionDeniedError as e:
# 403 - Permission denied
logger.error(f"OpenAI permission denied: {e}", exc_info=True)
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": message or str(e),
"type": error_type or "permission_denied",
"code": code or "insufficient_permissions",
},
},
}
except RateLimitError as e:
# 429 - Rate limit exceeded
logger.error(f"OpenAI rate limit exceeded: {e}", exc_info=True)
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": message or str(e),
"type": error_type or "rate_limit_error",
"code": code or "rate_limit_exceeded",
},
},
}
except APIStatusError as e:
# Other OpenAI API errors
logger.error(f"OpenAI API error: {e}", exc_info=True)
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": message or str(e),
"type": error_type or "api_error",
"code": code or "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")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,216 @@
# Copyright (c) Microsoft. All rights reserved.
"""Session management for agent execution tracking."""
import logging
import uuid
from datetime import datetime
from typing import Any, TypedDict, cast
from typing_extensions import NotRequired
logger = logging.getLogger(__name__)
class RequestRecord(TypedDict):
"""Tracked execution request data."""
id: str
timestamp: datetime
entity_id: str
executor: str
input: Any
model: str
stream: bool
execution_time: NotRequired[float]
status: NotRequired[str]
class SessionData(TypedDict):
"""Stored session state."""
id: str
created_at: datetime
requests: list[RequestRecord]
context: dict[str, Any]
active: bool
SessionSummary = dict[str, Any]
class SessionManager:
"""Manages execution sessions for tracking requests and context."""
def __init__(self) -> None:
"""Initialize the session manager."""
self.sessions: dict[str, SessionData] = {}
def create_session(self, session_id: str | None = None) -> str:
"""Create a new execution session.
Args:
session_id: Optional session ID, if not provided a new one is generated
Returns:
Session ID
"""
if not session_id:
session_id = str(uuid.uuid4())
self.sessions[session_id] = {
"id": session_id,
"created_at": datetime.now(),
"requests": [],
"context": {},
"active": True,
}
logger.debug(f"Created session: {session_id}")
return session_id
def get_session(self, session_id: str) -> SessionData | None:
"""Get session information.
Args:
session_id: Session ID
Returns:
Session data or None if not found
"""
return self.sessions.get(session_id)
def close_session(self, session_id: str) -> None:
"""Close and cleanup a session.
Args:
session_id: Session ID to close
"""
if session_id in self.sessions:
self.sessions[session_id]["active"] = False
logger.debug(f"Closed session: {session_id}")
def add_request_record(
self, session_id: str, entity_id: str, executor_name: str, request_input: Any, model: str
) -> str:
"""Add a request record to a session.
Args:
session_id: Session ID
entity_id: ID of the entity being executed
executor_name: Name of the executor
request_input: Input for the request
model: Model name
Returns:
Request ID
"""
session = self.get_session(session_id)
if not session:
return ""
request_record: RequestRecord = {
"id": str(uuid.uuid4()),
"timestamp": datetime.now(),
"entity_id": entity_id,
"executor": executor_name,
"input": request_input,
"model": model,
"stream": True,
}
session["requests"].append(request_record)
return request_record["id"]
def update_request_record(self, session_id: str, request_id: str, updates: dict[str, Any]) -> None:
"""Update a request record in a session.
Args:
session_id: Session ID
request_id: Request ID to update
updates: Dictionary of updates to apply
"""
session = self.get_session(session_id)
if not session:
return
for request in session["requests"]:
if request["id"] == request_id:
request_data = cast(dict[str, Any], request)
request_data.update(updates)
break
def get_session_history(self, session_id: str) -> SessionSummary | None:
"""Get session execution history.
Args:
session_id: Session ID
Returns:
Session history or None if not found
"""
session = self.get_session(session_id)
if not session:
return None
return {
"session_id": session_id,
"created_at": session["created_at"].isoformat(),
"active": session["active"],
"request_count": len(session["requests"]),
"requests": [
{
"id": req["id"],
"timestamp": req["timestamp"].isoformat(),
"entity_id": req["entity_id"],
"executor": req["executor"],
"model": req["model"],
"input_length": len(str(req["input"])) if req["input"] else 0,
"execution_time": req.get("execution_time"),
"status": req.get("status", "unknown"),
}
for req in session["requests"]
],
}
def get_active_sessions(self) -> list[SessionSummary]:
"""Get list of active sessions.
Returns:
List of active session summaries
"""
active_sessions: list[SessionSummary] = []
for session_id, session in self.sessions.items():
if session["active"]:
active_sessions.append({
"session_id": session_id,
"created_at": session["created_at"].isoformat(),
"request_count": len(session["requests"]),
"last_activity": (
session["requests"][-1]["timestamp"].isoformat()
if session["requests"]
else session["created_at"].isoformat()
),
})
return active_sessions
def cleanup_old_sessions(self, max_age_hours: int = 24) -> None:
"""Cleanup old sessions to prevent memory leaks.
Args:
max_age_hours: Maximum age of sessions to keep in hours
"""
cutoff_time = datetime.now().timestamp() - (max_age_hours * 3600)
sessions_to_remove: list[str] = []
for session_id, session in self.sessions.items():
if session["created_at"].timestamp() < cutoff_time:
sessions_to_remove.append(session_id)
for session_id in sessions_to_remove:
del self.sessions[session_id]
logger.debug(f"Cleaned up old session: {session_id}")
if sessions_to_remove:
logger.info(f"Cleaned up {len(sessions_to_remove)} old sessions")
@@ -0,0 +1,168 @@
# Copyright (c) Microsoft. All rights reserved.
"""Simplified tracing integration for Agent Framework Server."""
from __future__ import annotations
import logging
from collections.abc import Generator, Sequence
from contextlib import contextmanager
from datetime import datetime
from typing import Any
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
from .models import ResponseTraceEvent
logger = logging.getLogger(__name__)
class SimpleTraceCollector(SpanExporter):
"""Simple trace collector that captures spans for direct yielding."""
def __init__(self, response_id: str | None = None, entity_id: str | None = None) -> None:
"""Initialize trace collector.
Args:
response_id: Response identifier for grouping traces by turn
entity_id: Entity identifier for context
"""
self.response_id = response_id
self.entity_id = entity_id
self.collected_events: list[ResponseTraceEvent] = []
def export(self, spans: Sequence[Any]) -> SpanExportResult:
"""Collect spans as trace events.
Args:
spans: Sequence of OpenTelemetry spans
Returns:
SpanExportResult indicating success
"""
logger.debug(f"SimpleTraceCollector received {len(spans)} spans")
try:
for span in spans:
trace_event = self._convert_span_to_trace_event(span)
if trace_event:
self.collected_events.append(trace_event)
logger.debug(f"Collected trace event: {span.name}")
return SpanExportResult.SUCCESS
except Exception as e:
logger.error(f"Error collecting trace spans: {e}")
return SpanExportResult.FAILURE
def force_flush(self, timeout_millis: int = 30000) -> bool:
"""Force flush spans (no-op for simple collection)."""
return True
def get_pending_events(self) -> list[ResponseTraceEvent]:
"""Get and clear pending trace events.
Returns:
List of collected trace events, clearing the internal list
"""
events = self.collected_events.copy()
self.collected_events.clear()
return events
def _convert_span_to_trace_event(self, span: Any) -> ResponseTraceEvent | None:
"""Convert OpenTelemetry span to ResponseTraceEvent.
Args:
span: OpenTelemetry span
Returns:
ResponseTraceEvent or None if conversion fails
"""
try:
start_time = span.start_time / 1_000_000_000 # Convert from nanoseconds
end_time = span.end_time / 1_000_000_000 if span.end_time else None
duration_ms = ((end_time - start_time) * 1000) if end_time else None
# Build trace data
trace_data = {
"type": "trace_span",
"span_id": str(span.context.span_id),
"trace_id": str(span.context.trace_id),
"parent_span_id": str(span.parent.span_id) if span.parent else None,
"operation_name": span.name,
"start_time": start_time,
"end_time": end_time,
"duration_ms": duration_ms,
"attributes": dict(span.attributes) if span.attributes else {},
"status": str(span.status.status_code) if hasattr(span, "status") else "OK",
"response_id": self.response_id,
"entity_id": self.entity_id,
}
# Add events if available
if hasattr(span, "events") and span.events:
trace_data["events"] = [
{
"name": event.name,
"timestamp": event.timestamp / 1_000_000_000,
"attributes": dict(event.attributes) if event.attributes else {},
}
for event in span.events
]
# Add error information if span failed
if hasattr(span, "status") and span.status.status_code.name == "ERROR":
trace_data["error"] = span.status.description or "Unknown error"
return ResponseTraceEvent(type="trace_event", data=trace_data, timestamp=datetime.now().isoformat())
except Exception as e:
logger.warning(f"Failed to convert span {getattr(span, 'name', 'unknown')}: {e}")
return None
@contextmanager
def capture_traces(response_id: str | None = None, entity_id: str | None = None) -> Generator[SimpleTraceCollector]:
"""Context manager to capture traces during execution.
Args:
response_id: Response identifier for grouping traces by turn
entity_id: Entity identifier for context
Yields:
SimpleTraceCollector instance to get trace events from
"""
collector = SimpleTraceCollector(response_id, entity_id)
try:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
# Get current tracer provider and add our collector
provider = trace.get_tracer_provider()
processor = SimpleSpanProcessor(collector)
# Check if this is a real TracerProvider (not the default NoOpTracerProvider)
if isinstance(provider, TracerProvider):
provider.add_span_processor(processor)
logger.debug(f"Added trace collector to TracerProvider for response: {response_id}, entity: {entity_id}")
try:
yield collector
finally:
# Clean up - shutdown processor
try:
processor.shutdown()
except Exception as e:
logger.debug(f"Error shutting down processor: {e}")
else:
logger.warning(f"No real TracerProvider available, got: {type(provider)}")
yield collector
except ImportError:
logger.debug("OpenTelemetry not available")
yield collector
except Exception as e:
logger.error(f"Error setting up trace capture: {e}")
yield collector
@@ -0,0 +1,801 @@
# Copyright (c) Microsoft. All rights reserved.
"""Utility functions for DevUI."""
import inspect
import json
import logging
from dataclasses import fields, is_dataclass
from types import UnionType
from typing import Any, Union, cast, get_args, get_origin, get_type_hints
from agent_framework import Message
logger = logging.getLogger(__name__)
def _string_key_dict(value: object) -> dict[str, Any] | None:
"""Cast value to a dict."""
if not isinstance(value, dict):
return None
return cast(dict[str, Any], value)
# ============================================================================
# Agent Metadata Extraction
# ============================================================================
def extract_agent_metadata(entity_object: Any) -> dict[str, Any]:
"""Extract agent-specific metadata from an entity object.
Args:
entity_object: Agent Framework agent object
Returns:
Dictionary with agent metadata: instructions, model, chat_client_type,
context_providers, and middleware
"""
metadata = {
"instructions": None,
"model": None,
"chat_client_type": None,
"context_provider": None,
"middleware": None,
}
# Try to get instructions
if hasattr(entity_object, "default_options"):
chat_opts = entity_object.default_options
chat_opts_dict = _string_key_dict(chat_opts)
if chat_opts_dict is not None:
if "instructions" in chat_opts_dict:
metadata["instructions"] = chat_opts_dict.get("instructions")
elif hasattr(chat_opts, "instructions"):
metadata["instructions"] = chat_opts.instructions
# Try to get model - check both default_options and client
if hasattr(entity_object, "default_options"):
chat_opts = entity_object.default_options
chat_opts_dict = _string_key_dict(chat_opts)
if chat_opts_dict is not None:
model = chat_opts_dict.get("model")
if model:
metadata["model"] = model
elif hasattr(chat_opts, "model") and chat_opts.model:
metadata["model"] = chat_opts.model
if metadata["model"] is None and hasattr(entity_object, "client") and hasattr(entity_object.client, "model"):
metadata["model"] = entity_object.client.model
# Try to get chat client type
if hasattr(entity_object, "client"):
metadata["chat_client_type"] = entity_object.client.__class__.__name__
# Try to get context providers
if (
hasattr(entity_object, "context_provider")
and entity_object.context_provider
and hasattr(entity_object.context_provider, "__class__")
):
metadata["context_provider"] = [entity_object.context_provider.__class__.__name__] # type: ignore
# Try to get middleware
if hasattr(entity_object, "middleware") and entity_object.middleware:
middlewares_list: list[str] = []
for m in entity_object.middleware:
# Try multiple ways to get a good name for middleware
if hasattr(m, "__name__"): # Function or callable
middlewares_list.append(m.__name__)
elif hasattr(m, "__class__"): # Class instance
middlewares_list.append(m.__class__.__name__)
else:
middlewares_list.append(str(m))
metadata["middleware"] = middlewares_list # type: ignore
return metadata
# ============================================================================
# Workflow Input Type Utilities
# ============================================================================
def extract_executor_message_types(executor: Any) -> list[Any]:
"""Extract declared input types for the given executor.
Args:
executor: Workflow executor object
Returns:
List of message types that the executor accepts
"""
message_types: list[Any] = []
try:
input_types = getattr(executor, "input_types", None)
except Exception as exc: # pragma: no cover - defensive logging path
logger.debug(f"Failed to access executor input_types: {exc}")
else:
if input_types:
message_types = list(input_types)
if not message_types and hasattr(executor, "_handlers"):
try:
handlers = executor._handlers
if isinstance(handlers, dict):
message_types = list(handlers.keys()) # type: ignore[arg-type]
except Exception as exc: # pragma: no cover - defensive logging path
logger.debug(f"Failed to read executor handlers: {exc}")
return message_types
def _contains_chat_message(type_hint: Any) -> bool:
"""Check whether the provided type hint directly or indirectly references Message."""
if type_hint is Message:
return True
origin = get_origin(type_hint)
if origin in (list, tuple):
return any(_contains_chat_message(arg) for arg in get_args(type_hint))
if origin in (Union, UnionType):
return any(_contains_chat_message(arg) for arg in get_args(type_hint))
return False
def _is_list_message_type(type_hint: Any) -> bool:
"""Return True if type_hint is exactly list[Message]."""
return get_origin(type_hint) is list and bool(get_args(type_hint)) and get_args(type_hint)[0] is Message
def _find_chat_message_type(type_hint: Any) -> Any | None:
"""Return ``list[Message]`` or ``Message`` if present in type_hint, else None.
Recursively inspects union members so that a single-element ``message_types``
list like ``[dict | str | list[Message] | ...]`` (the real ``JoinExecutor``
form) is handled correctly. ``list[Message]`` takes priority over bare
``Message``.
"""
if _is_list_message_type(type_hint):
return type_hint
if type_hint is Message:
return Message
origin = get_origin(type_hint)
if origin in (Union, UnionType):
fallback = None
for arg in get_args(type_hint):
found = _find_chat_message_type(arg)
if found is None:
continue
if _is_list_message_type(found):
return found # list[Message] wins immediately
if fallback is None:
fallback = found # bare Message — keep searching
return fallback
return None
def select_primary_input_type(message_types: list[Any]) -> Any | None:
"""Choose the most user-friendly input type for workflow inputs.
Prefers Message (or containers thereof) and then falls back to primitives.
When the executor's union contains ``list[Message]`` (as the declarative
entry ``JoinExecutor`` does), that type is returned so that
``parse_input_for_type`` can wrap the user's text in a list before
dispatching — avoiding a "cannot handle message of type Message" error.
``message_types`` may contain full union types as single elements (the real
``JoinExecutor`` emits ``[dict | str | list[Message] | ...]``), so each
element is searched recursively.
Args:
message_types: List of possible message types
Returns:
Selected primary input type, or None if list is empty
"""
if not message_types:
return None
# First pass: search each type (including union members) for list[Message] or Message.
for message_type in message_types:
found = _find_chat_message_type(message_type)
if found is not None:
return found
# Second pass: broader fallback for deeply nested or unusual containers.
for message_type in message_types:
if _contains_chat_message(message_type):
return Message
preferred = (str, dict)
for candidate in preferred:
for message_type in message_types:
if message_type is candidate:
return candidate
origin = get_origin(message_type)
if origin is candidate:
return candidate
return message_types[0]
# ============================================================================
# Type System Utilities
# ============================================================================
def is_serialization_mixin(cls: type) -> bool:
"""Check if class is a SerializationMixin subclass.
Args:
cls: Class to check
Returns:
True if class is a SerializationMixin subclass
"""
try:
from agent_framework._serialization import SerializationMixin
return isinstance(cls, type) and issubclass(cls, SerializationMixin)
except ImportError:
return False
def _type_to_schema(type_hint: Any, field_name: str) -> dict[str, Any]:
"""Convert a type hint to JSON schema.
Args:
type_hint: Type hint to convert
field_name: Name of the field (for documentation)
Returns:
JSON schema dict
"""
type_str = str(type_hint)
# Handle None/Optional
if type_hint is type(None):
return {"type": "null"}
# Handle basic types
if type_hint is str or "str" in type_str:
return {"type": "string"}
if type_hint is int or "int" in type_str:
return {"type": "integer"}
if type_hint is float or "float" in type_str:
return {"type": "number"}
if type_hint is bool or "bool" in type_str:
return {"type": "boolean"}
# Handle Literal types (for enum-like values)
if "Literal" in type_str:
origin = get_origin(type_hint)
if origin is not None:
args = get_args(type_hint)
if args:
return {"type": "string", "enum": list(args)}
# Handle Union/Optional
if "Union" in type_str or "Optional" in type_str:
origin = get_origin(type_hint)
if origin is not None:
args = get_args(type_hint)
# Filter out None type
non_none_args = [arg for arg in args if arg is not type(None)]
if len(non_none_args) == 1:
return _type_to_schema(non_none_args[0], field_name)
# Multiple types - pick first non-None
if non_none_args:
return _type_to_schema(non_none_args[0], field_name)
# Handle collections
if "list" in type_str or "List" in type_str or "Sequence" in type_str:
origin = get_origin(type_hint)
if origin is not None:
args = get_args(type_hint)
if args:
items_schema = _type_to_schema(args[0], field_name)
return {"type": "array", "items": items_schema}
return {"type": "array"}
if "dict" in type_str or "Dict" in type_str or "Mapping" in type_str:
return {"type": "object"}
# Default fallback
return {"type": "string", "description": f"Type: {type_hint}"}
def generate_schema_from_serialization_mixin(cls: type[Any]) -> dict[str, Any]:
"""Generate JSON schema from SerializationMixin class.
Introspects the __init__ signature to extract parameter types and defaults.
Args:
cls: SerializationMixin subclass
Returns:
JSON schema dict
"""
sig = inspect.signature(cls)
# Get type hints
try:
type_hints = get_type_hints(cls)
except Exception:
type_hints = {}
properties: dict[str, Any] = {}
required: list[str] = []
for param_name, param in sig.parameters.items():
if param_name in ("self", "kwargs"):
continue
# Get type annotation
param_type = type_hints.get(param_name, str)
# Generate schema for this parameter
param_schema = _type_to_schema(param_type, param_name)
properties[param_name] = param_schema
# Check if required (no default value, not VAR_KEYWORD)
if param.default == inspect.Parameter.empty and param.kind != inspect.Parameter.VAR_KEYWORD:
required.append(param_name)
schema: dict[str, Any] = {"type": "object", "properties": properties}
if required:
schema["required"] = required
return schema
def generate_schema_from_dataclass(cls: type[Any]) -> dict[str, Any]:
"""Generate JSON schema from dataclass.
Args:
cls: Dataclass type
Returns:
JSON schema dict
"""
if not is_dataclass(cls):
return {"type": "object"}
properties: dict[str, Any] = {}
required: list[str] = []
for field in fields(cls):
# Generate schema for field type
field_schema = _type_to_schema(field.type, field.name)
properties[field.name] = field_schema
# Check if required (no default value)
if field.default == field.default_factory: # No default
required.append(field.name)
schema: dict[str, Any] = {"type": "object", "properties": properties}
if required:
schema["required"] = required
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:
# 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
if not first_matches_request and isinstance(first_param_type, type):
request_type_name = request_type.__name__
first_matches_request = 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.
Supports multiple input types in priority order:
0. list[Message] — rendered as a plain string input (DevUI presents a text
box; the text is later wrapped in a list by parse_input_for_type)
1. Built-in types (str, dict, int, etc.)
2. Pydantic models (via model_json_schema)
3. SerializationMixin classes (via __init__ introspection)
4. Dataclasses (via fields introspection)
5. Fallback to string
Args:
input_type: Input type to generate schema for
Returns:
JSON schema dict
"""
# 0. list[Message] — treat as simple text so DevUI shows a text box
if _is_list_message_type(input_type):
return {"type": "string"}
# 1. Built-in types
if input_type is str:
return {"type": "string"}
if input_type is dict:
return {"type": "object"}
if input_type is int:
return {"type": "integer"}
if input_type is float:
return {"type": "number"}
if input_type is bool:
return {"type": "boolean"}
# 2. Pydantic models (legacy support)
if hasattr(input_type, "model_json_schema"):
return input_type.model_json_schema() # type: ignore
# 3. SerializationMixin classes (Message, etc.)
if is_serialization_mixin(input_type):
return generate_schema_from_serialization_mixin(input_type)
# 4. Dataclasses
if is_dataclass(input_type):
return generate_schema_from_dataclass(input_type)
# 5. Fallback to string
type_name = input_type.__name__ if isinstance(input_type, type) else str(cast(Any, input_type))
return {"type": "string", "description": f"Input type: {type_name}"}
# ============================================================================
# Input Parsing Utilities
# ============================================================================
def parse_input_for_type(input_data: Any, target_type: type) -> Any:
"""Parse input data to match the target type.
Handles conversion from raw input (string, dict) to the expected type:
- list[Message]: build a Message from the raw input and wrap it in a list
- Built-in types: direct conversion
- Pydantic models: use model_validate or model_validate_json
- SerializationMixin: use from_dict or construct from string
- Dataclasses: construct from dict
Args:
input_data: Raw input data (string, dict, or already correct type)
target_type: Expected type for the input
Returns:
Parsed input matching target_type, or original input if parsing fails
"""
# list[Message]: generic aliases cannot be used with isinstance, handle first.
if _is_list_message_type(target_type):
raw: object = input_data
if isinstance(raw, list):
items = cast(list[object], raw)
if all(isinstance(m, Message) for m in items):
return items
# Try to convert each item (serialized str/dict OpenAI message) to Message.
converted: list[Message] = []
ok = True
for item in items:
if isinstance(item, Message):
converted.append(item)
elif isinstance(item, str):
converted.append(_build_message_from_legacy_payload(item))
elif isinstance(item, dict):
converted.append(_build_message_from_legacy_payload(cast(dict[str, Any], item)))
else:
ok = False
break
if ok:
return converted
# A list never matches the Message/str/dict checks below; stringify directly.
return [_build_message_from_legacy_payload(str(items))]
if isinstance(raw, Message):
return [raw]
if isinstance(raw, str):
return [_build_message_from_legacy_payload(raw)]
parsed_dict = _string_key_dict(raw)
if parsed_dict is not None:
if parsed_dict and _looks_like_message_dict(parsed_dict):
return [_build_message_from_legacy_payload(parsed_dict)]
return raw
return [_build_message_from_legacy_payload(str(raw))]
# If already correct type, return as-is
if isinstance(input_data, target_type):
return input_data
# Handle string input
if isinstance(input_data, str):
return _parse_string_input(input_data, target_type)
# Handle dict input
parsed_dict = _string_key_dict(input_data)
if parsed_dict is not None:
return _parse_dict_input(parsed_dict, target_type)
# Fallback: return original
return input_data
def _looks_like_message_dict(d: dict[str, Any]) -> bool:
"""Return True if *d* is a recognisable serialised Message payload.
Three recognised signatures (in priority order):
1. ``type`` discriminator is literally ``"message"`` — output of ``Message.to_dict()``.
2. Dict has a ``"role"`` key — all framework Message objects carry a role.
3. Dict has exactly the single key ``"input"`` — DevUI ``WorkflowInputForm``
submits ``{"input": "<user text>"}`` for workflows whose schema is ``{"type":
"string"}``.
Anything else is treated as a structured workflow input and passed through
unchanged, to be handled by the dict-union branch of the executor.
"""
if d.get("type") == "message":
return True
if "role" in d:
return True
return set(d.keys()) == {"input"}
def _build_message_from_legacy_payload(input_data: str | dict[str, Any]) -> Message:
"""Convert raw DevUI input into a framework Message.
This preserves DevUI compatibility for older payloads that still send
``{"role": "...", "text": "..."}`` instead of the framework-native
``{"role": "...", "contents": [...]}`` shape.
"""
if isinstance(input_data, str):
return Message(role="user", contents=[input_data])
role = input_data.get("role", "user")
role = role if isinstance(role, str) else str(role)
if "contents" in input_data:
contents = input_data["contents"]
else:
contents = None
for field in ("text", "message", "content", "input", "data"):
if field in input_data:
contents = input_data[field]
break
if contents is None:
contents_list: list[Any] = []
elif isinstance(contents, list):
contents_list = contents # type: ignore[reportUnknownVariableType]
else:
contents_list = [contents]
kwargs: dict[str, Any] = {}
for field in (
"author_name",
"message_id",
"additional_properties",
"raw_representation",
):
if field in input_data:
kwargs[field] = input_data[field]
return Message(role=role, contents=contents_list, **kwargs)
def _parse_string_input(input_str: str, target_type: type) -> Any:
"""Parse string input to target type.
Args:
input_str: Input string
target_type: Target type
Returns:
Parsed input or original string
"""
# Built-in types
if target_type is str:
return input_str
if target_type is int:
try:
return int(input_str)
except ValueError:
return input_str
elif target_type is float:
try:
return float(input_str)
except ValueError:
return input_str
elif target_type is bool:
return input_str.lower() in ("true", "1", "yes")
# Pydantic models
if hasattr(target_type, "model_validate_json"):
try:
# Try parsing as JSON first
if input_str.strip().startswith("{"):
return target_type.model_validate_json(input_str) # type: ignore
# Try common field names with the string value
common_fields = ["text", "message", "content", "input", "data"]
for field in common_fields:
try:
return target_type(**{field: input_str})
except Exception as e:
logger.debug(f"Failed to parse string input with field '{field}': {e}")
continue
except Exception as e:
logger.debug(f"Failed to parse string as Pydantic model: {e}")
# SerializationMixin (like Message)
if is_serialization_mixin(target_type):
try:
if target_type is Message:
if input_str.strip().startswith("{"):
data = json.loads(input_str)
parsed_dict = _string_key_dict(data)
if parsed_dict is not None:
return _build_message_from_legacy_payload(parsed_dict)
return _build_message_from_legacy_payload(input_str)
# Try parsing as JSON dict first
if input_str.strip().startswith("{"):
data = json.loads(input_str)
if hasattr(target_type, "from_dict"):
return target_type.from_dict(data) # type: ignore
return target_type(**data)
# Try other common fields
common_fields = ["text", "message", "content"]
sig = inspect.signature(target_type)
params = list(sig.parameters.keys())
for field in common_fields:
if field in params:
try:
return target_type(**{field: input_str})
except Exception as e:
logger.debug(f"Failed to create SerializationMixin with field '{field}': {e}")
continue
except Exception as e:
logger.debug(f"Failed to parse string as SerializationMixin: {e}")
# Dataclasses
if is_dataclass(target_type):
try:
# Try parsing as JSON
if input_str.strip().startswith("{"):
data = json.loads(input_str)
return target_type(**data)
# Try common field names
common_fields = ["text", "message", "content", "input", "data"]
for field in common_fields:
try:
return target_type(**{field: input_str})
except Exception as e:
logger.debug(f"Failed to create dataclass with field '{field}': {e}")
continue
except Exception as e:
logger.debug(f"Failed to parse string as dataclass: {e}")
# Fallback: return original string
return input_str
def _parse_dict_input(input_dict: dict[str, Any], target_type: type) -> Any:
"""Parse dict input to target type.
Args:
input_dict: Input dictionary
target_type: Target type
Returns:
Parsed input or original dict
"""
# Handle primitive types - extract from common field names
if target_type in (str, int, float, bool):
try:
# If it's already the right type, return as-is
if isinstance(input_dict, target_type):
return input_dict
# Try "input" field first (common for workflow inputs)
if "input" in input_dict:
return target_type(input_dict["input"])
# If single-key dict, extract the value
if len(input_dict) == 1:
value = next(iter(input_dict.values()))
return target_type(value)
# Otherwise, return as-is
return input_dict
except (ValueError, TypeError) as e:
logger.debug(f"Failed to convert dict to {target_type}: {e}")
return input_dict
# If target is dict, return as-is
if target_type is dict:
return input_dict
# Pydantic models
if hasattr(target_type, "model_validate"):
try:
return target_type.model_validate(input_dict) # type: ignore
except Exception as e:
logger.debug(f"Failed to validate dict as Pydantic model: {e}")
# SerializationMixin
if is_serialization_mixin(target_type):
try:
if target_type is Message:
return _build_message_from_legacy_payload(input_dict)
if hasattr(target_type, "from_dict"):
return target_type.from_dict(input_dict) # type: ignore
return target_type(**input_dict)
except Exception as e:
logger.debug(f"Failed to parse dict as SerializationMixin: {e}")
# Dataclasses
if is_dataclass(target_type):
try:
return target_type(**input_dict)
except Exception as e:
logger.debug(f"Failed to parse dict as dataclass: {e}")
# Fallback: return original dict
return input_dict
@@ -0,0 +1,94 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent Framework DevUI Models - OpenAI-compatible types and custom extensions."""
# Import discovery models
# Import all OpenAI types directly from the openai package
from openai.types.conversations import Conversation, ConversationDeletedResource
from openai.types.conversations.conversation_item import ConversationItem
from openai.types.responses import (
Response,
ResponseCompletedEvent,
ResponseErrorEvent,
ResponseFunctionCallArgumentsDeltaEvent,
ResponseFunctionToolCall,
ResponseFunctionToolCallOutputItem,
ResponseInputParam,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
ResponseOutputMessage,
ResponseOutputText,
ResponseReasoningTextDeltaEvent,
ResponseStreamEvent,
ResponseTextDeltaEvent,
ResponseUsage,
ToolParam,
)
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
from openai.types.shared import Metadata, ResponsesModel
from ._discovery_models import Deployment, DeploymentConfig, DeploymentEvent, DiscoveryResponse, EntityInfo
from ._openai_custom import (
AgentFrameworkRequest,
CustomResponseOutputItemAddedEvent,
CustomResponseOutputItemDoneEvent,
ExecutorActionItem,
MetaResponse,
OpenAIError,
ResponseFunctionResultComplete,
ResponseOutputData,
ResponseOutputFile,
ResponseOutputImage,
ResponseTraceEvent,
ResponseTraceEventComplete,
ResponseWorkflowEventComplete,
)
# Type alias for compatibility
OpenAIResponse = Response
# Export all types for easy importing
__all__ = [
"AgentFrameworkRequest",
"Conversation",
"ConversationDeletedResource",
"ConversationItem",
"CustomResponseOutputItemAddedEvent",
"CustomResponseOutputItemDoneEvent",
"Deployment",
"DeploymentConfig",
"DeploymentEvent",
"DiscoveryResponse",
"EntityInfo",
"ExecutorActionItem",
"InputTokensDetails",
"MetaResponse",
"Metadata",
"OpenAIError",
"OpenAIResponse",
"OutputTokensDetails",
"Response",
"ResponseCompletedEvent",
"ResponseErrorEvent",
"ResponseFunctionCallArgumentsDeltaEvent",
"ResponseFunctionResultComplete",
"ResponseFunctionToolCall",
"ResponseFunctionToolCallOutputItem",
"ResponseInputParam",
"ResponseOutputData",
"ResponseOutputFile",
"ResponseOutputImage",
"ResponseOutputItemAddedEvent",
"ResponseOutputItemDoneEvent",
"ResponseOutputMessage",
"ResponseOutputText",
"ResponseReasoningTextDeltaEvent",
"ResponseStreamEvent",
"ResponseTextDeltaEvent",
"ResponseTraceEvent",
"ResponseTraceEventComplete",
"ResponseUsage",
"ResponseWorkflowEventComplete",
"ResponsesModel",
"ToolParam",
]
@@ -0,0 +1,204 @@
# Copyright (c) Microsoft. All rights reserved.
"""Discovery API models for entity information."""
from __future__ import annotations
import re
from collections.abc import Callable
from typing import Any, cast
from pydantic import BaseModel, Field, field_validator
class EnvVarRequirement(BaseModel):
"""Environment variable requirement for an entity."""
name: str
description: str
required: bool = True
example: str | None = None
class EntityInfo(BaseModel):
"""Entity information for discovery and detailed views."""
# Always present (core entity data)
id: str
type: str # "agent", "workflow"
name: str
description: str | None = None
framework: str
tools: list[str | dict[str, Any]] | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
# Source information
source: str = "directory" # "directory" or "in_memory"
# 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: str | None = None
chat_client_type: str | None = None
context_provider: list[str] | None = None
middleware: list[str] | None = None
# Workflow-specific fields (populated only for detailed info requests)
executors: list[str] | None = None
workflow_dump: dict[str, Any] | None = None
input_schema: dict[str, Any] | None = None
input_type_name: str | None = None
start_executor_id: str | None = None
class DiscoveryResponse(BaseModel):
"""Response model for entity discovery."""
entities: list[EntityInfo] = Field(default_factory=cast(Callable[..., list[EntityInfo]], 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")
@@ -0,0 +1,413 @@
# Copyright (c) Microsoft. All rights reserved.
"""Custom OpenAI-compatible event types for Agent Framework extensions.
These are custom event types that extend beyond the standard OpenAI Responses API
to support Agent Framework specific features like workflows and traces.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
# Custom Agent Framework OpenAI event types for structured data
# Agent lifecycle events - simple and clear
class AgentStartedEvent:
"""Event emitted when an agent starts execution."""
pass
class AgentCompletedEvent:
"""Event emitted when an agent completes execution successfully."""
pass
@dataclass
class AgentFailedEvent:
"""Event emitted when an agent fails during execution."""
error: Exception | None = None
class ExecutorActionItem(BaseModel):
"""Custom item type for workflow executor actions.
This is a DevUI-specific extension to represent workflow executors as output items.
Since OpenAI's ResponseOutputItemAddedEvent only accepts specific item types,
and executor actions are not part of the standard, we need this custom type.
"""
type: Literal["executor_action"] = "executor_action"
id: str
executor_id: str
status: Literal["in_progress", "completed", "failed", "cancelled"] = "in_progress"
metadata: dict[str, Any] | None = None
result: Any | None = None
error: dict[str, Any] | None = None
class CustomResponseOutputItemAddedEvent(BaseModel):
"""Custom version of ResponseOutputItemAddedEvent that accepts any item type.
This allows us to emit executor action items while maintaining the same
event structure as OpenAI's standard.
"""
type: Literal["response.output_item.added"] = "response.output_item.added"
output_index: int
sequence_number: int
item: dict[str, Any] | ExecutorActionItem | Any # Flexible item type
created_at: float | None = None # Unix timestamp; used by frontend for accurate workflow timings
class CustomResponseOutputItemDoneEvent(BaseModel):
"""Custom version of ResponseOutputItemDoneEvent that accepts any item type.
This allows us to emit executor action items while maintaining the same
event structure as OpenAI's standard.
"""
type: Literal["response.output_item.done"] = "response.output_item.done"
output_index: int
sequence_number: int
item: dict[str, Any] | ExecutorActionItem | Any # Flexible item type
created_at: float | None = None # Unix timestamp; used by frontend for accurate workflow timings
class ResponseWorkflowEventComplete(BaseModel):
"""Complete workflow event data.
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
output_index: int = 0
sequence_number: int
class ResponseTraceEventComplete(BaseModel):
"""Complete trace event data.
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
output_index: int = 0
sequence_number: int
class ResponseFunctionResultComplete(BaseModel):
"""DevUI extension: Stream function execution results.
This is a DevUI extension because:
- OpenAI Responses API doesn't stream function results (clients execute functions)
- Agent Framework executes functions server-side, so we stream results for debugging visibility
- ResponseFunctionToolCallOutputItem exists in OpenAI SDK but isn't in ResponseOutputItem union
(it's for Conversations API input, not Responses API streaming output)
This event provides the same structure as OpenAI's function output items but wrapped
in a custom event type since standard events don't support streaming function results.
"""
type: Literal["response.function_result.complete"] = "response.function_result.complete"
call_id: str
output: str
status: Literal["in_progress", "completed", "incomplete"]
item_id: str
output_index: int = 0
sequence_number: int
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."""
entity_id: str
# input_data removed - now using standard input field for all data
model_config = ConfigDict(extra="allow")
# Agent Framework Request Model - Extending real OpenAI types
class AgentFrameworkRequest(BaseModel):
"""OpenAI ResponseCreateParams with Agent Framework routing.
This properly extends the real OpenAI API request format.
- Uses 'model' field as entity_id (agent/workflow name)
- Uses 'conversation' field for conversation context (OpenAI standard)
"""
# All OpenAI fields from ResponseCreateParams
model: str | None = None
input: str | list[Any] | dict[str, Any] # ResponseInputParam + dict for workflow structured input
stream: bool | None = False
# OpenAI conversation parameter (standard!)
conversation: str | dict[str, Any] | None = None # Union[str, {"id": str}]
# Common OpenAI optional fields
instructions: str | None = None
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
model_config = ConfigDict(extra="allow")
def get_entity_id(self) -> str | None:
"""Get entity_id from metadata.entity_id.
In DevUI, entity_id is specified in metadata for routing.
"""
if self.metadata:
return self.metadata.get("entity_id")
return None
def _get_conversation_id(self) -> str | None:
"""Extract conversation_id from conversation parameter.
Supports both string and object forms:
- conversation: "conv_123"
- conversation: {"id": "conv_123"}
"""
if isinstance(self.conversation, str):
return self.conversation
if isinstance(self.conversation, dict):
return self.conversation.get("id")
return None
def to_openai_params(self) -> dict[str, Any]:
"""Convert to dict for OpenAI client compatibility."""
return self.model_dump(exclude_none=True)
# Error handling
class ResponseTraceEvent(BaseModel):
"""Trace event for execution tracing."""
type: Literal["trace_event"] = "trace_event"
data: dict[str, Any]
timestamp: str
class OpenAIError(BaseModel):
"""OpenAI standard error response model."""
error: dict[str, Any]
@classmethod
def create(cls, message: str, type: str = "invalid_request_error", code: str | None = None) -> OpenAIError:
"""Create a standard OpenAI error response."""
error_data = {"message": message, "type": type, "code": code}
return cls(error=error_data)
def to_dict(self) -> dict[str, Any]:
"""Return the error payload as a plain mapping."""
return {"error": dict(self.error)}
def to_json(self) -> str:
"""Return the error payload serialized to JSON."""
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."""
runtime: Literal["python", "dotnet"] = "python"
"""Backend runtime/language - 'python' or 'dotnet' for deployment guides and feature availability."""
capabilities: dict[str, bool] = {}
"""Server capabilities (e.g., instrumentation, 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",
]
@@ -0,0 +1,33 @@
<svg width="805" height="805" viewBox="0 0 805 805" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_iii_510_1294)">
<path d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z" fill="url(#paint0_linear_510_1294)"/>
</g>
<defs>
<filter id="filter0_iii_510_1294" x="103.759" y="93.4694" width="578.735" height="599.314" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="8.39647" dy="8.39647"/>
<feGaussianBlur stdDeviation="20.9912"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.835294 0 0 0 0 0.623529 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_510_1294"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-26.2432" dy="-26.2432"/>
<feGaussianBlur stdDeviation="20.9912"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.3 0"/>
<feBlend mode="plus-darker" in2="effect1_innerShadow_510_1294" result="effect2_innerShadow_510_1294"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-26.2432" dy="-26.2432"/>
<feGaussianBlur stdDeviation="50"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.1 0"/>
<feBlend mode="plus-darker" in2="effect2_innerShadow_510_1294" result="effect3_innerShadow_510_1294"/>
</filter>
<linearGradient id="paint0_linear_510_1294" x1="255.628" y1="-34.3245" x2="618.483" y2="632.032" gradientUnits="userSpaceOnUse">
<stop stop-color="#D59FFF"/>
<stop offset="1" stop-color="#8562C5"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="agentframework.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Framework Dev UI</title>
<script type="module" crossorigin src="./assets/index.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
# Testing DevUI - Quick Setup Guide
Here are the step-by-step instructions to test the new DevUI feature:
## 1. Get the Code
```bash
git clone https://github.com/microsoft/agent-framework.git
cd agent-framework
```
## 2. Setup Environment
Navigate to the Python directory and install dependencies:
```bash
cd python
uv sync --dev
source .venv/bin/activate
```
## 3. Configure Environment Variables
Create a `.env` file in the `python/` directory with your API credentials:
```bash
# Copy the example file
cp .env.example .env
```
Then edit `.env` and add your API keys:
```bash
# For OpenAI (minimum required)
OPENAI_API_KEY="your-api-key-here"
OPENAI_CHAT_COMPLETION_MODEL="gpt-4o-mini"
# Or for Azure OpenAI
AZURE_OPENAI_ENDPOINT="your-endpoint"
AZURE_OPENAI_MODEL="your-deployment-name"
```
## 4. Test DevUI
**Option A: In-Memory Mode (Recommended for quick testing)**
```bash
cd samples/02-agents/devui
python in_memory_mode.py
```
This runs a simple example with predefined agents and opens your browser automatically at http://localhost:8090
**Option B: Directory-Based Discovery**
```bash
cd samples/02-agents/devui
devui
```
This launches the UI with all example agents/workflows at http://localhost:8080
DevUI is auth-enabled by default. Copy the generated token from startup logs and pass it as
`Authorization: Bearer <token>` for direct API calls. Use `--no-auth` only for loopback-only local testing.
## 5. What You'll See
- A web interface for testing agents interactively
- Multiple example agents (weather assistant, general assistant, etc.)
- OpenAI-compatible API endpoints for programmatic access
## 6. API Testing (Optional)
You can also test via API calls:
### Single Request
```bash
curl -X POST http://localhost:8080/v1/responses \
-H "Authorization: Bearer <devui-token>" \
-H "Content-Type: application/json" \
-d '{
"model": "weather_agent",
"input": "What is the weather in Seattle?"
}'
```
### Multi-turn Conversations
```bash
# Create a conversation
curl -X POST http://localhost:8080/v1/conversations \
-H "Authorization: Bearer <devui-token>" \
-H "Content-Type: application/json" \
-d '{"metadata": {"agent_id": "weather_agent"}}'
# Returns: {"id": "conv_abc123", ...}
# Use conversation ID in requests
curl -X POST http://localhost:8080/v1/responses \
-H "Authorization: Bearer <devui-token>" \
-H "Content-Type: application/json" \
-d '{
"model": "weather_agent",
"input": "What is the weather in Seattle?",
"conversation": "conv_abc123"
}'
# Continue the conversation
curl -X POST http://localhost:8080/v1/responses \
-H "Authorization: Bearer <devui-token>" \
-H "Content-Type: application/json" \
-d '{
"model": "weather_agent",
"input": "How about tomorrow?",
"conversation": "conv_abc123"
}'
```
## API Mapping
Agent Framework content types → OpenAI Responses API events (in `_mapper.py`):
| Agent Framework Content | OpenAI Event | Status |
| ------------------------------- | ---------------------------------------- | -------- |
| `TextContent` | `response.output_text.delta` | Standard |
| `TextReasoningContent` | `response.reasoning.delta` | Standard |
| `FunctionCallContent` (initial) | `response.output_item.added` | Standard |
| `FunctionCallContent` (args) | `response.function_call_arguments.delta` | Standard |
| `FunctionResultContent` | `response.function_result.complete` | DevUI |
| `ErrorContent` | `response.error` | Standard |
| `UsageContent` | `response.usage.complete` | Extended |
| `WorkflowEvent` | `response.workflow.event` | DevUI |
| `DataContent`, `UriContent` | `response.trace.complete` | DevUI |
- **Standard** = OpenAI spec, **Extended** = OpenAI + extra fields, **DevUI** = DevUI-specific
## Frontend Development
```bash
cd python/packages/devui/frontend
yarn install
# Development (hot reload)
yarn dev
# Build (copies to backend ui/)
yarn build
```
## Running Tests
```bash
cd python/packages/devui
# All tests
pytest tests/ -v
# Specific suites
pytest tests/test_conversations.py -v # Conversation store
pytest tests/test_server.py -v # API endpoints
pytest tests/test_mapper.py -v # Event mapping
```
## Troubleshooting
- **Missing API key**: Make sure your `.env` file is in the `python/` directory with valid credentials. Or set environment variables directly in your shell before running DevUI.
- **Import errors**: Run `uv sync --dev` again to ensure all dependencies are installed
- **Port conflicts**: DevUI uses ports 8080 and 8090 by default - close other services using these ports
Let me know if you run into any issues!
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

+23
View File
@@ -0,0 +1,23 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
.env.*
claude.md
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+81
View File
@@ -0,0 +1,81 @@
# DevUI Frontend
## Build Instructions
```bash
cd frontend
yarn install
# Create .env.local with backend URL
echo 'VITE_API_BASE_URL=http://localhost:8000' > .env.local
# Create .env.production (empty for relative URLs)
echo '' > .env.production
# Development
yarn dev
# Build (copies to backend)
yarn build
```
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -0,0 +1,31 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
// Allow exporting constants alongside components in specific patterns
// This is common for shadcn/ui components (buttonVariants) and form utilities
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true }
],
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="agentframework.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Framework Dev UI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-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",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.3.1",
"@xyflow/react": "^12.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.540.0",
"next-themes": "^0.4.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/node": "^24.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"tw-animate-css": "^1.3.7",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^8.0.16"
}
}
@@ -0,0 +1,33 @@
<svg width="805" height="805" viewBox="0 0 805 805" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_iii_510_1294)">
<path d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z" fill="url(#paint0_linear_510_1294)"/>
</g>
<defs>
<filter id="filter0_iii_510_1294" x="103.759" y="93.4694" width="578.735" height="599.314" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="8.39647" dy="8.39647"/>
<feGaussianBlur stdDeviation="20.9912"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.835294 0 0 0 0 0.623529 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_510_1294"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-26.2432" dy="-26.2432"/>
<feGaussianBlur stdDeviation="20.9912"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.3 0"/>
<feBlend mode="plus-darker" in2="effect1_innerShadow_510_1294" result="effect2_innerShadow_510_1294"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-26.2432" dy="-26.2432"/>
<feGaussianBlur stdDeviation="50"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.1 0"/>
<feBlend mode="plus-darker" in2="effect2_innerShadow_510_1294" result="effect3_innerShadow_510_1294"/>
</filter>
<linearGradient id="paint0_linear_510_1294" x1="255.628" y1="-34.3245" x2="618.483" y2="632.032" gradientUnits="userSpaceOnUse">
<stop stop-color="#D59FFF"/>
<stop offset="1" stop-color="#8562C5"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+733
View File
@@ -0,0 +1,733 @@
/**
* DevUI App - Minimal orchestrator for agent/workflow interactions
* Features: Entity selection, layout management, debug coordination
*/
import { useEffect, useCallback, useRef, 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, ToastContainer } from "@/components/ui/toast";
import { apiClient } from "@/services/api";
import { PanelRightOpen, ChevronLeft, ChevronDown, ServerOff, Rocket, Lock } from "lucide-react";
import type {
AgentInfo,
WorkflowInfo,
ExtendedResponseStreamEvent,
ResponseTextDeltaEvent,
} from "@/types";
import { Button } from "./components/ui/button";
import { Input } from "./components/ui/input";
import { useDevUIStore } from "@/stores";
const DEBUG_TEXT_EVENT_FLUSH_INTERVAL_MS = 50;
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("");
const bufferedDebugTextRef = useRef<ResponseTextDeltaEvent | null>(null);
const lastBufferedDebugFlushAtRef = useRef(0);
// 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);
const setIsLoadingEntities = useDevUIStore((state) => state.setIsLoadingEntities);
const setEntityError = useDevUIStore((state) => state.setEntityError);
// 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);
const setIsResizing = useDevUIStore((state) => state.setIsResizing);
// Modal state
const showAboutModal = useDevUIStore((state) => state.showAboutModal);
const showGallery = useDevUIStore((state) => state.showGallery);
const showDeployModal = useDevUIStore((state) => state.showDeployModal);
const showEntityNotFoundToast = useDevUIStore((state) => state.showEntityNotFoundToast);
// Modal actions
const setShowAboutModal = useDevUIStore((state) => state.setShowAboutModal);
const setShowGallery = useDevUIStore((state) => state.setShowGallery);
const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal);
const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast);
// Toast state and actions
const toasts = useDevUIStore((state) => state.toasts);
const addToast = useDevUIStore((state) => state.addToast);
const removeToast = useDevUIStore((state) => state.removeToast);
// Initialize app - load agents and workflows
useEffect(() => {
const loadData = async () => {
try {
// 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,
runtime: meta.runtime,
capabilities: meta.capabilities,
authRequired: meta.auth_required,
version: meta.version,
});
// 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);
// Check if there's an entity_id in the URL
const urlParams = new URLSearchParams(window.location.search);
const entityId = urlParams.get("entity_id");
let selectedEntity: AgentInfo | WorkflowInfo | undefined;
// Try to find entity from URL parameter first
if (entityId) {
selectedEntity = allEntities.find((e) => e.id === entityId);
// If entity not found but was requested, show notification
if (!selectedEntity) {
setShowEntityNotFoundToast(true);
}
}
// Fallback to first available entity if URL entity not found
if (!selectedEntity) {
// 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) {
const url = new URL(window.location.href);
url.searchParams.set("entity_id", selectedEntity.id);
window.history.replaceState({}, "", url);
} else {
// Clear entity_id if no entities available
const url = new URL(window.location.href);
url.searchParams.delete("entity_id");
window.history.replaceState({}, "", url);
}
}
if (selectedEntity) {
selectEntity(selectedEntity);
// Load full info for the first entity immediately
if (selectedEntity.metadata?.lazy_loaded === false) {
try {
if (selectedEntity.type === "agent") {
const fullAgent = await apiClient.getAgentInfo(
selectedEntity.id
);
updateAgent(fullAgent);
} else {
const fullWorkflow = await apiClient.getWorkflowInfo(
selectedEntity.id
);
updateWorkflow(fullWorkflow);
}
} catch (error) {
console.error(
`Failed to load full info for first entity ${selectedEntity.id}:`,
error
);
// Show toast for entity load errors (don't use setEntityError - that kills the whole UI)
const errorMessage = error instanceof Error ? error.message : String(error);
addToast({
type: "error",
message: `Failed to load "${selectedEntity.id}": ${errorMessage}`,
});
}
}
}
setIsLoadingEntities(false);
} catch (error) {
console.error("Failed to load agents/workflows:", error);
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);
}
};
loadData();
}, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast, addToast, setEntities]);
// 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) => {
e.preventDefault();
setIsResizing(true);
const startX = e.clientX;
const startWidth = debugPanelWidth;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = startX - e.clientX; // Subtract because we're dragging from right
const newWidth = Math.max(
200,
Math.min(window.innerWidth * 0.5, startWidth + deltaX)
);
setDebugPanelWidth(newWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[debugPanelWidth]
);
// Handle entity selection - uses Zustand's selectEntity which handles ALL side effects
const handleEntitySelect = useCallback(
async (item: AgentInfo | WorkflowInfo) => {
selectEntity(item); // This clears conversation state, debug events, and updates URL!
// If entity is sparse (not fully loaded), load full details
if (item.metadata?.lazy_loaded === false) {
try {
if (item.type === "agent") {
const fullAgent = await apiClient.getAgentInfo(item.id);
updateAgent(fullAgent);
} else {
const fullWorkflow = await apiClient.getWorkflowInfo(item.id);
updateWorkflow(fullWorkflow);
}
} catch (error) {
console.error(`Failed to load full info for ${item.id}:`, error);
// Show toast for entity load errors (don't use setEntityError - that kills the whole UI)
const errorMessage = error instanceof Error ? error.message : String(error);
addToast({
type: "error",
message: `Failed to load "${item.id}": ${errorMessage}`,
});
}
}
},
[selectEntity, updateAgent, updateWorkflow, addToast]
);
const flushBufferedDebugText = useCallback(() => {
const bufferedEvent = bufferedDebugTextRef.current;
if (!bufferedEvent) {
return;
}
bufferedDebugTextRef.current = null;
lastBufferedDebugFlushAtRef.current = performance.now();
addDebugEvent(bufferedEvent);
}, [addDebugEvent]);
// Handle debug events from active view
const handleDebugEvent = useCallback(
(event: ExtendedResponseStreamEvent | "clear") => {
if (event === "clear") {
bufferedDebugTextRef.current = null;
clearDebugEvents();
return;
}
if (
event.type === "response.output_text.delta" &&
"delta" in event &&
typeof event.delta === "string" &&
event.delta.length > 0
) {
const bufferedEvent = bufferedDebugTextRef.current;
const isSameOutput =
bufferedEvent !== null &&
bufferedEvent.item_id === event.item_id &&
bufferedEvent.output_index === event.output_index &&
bufferedEvent.content_index === event.content_index;
if (isSameOutput && bufferedEvent) {
bufferedDebugTextRef.current = {
...bufferedEvent,
delta: bufferedEvent.delta + event.delta,
sequence_number: event.sequence_number ?? bufferedEvent.sequence_number,
};
} else {
flushBufferedDebugText();
bufferedDebugTextRef.current = { ...event } as ResponseTextDeltaEvent;
}
if (
performance.now() - lastBufferedDebugFlushAtRef.current >=
DEBUG_TEXT_EVENT_FLUSH_INTERVAL_MS
) {
flushBufferedDebugText();
}
return;
}
flushBufferedDebugText();
addDebugEvent(event);
},
[addDebugEvent, clearDebugEvents, flushBufferedDebugText]
);
// Show loading state while initializing
if (isLoadingEntities) {
return (
<div className="h-screen flex flex-col bg-background">
{/* Top Bar - Skeleton */}
<header className="flex h-14 items-center gap-4 border-b px-4">
<div className="w-64 h-9 bg-muted animate-pulse rounded-md" />
<div className="flex items-center gap-2 ml-auto">
<div className="w-8 h-8 bg-muted animate-pulse rounded-md" />
<div className="w-8 h-8 bg-muted animate-pulse rounded-md" />
</div>
</header>
{/* Loading Content */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="text-lg font-medium">Initializing DevUI...</div>
<div className="text-sm text-muted-foreground mt-2">Loading agents and workflows from your configuration</div>
</div>
</div>
</div>
);
}
// Show error state if loading failed
if (entityError) {
const currentBackendUrl = apiClient.getBaseUrl();
const isAuthError = entityError === "UNAUTHORIZED" || authRequired;
// Extract port from the backend URL for the command suggestion
let backendPort = "8080"; // default fallback
try {
if (currentBackendUrl) {
const url = new URL(currentBackendUrl);
backendPort = url.port || (url.protocol === "https:" ? "443" : "80");
}
} catch {
// If URL parsing fails, keep default
}
return (
<div className="h-screen flex flex-col bg-background">
<AppHeader
agents={[]}
workflows={[]}
entities={[]}
selectedItem={undefined}
onSelect={() => {}}
isLoading={false}
onSettingsClick={() => setShowAboutModal(true)}
/>
{/* Error Content */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center space-y-6 max-w-2xl">
{/* Icon */}
<div className="flex justify-center">
<div className="rounded-full bg-muted p-4 animate-pulse">
{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">
{isAuthError ? "Authentication Required" : "Can't Connect to Backend"}
</h2>
<p className="text-muted-foreground text-base">
{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>
{/* 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 />
&nbsp;&nbsp; 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 {backendPort}
</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>
{/* 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>
</>
)}
</div>
</div>
{/* Settings Modal */}
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
</div>
);
}
return (
<div className="h-screen flex flex-col bg-background max-h-screen">
<AppHeader
agents={agents}
workflows={workflows}
entities={entities}
selectedItem={selectedAgent}
onSelect={handleEntitySelect}
onBrowseGallery={() => setShowGallery(true)}
isLoading={isLoadingEntities}
onSettingsClick={() => setShowAboutModal(true)}
/>
{/* Main Content - Split Panel or Gallery */}
<div className="flex flex-1 overflow-hidden">
{showGallery ? (
// Show gallery full screen (w-full ensures it takes entire width)
<div className="flex-1 w-full">
<GalleryView
variant="route"
onClose={() => setShowGallery(false)}
hasExistingEntities={
agents.length > 0 || workflows.length > 0
}
/>
</div>
) : agents.length === 0 && workflows.length === 0 ? (
// Empty state - show gallery inline (full width, no debug panel)
<GalleryView variant="inline" />
) : (
<>
{/* Left Panel - Main View */}
<div className="flex-1 min-w-0">
{selectedAgent ? (
selectedAgent.type === "agent" ? (
<AgentView
selectedAgent={selectedAgent as AgentInfo}
onDebugEvent={handleDebugEvent}
/>
) : (
<WorkflowView
selectedWorkflow={selectedAgent as WorkflowInfo}
onDebugEvent={handleDebugEvent}
/>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select an agent or workflow to get started.
</div>
)}
</div>
{uiMode === "developer" && showDebugPanel ? (
<>
{/* Resize Handle */}
<div
className={`w-1 cursor-col-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
}`}
onMouseDown={handleMouseDown}
>
<div className="absolute inset-y-0 -left-2 -right-2 flex items-center justify-center">
<div
className={`h-12 w-1 rounded-full transition-all duration-200 ease-in-out ${
isResizing
? "bg-primary shadow-lg shadow-primary/25"
: "bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20"
}`}
></div>
</div>
</div>
{/* Right Panel - Debug */}
<div
className="flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]"
style={{ width: debugPanelMinimized ? '2.5rem' : `${debugPanelWidth}px` }}
>
{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"
>
{/* 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
variant="ghost"
size="sm"
onClick={() => setShowDebugPanel(true)}
className="h-full w-10 rounded-none border-l"
title="Show debug panel"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
</div>
) : null}
</>
)}
</div>
{/* Settings Modal */}
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
{/* Deployment Modal */}
<DeploymentModal
open={showDeployModal}
onClose={() => setShowDeployModal(false)}
agentName={selectedAgent?.name}
entity={selectedAgent}
/>
{/* Toast Notification */}
{showEntityNotFoundToast && (
<Toast
message="Entity not found. Showing first available entity instead."
type="info"
onClose={() => setShowEntityNotFoundToast(false)}
/>
)}
{/* Toast Container for reload and other notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
);
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,215 @@
/**
* AgentDetailsModal - Responsive grid-based modal for displaying agent metadata
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import {
Bot,
Package,
FileText,
FolderOpen,
Database,
Globe,
CheckCircle,
XCircle,
} from "lucide-react";
import type { AgentInfo } from "@/types";
interface AgentDetailsModalProps {
agent: AgentInfo;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface DetailCardProps {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
className?: string;
}
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
return (
<div className={`border rounded-lg p-4 bg-card ${className}`}>
<div className="flex items-center gap-2 mb-3">
{icon}
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
<div className="text-sm text-muted-foreground">{children}</div>
</div>
);
}
export function AgentDetailsModal({
agent,
open,
onOpenChange,
}: AgentDetailsModalProps) {
const sourceIcon =
agent.source === "directory" ? (
<FolderOpen className="h-4 w-4 text-muted-foreground" />
) : agent.source === "in_memory" ? (
<Database className="h-4 w-4 text-muted-foreground" />
) : (
<Globe className="h-4 w-4 text-muted-foreground" />
);
const sourceLabel =
agent.source === "directory"
? "Local"
: agent.source === "in_memory"
? "In-Memory"
: "Gallery";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-6 pt-6 flex-shrink-0">
<DialogTitle>Agent Details</DialogTitle>
<DialogClose onClose={() => onOpenChange(false)} />
</DialogHeader>
<div className="px-6 pb-6 overflow-y-auto flex-1">
{/* Header Section */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<Bot className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">
{agent.name || agent.id}
</h2>
</div>
{agent.description && (
<p className="text-muted-foreground">{agent.description}</p>
)}
</div>
<div className="h-px bg-border mb-6" />
{/* Grid Layout for Metadata */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* Model & Client */}
{(agent.model_id || agent.chat_client_type) && (
<DetailCard
title="Model & Client"
icon={<Bot className="h-4 w-4 text-muted-foreground" />}
>
<div className="space-y-1">
{agent.model_id && (
<div className="font-mono text-foreground">{agent.model_id}</div>
)}
{agent.chat_client_type && (
<div className="text-xs">({agent.chat_client_type})</div>
)}
</div>
</DetailCard>
)}
{/* Source */}
<DetailCard title="Source" icon={sourceIcon}>
<div className="space-y-1">
<div className="text-foreground">{sourceLabel}</div>
{agent.module_path && (
<div className="font-mono text-xs break-all">
{agent.module_path}
</div>
)}
</div>
</DetailCard>
{/* Environment */}
<DetailCard
title="Environment"
icon={
agent.has_env ? (
<XCircle className="h-4 w-4 text-orange-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)
}
className="md:col-span-2"
>
<div
className={
agent.has_env
? "text-orange-600 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}
>
{agent.has_env
? "Requires environment variables"
: "No environment variables required"}
</div>
</DetailCard>
</div>
{/* Full Width Sections */}
{agent.instructions && (
<DetailCard
title="Instructions"
icon={<FileText className="h-4 w-4 text-muted-foreground" />}
className="mb-4"
>
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{agent.instructions}
</div>
</DetailCard>
)}
{/* Tools and MiddlewareTypes Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Tools */}
{agent.tools && agent.tools.length > 0 && (
<DetailCard
title={`Tools (${agent.tools.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
<ul className="space-y-1">
{agent.tools.map((tool, index) => (
<li key={index} className="font-mono text-xs text-foreground">
{tool}
</li>
))}
</ul>
</DetailCard>
)}
{/* Middlewares */}
{agent.middleware && agent.middleware.length > 0 && (
<DetailCard
title={`Middlewares (${agent.middleware.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
<ul className="space-y-1">
{agent.middleware.map((mw, index) => (
<li key={index} className="font-mono text-xs text-foreground">
{mw}
</li>
))}
</ul>
</DetailCard>
)}
{/* Context Provider */}
{agent.context_provider && (
<DetailCard
title="Context Provider"
icon={<Database className="h-4 w-4 text-muted-foreground" />}
className={!agent.middleware || agent.middleware.length === 0 ? "md:col-start-2" : ""}
>
<div className="font-mono text-xs text-foreground">
{agent.context_provider}
</div>
</DetailCard>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,949 @@
/**
* ContextInspector - Token usage visualization and context analysis
*
* Features:
* - Stacked bar chart showing input/output tokens per turn
* - Composition view showing what fills the context (system, user, assistant, tools)
* - Per-turn vs cumulative modes
* - Summary statistics (total, average, peak)
* - Pure CSS visualization (no external charting library)
*/
import { useState, useMemo } from "react";
import { useDevUIStore } from "@/stores/devuiStore";
import {
BarChart3,
Layers,
Info,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ExtendedResponseStreamEvent } from "@/types";
import {
TraceAttributes,
type TypedTraceAttributes,
type TraceMessage,
parseTraceMessages,
isTextPart,
isToolCallPart,
isToolResultPart,
} from "@/types/openai";
// Trace data interface matching debug-panel types
interface TraceEventData {
operation_name?: string;
duration_ms?: number;
status?: string;
attributes?: TypedTraceAttributes;
span_id?: string;
trace_id?: string;
parent_span_id?: string | null;
start_time?: number;
end_time?: number;
entity_id?: string;
response_id?: string | null;
}
// Context composition breakdown
interface ContextComposition {
system: number; // character count
user: number;
assistant: number;
toolCalls: number; // function definitions + arguments
toolResults: number; // function outputs
total: number;
}
// Turn data extracted from traces
interface TurnData {
response_id: string;
timestamp: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
model?: string;
entity_id?: string;
duration_ms: number;
composition: ContextComposition;
}
// Props for the component
interface ContextInspectorProps {
events: ExtendedResponseStreamEvent[];
}
// Parse message content to extract composition using typed TraceMessage format
function parseComposition(messagesJson: string | unknown): ContextComposition {
const composition: ContextComposition = {
system: 0,
user: 0,
assistant: 0,
toolCalls: 0,
toolResults: 0,
total: 0,
};
try {
// Use the typed parser for string input
let messages: TraceMessage[];
if (typeof messagesJson === "string") {
messages = parseTraceMessages(messagesJson);
} else if (Array.isArray(messagesJson)) {
messages = messagesJson as TraceMessage[];
} else {
return composition;
}
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const role = message.role;
const parts = message.parts;
// Calculate character count for this message
let charCount = 0;
// Handle parts array (Agent Framework format)
// Using type guards for type-safe access to part properties
if (Array.isArray(parts)) {
for (const part of parts) {
if (!part || typeof part !== "object") continue;
if (isTextPart(part)) {
// Text content can be in either 'content' or 'text' field
const text = part.content || part.text || "";
charCount += text.length;
} else if (isToolCallPart(part)) {
// Tool call includes name and arguments
const name = part.name || "";
const args = part.arguments || "";
composition.toolCalls += name.length + args.length;
} else if (isToolResultPart(part)) {
// Tool result - check both 'result' and 'response' fields
const result = part.result || part.response || "";
composition.toolResults += result.length;
}
}
}
// Categorize by role
if (role === "system") {
composition.system += charCount;
} else if (role === "user") {
composition.user += charCount;
} else if (role === "assistant") {
composition.assistant += charCount;
} else if (role === "tool") {
composition.toolResults += charCount;
}
}
composition.total =
composition.system +
composition.user +
composition.assistant +
composition.toolCalls +
composition.toolResults;
} catch {
// Parsing failed, return empty composition
}
return composition;
}
// Extract turn data from trace events
function extractTurnData(events: ExtendedResponseStreamEvent[]): TurnData[] {
const traceEvents = events.filter(e => e.type === "response.trace.completed");
// Group by response_id
const byResponseId = new Map<string, TraceEventData[]>();
for (const event of traceEvents) {
if (!("data" in event)) continue;
const data = event.data as TraceEventData;
const responseId = data.response_id || "unknown";
if (!byResponseId.has(responseId)) {
byResponseId.set(responseId, []);
}
byResponseId.get(responseId)!.push(data);
}
const turns: TurnData[] = [];
for (const [responseId, traces] of byResponseId) {
let inputTokens = 0;
let outputTokens = 0;
let model: string | undefined;
let timestamp = Date.now() / 1000;
let entity_id: string | undefined;
let totalDuration = 0;
let composition: ContextComposition = {
system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0
};
for (const trace of traces) {
const attrs = trace.attributes || {};
// Get token counts using typed attribute keys
const traceInput = attrs[TraceAttributes.INPUT_TOKENS];
const traceOutput = attrs[TraceAttributes.OUTPUT_TOKENS];
if (traceInput !== undefined) {
inputTokens += Number(traceInput);
}
if (traceOutput !== undefined) {
outputTokens += Number(traceOutput);
}
// Get model using typed attribute key
if (attrs[TraceAttributes.MODEL]) {
model = String(attrs[TraceAttributes.MODEL]);
}
// Get timestamp
if (trace.start_time && trace.start_time < timestamp) {
timestamp = trace.start_time;
}
// Get entity_id
if (trace.entity_id) {
entity_id = trace.entity_id;
}
// Sum durations
if (trace.duration_ms) {
totalDuration += Number(trace.duration_ms);
}
// Parse composition from input messages using typed attribute key
const inputMessages = attrs[TraceAttributes.INPUT_MESSAGES];
if (inputMessages && composition.total === 0) {
composition = parseComposition(inputMessages);
}
// Also check for system instructions using typed attribute key
const systemInstructions = attrs[TraceAttributes.SYSTEM_INSTRUCTIONS];
if (systemInstructions && typeof systemInstructions === "string" && composition.system === 0) {
composition.system = systemInstructions.length;
composition.total += systemInstructions.length;
}
}
// Only include turns that have token data
if (inputTokens > 0 || outputTokens > 0) {
turns.push({
response_id: responseId,
timestamp,
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
model,
entity_id,
duration_ms: totalDuration,
composition,
});
}
}
// Sort by timestamp (oldest first)
turns.sort((a, b) => a.timestamp - b.timestamp);
return turns;
}
// Calculate summary stats
function calculateStats(turns: TurnData[]) {
if (turns.length === 0) {
return {
totalInput: 0,
totalOutput: 0,
totalTokens: 0,
avgInput: 0,
avgOutput: 0,
avgTotal: 0,
peakInput: 0,
peakOutput: 0,
peakTotal: 0,
turnCount: 0,
};
}
const totalInput = turns.reduce((sum, t) => sum + t.input_tokens, 0);
const totalOutput = turns.reduce((sum, t) => sum + t.output_tokens, 0);
const totalTokens = totalInput + totalOutput;
const peakInput = Math.max(...turns.map(t => t.input_tokens));
const peakOutput = Math.max(...turns.map(t => t.output_tokens));
const peakTotal = Math.max(...turns.map(t => t.total_tokens));
return {
totalInput,
totalOutput,
totalTokens,
avgInput: Math.round(totalInput / turns.length),
avgOutput: Math.round(totalOutput / turns.length),
avgTotal: Math.round(totalTokens / turns.length),
peakInput,
peakOutput,
peakTotal,
turnCount: turns.length,
};
}
// Aggregate composition across all turns
function aggregateComposition(turns: TurnData[]): ContextComposition {
return turns.reduce(
(acc, turn) => ({
system: acc.system + turn.composition.system,
user: acc.user + turn.composition.user,
assistant: acc.assistant + turn.composition.assistant,
toolCalls: acc.toolCalls + turn.composition.toolCalls,
toolResults: acc.toolResults + turn.composition.toolResults,
total: acc.total + turn.composition.total,
}),
{ system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0 }
);
}
// Format large numbers with K suffix
function formatTokenCount(n: number): string {
if (n >= 1000) {
return `${(n / 1000).toFixed(1)}k`;
}
return String(n);
}
// Color constants - single source of truth for all visualizations
const SEGMENT_COLORS = {
// Token segments
input: "bg-blue-500 dark:bg-blue-600",
output: "bg-emerald-500 dark:bg-emerald-600",
// Composition segments
system: "bg-purple-500 dark:bg-purple-600",
user: "bg-blue-500 dark:bg-blue-600",
assistant: "bg-emerald-500 dark:bg-emerald-600",
toolCalls: "bg-amber-500 dark:bg-amber-600",
toolResults: "bg-orange-500 dark:bg-orange-600",
} as const;
// Segment definition for the unified bar component
interface BarSegment {
key: string;
value: number;
color: string;
label: string;
}
// Unified segmented bar component with tooltips
// Replaces both TokenBar and CompositionBar for consistency and maintainability
function SegmentedBar({
segments,
maxValue,
height = 20,
renderLabel,
}: {
segments: BarSegment[];
maxValue: number;
height?: number;
renderLabel?: (total: number, segments: BarSegment[]) => React.ReactNode;
}) {
const total = segments.reduce((sum, s) => sum + s.value, 0);
if (total === 0) {
return (
<div className="flex items-center gap-2 w-full">
<div
className="rounded bg-muted/30 flex-1"
style={{ height: `${height}px` }}
/>
</div>
);
}
// When maxValue is 0, use full width (100%) - focus on ratios within the bar
// When maxValue > 0, scale relative to max - focus on size comparison
const widthPercent = maxValue > 0 ? (total / maxValue) * 100 : 100;
// Pre-compute segment metadata for tooltips
const segmentsWithMeta = segments
.filter(s => s.value > 0)
.map(seg => ({
...seg,
percent: Math.round((seg.value / total) * 100),
}));
return (
<div className="flex items-center gap-2 w-full">
<div
className="relative rounded overflow-hidden bg-muted/30 flex-1"
style={{ height: `${height}px` }}
>
<TooltipProvider delayDuration={150}>
<div
className="h-full flex transition-all duration-300"
style={{ width: `${widthPercent}%` }}
>
{segmentsWithMeta.map((seg) => (
<Tooltip key={seg.key}>
<TooltipTrigger asChild>
<div
className={`h-full ${seg.color} transition-all duration-150 hover:brightness-110 hover:scale-y-[1.15] origin-bottom cursor-default`}
style={{ width: `${(seg.value / total) * 100}%` }}
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-sm ${seg.color} flex-shrink-0`} />
<span className="font-medium">{seg.label}</span>
<span className="opacity-80">{formatTokenCount(seg.value)} ({seg.percent}%)</span>
</div>
</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
</div>
{renderLabel?.(total, segments)}
</div>
);
}
// Helper to create token segments (input/output)
function createTokenSegments(input: number, output: number): BarSegment[] {
return [
{ key: "input", value: input, color: SEGMENT_COLORS.input, label: "Input" },
{ key: "output", value: output, color: SEGMENT_COLORS.output, label: "Output" },
];
}
// Helper to create composition segments
function createCompositionSegments(composition: ContextComposition): BarSegment[] {
return [
{ key: "system", value: composition.system, color: SEGMENT_COLORS.system, label: "System" },
{ key: "user", value: composition.user, color: SEGMENT_COLORS.user, label: "User" },
{ key: "assistant", value: composition.assistant, color: SEGMENT_COLORS.assistant, label: "Assistant" },
{ key: "toolCalls", value: composition.toolCalls, color: SEGMENT_COLORS.toolCalls, label: "Tool Calls" },
{ key: "toolResults", value: composition.toolResults, color: SEGMENT_COLORS.toolResults, label: "Tool Results" },
];
}
// Composition breakdown list
function CompositionBreakdown({
composition,
className = "",
}: {
composition: ContextComposition;
className?: string;
}) {
const { system, user, assistant, toolCalls, toolResults, total } = composition;
if (total === 0) {
return (
<div className={`text-xs text-muted-foreground ${className}`}>
No composition data available
</div>
);
}
const items = [
{ label: "System", value: system, color: SEGMENT_COLORS.system },
{ label: "User", value: user, color: SEGMENT_COLORS.user },
{ label: "Assistant", value: assistant, color: SEGMENT_COLORS.assistant },
{ label: "Tool Calls", value: toolCalls, color: SEGMENT_COLORS.toolCalls },
{ label: "Tool Results", value: toolResults, color: SEGMENT_COLORS.toolResults },
].filter(item => item.value > 0);
return (
<div className={`space-y-1.5 ${className}`}>
{items.map((item) => {
const percent = Math.round((item.value / total) * 100);
return (
<div key={item.label} className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-sm ${item.color}`} />
<span className="text-muted-foreground w-20">{item.label}</span>
<div className="flex-1 h-3 bg-muted/30 rounded overflow-hidden">
<div
className={`h-full ${item.color} transition-all duration-300`}
style={{ width: `${percent}%` }}
/>
</div>
<span className="font-mono w-10 text-right text-muted-foreground">
{percent}%
</span>
</div>
);
})}
</div>
);
}
// Turn row component
function TurnRow({
turn,
index,
maxValue,
maxCompositionValue,
cumulativeInput,
cumulativeOutput,
cumulativeComposition,
showCumulative,
viewMode,
}: {
turn: TurnData;
index: number;
maxValue: number;
maxCompositionValue: number;
cumulativeInput: number;
cumulativeOutput: number;
cumulativeComposition: ContextComposition;
showCumulative: boolean;
viewMode: "tokens" | "composition";
}) {
const [isExpanded, setIsExpanded] = useState(false);
const displayInput = showCumulative ? cumulativeInput : turn.input_tokens;
const displayOutput = showCumulative ? cumulativeOutput : turn.output_tokens;
const displayComposition = showCumulative ? cumulativeComposition : turn.composition;
const timestamp = new Date(turn.timestamp * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
return (
<div className="border-b border-muted/50 last:border-0">
<div
className="flex items-center gap-3 py-2 px-2 hover:bg-muted/30 cursor-pointer transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
{/* Turn number */}
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium flex-shrink-0">
{index + 1}
</div>
{/* Bar */}
<div className="flex-1 min-w-0">
{viewMode === "tokens" ? (
<SegmentedBar
segments={createTokenSegments(displayInput, displayOutput)}
maxValue={maxValue}
height={20}
renderLabel={(_, segs) => (
<div className="flex items-center gap-1 text-xs font-mono text-muted-foreground min-w-[80px] justify-end">
<span className="text-blue-600 dark:text-blue-400">{formatTokenCount(segs[0]?.value || 0)}</span>
<span>/</span>
<span className="text-emerald-600 dark:text-emerald-400">{formatTokenCount(segs[1]?.value || 0)}</span>
</div>
)}
/>
) : (
<SegmentedBar
segments={createCompositionSegments(displayComposition)}
maxValue={maxCompositionValue}
height={20}
renderLabel={(total) => (
<div className="text-xs font-mono text-muted-foreground min-w-[50px] text-right">
{formatTokenCount(Math.round(total / 4))}~
</div>
)}
/>
)}
</div>
{/* Expand icon */}
<div className="text-muted-foreground flex-shrink-0">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</div>
{/* Expanded details */}
{isExpanded && (
<div className="pb-3">
{/* Connector line */}
<div className="flex items-start gap-3 px-2">
<div className="w-6 flex justify-center flex-shrink-0">
<div className="w-px h-full bg-muted" />
</div>
<div className="flex-1 min-w-0">
{/* L-connector and composition */}
<div className="flex items-start gap-2">
<div className="text-muted-foreground text-xs mt-1"></div>
<div className="flex-1 space-y-3">
{/* Basic info */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
<div>Time: <span className="font-mono text-foreground">{timestamp}</span></div>
<div>Duration: <span className="font-mono text-foreground">{turn.duration_ms.toFixed(0)}ms</span></div>
{turn.model && (
<div>Model: <span className="font-mono text-foreground">{turn.model}</span></div>
)}
{turn.entity_id && (
<div>Entity: <span className="font-mono text-foreground">{turn.entity_id}</span></div>
)}
</div>
{/* Token counts - shown in tokens mode */}
{viewMode === "tokens" && (
<div className="flex gap-4 text-xs">
<div>
<span className="text-blue-600 dark:text-blue-400">Input:</span>{" "}
<span className="font-mono">{turn.input_tokens.toLocaleString()}</span>
</div>
<div>
<span className="text-emerald-600 dark:text-emerald-400">Output:</span>{" "}
<span className="font-mono">{turn.output_tokens.toLocaleString()}</span>
</div>
<div>
<span className="text-muted-foreground">Total:</span>{" "}
<span className="font-mono">{turn.total_tokens.toLocaleString()}</span>
</div>
</div>
)}
{/* Composition breakdown - shown in composition mode */}
{viewMode === "composition" && turn.composition.total > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<Info className="h-3 w-3" />
Context Composition (estimated from ~{formatTokenCount(Math.round(turn.composition.total / 4))} tokens)
</div>
<CompositionBreakdown composition={turn.composition} />
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}
// Summary stats card
function StatCard({
label,
value,
icon: Icon,
color = "default",
}: {
label: string;
value: string | number;
icon: typeof BarChart3;
color?: "default" | "blue" | "green";
}) {
const colorClass = {
default: "text-muted-foreground",
blue: "text-blue-600 dark:text-blue-400",
green: "text-emerald-600 dark:text-emerald-400",
}[color];
return (
<div className="flex items-center gap-2 p-2 bg-muted/30 rounded">
<Icon className={`h-4 w-4 ${colorClass}`} />
<div className="flex-1 min-w-0">
<div className="text-xs text-muted-foreground truncate">{label}</div>
<div className="font-mono text-sm font-medium">{value}</div>
</div>
</div>
);
}
// Main component
export function ContextInspector({ events }: ContextInspectorProps) {
// Use persisted store state instead of local useState
const viewMode = useDevUIStore((state) => state.contextInspectorViewMode);
const setViewMode = useDevUIStore((state) => state.setContextInspectorViewMode);
const showCumulative = useDevUIStore((state) => state.contextInspectorCumulative);
const setShowCumulative = useDevUIStore((state) => state.setContextInspectorCumulative);
// Extract turn data from traces
const turns = useMemo(() => extractTurnData(events), [events]);
// Calculate stats
const stats = useMemo(() => calculateStats(turns), [turns]);
// Aggregate composition
const totalComposition = useMemo(() => aggregateComposition(turns), [turns]);
// Calculate max value for bar scaling (tokens)
// In non-cumulative mode, use 0 to signal full-width bars (focus on ratios)
// In cumulative mode, scale relative to total (focus on growth)
const maxValue = useMemo(() => {
if (turns.length === 0) return 0;
if (showCumulative) {
return stats.totalTokens;
} else {
// Return 0 to signal "use full width" - each bar shows its own ratio
return 0;
}
}, [turns, showCumulative, stats.totalTokens]);
// Calculate max value for composition bar scaling
// Same logic: full-width in non-cumulative, scaled in cumulative
const maxCompositionValue = useMemo(() => {
if (turns.length === 0) return 0;
if (showCumulative) {
return totalComposition.total;
} else {
// Return 0 to signal "use full width"
return 0;
}
}, [turns, showCumulative, totalComposition.total]);
// Calculate cumulative values for tokens and composition
const cumulativeData = useMemo(() => {
let cumInput = 0;
let cumOutput = 0;
let cumComposition: ContextComposition = {
system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0
};
return turns.map(t => {
cumInput += t.input_tokens;
cumOutput += t.output_tokens;
cumComposition = {
system: cumComposition.system + t.composition.system,
user: cumComposition.user + t.composition.user,
assistant: cumComposition.assistant + t.composition.assistant,
toolCalls: cumComposition.toolCalls + t.composition.toolCalls,
toolResults: cumComposition.toolResults + t.composition.toolResults,
total: cumComposition.total + t.composition.total,
};
return {
input: cumInput,
output: cumOutput,
composition: { ...cumComposition }
};
});
}, [turns]);
// No data state
if (turns.length === 0) {
return (
<div className="flex flex-col items-center text-center p-6 pt-9">
<BarChart3 className="h-8 w-8 text-muted-foreground mb-3" />
<div className="text-sm font-medium mb-1">No Data</div>
<div className="text-xs text-muted-foreground max-w-[200px]">
Run{" "}
<span className="font-mono bg-accent/10 px-1 rounded">
devui --instrumentation
</span>{" "}
and start a conversation.
</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-3 border-b flex-shrink-0 space-y-2">
{/* Title row */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
<span className="font-medium text-sm">Context Inspector</span>
<Badge variant="outline" className="text-xs">
{turns.length} turn{turns.length !== 1 ? "s" : ""}
</Badge>
</div>
{/* Cumulative checkbox */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={showCumulative}
onCheckedChange={(checked) => setShowCumulative(checked === true)}
className="h-3.5 w-3.5"
/>
<span>Cumulative</span>
</label>
</div>
{/* View mode segmented control */}
<div className="flex items-center bg-muted rounded-md p-1">
<button
onClick={() => setViewMode("tokens")}
className={`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${
viewMode === "tokens"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
Tokens
</button>
<button
onClick={() => setViewMode("composition")}
className={`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${
viewMode === "composition"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
Composition
</button>
</div>
{/* View mode description */}
<div className="text-xs text-muted-foreground">
{viewMode === "tokens"
? "Token usage per turn"
: "Context breakdown by message type (chars)"}
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-3 space-y-4">
{/* Legend */}
<div className="flex items-center gap-4 text-xs px-1 flex-wrap">
{viewMode === "tokens" ? (
<>
<div className="flex items-center gap-1.5">
<div className={`w-3 h-3 rounded ${SEGMENT_COLORS.input}`} />
<span className="text-muted-foreground">Input ()</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-3 h-3 rounded ${SEGMENT_COLORS.output}`} />
<span className="text-muted-foreground">Output ()</span>
</div>
</>
) : (
<>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.system}`} />
<span className="text-muted-foreground">System</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.user}`} />
<span className="text-muted-foreground">User</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.assistant}`} />
<span className="text-muted-foreground">Assistant</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.toolCalls}`} />
<span className="text-muted-foreground">Tools</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.toolResults}`} />
<span className="text-muted-foreground">Results</span>
</div>
</>
)}
<div className="flex-1" />
<div className="flex items-center gap-1 text-muted-foreground">
<Info className="h-3 w-3" />
<span>Click for details</span>
</div>
</div>
{/* Turn bars */}
<div className="border rounded-lg overflow-hidden">
{turns.map((turn, index) => (
<TurnRow
key={turn.response_id}
turn={turn}
index={index}
maxValue={maxValue}
maxCompositionValue={maxCompositionValue}
cumulativeInput={cumulativeData[index]?.input || 0}
cumulativeOutput={cumulativeData[index]?.output || 0}
cumulativeComposition={cumulativeData[index]?.composition || turn.composition}
showCumulative={showCumulative}
viewMode={viewMode}
/>
))}
</div>
{/* Session summary */}
<div className="border rounded-lg overflow-hidden">
<div className="p-3 bg-muted/30 border-b">
<span className="text-xs font-medium">Session Summary</span>
</div>
<div className="p-3 space-y-3">
{/* Token summary cards */}
<div className="grid grid-cols-3 gap-2">
<StatCard
label="Total Tokens"
value={formatTokenCount(stats.totalTokens)}
icon={Layers}
/>
<StatCard
label="Input"
value={formatTokenCount(stats.totalInput)}
icon={BarChart3}
color="blue"
/>
<StatCard
label="Output"
value={formatTokenCount(stats.totalOutput)}
icon={BarChart3}
color="green"
/>
</div>
{/* Per-turn statistics (only for multi-turn sessions) */}
{turns.length > 1 && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs pt-2 border-t border-muted/50">
<div className="flex justify-between">
<span className="text-muted-foreground">Avg per turn:</span>
<span className="font-mono">{formatTokenCount(stats.avgTotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Peak turn:</span>
<span className="font-mono">{formatTokenCount(stats.peakTotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Avg input:</span>
<span className="font-mono text-blue-600 dark:text-blue-400">{formatTokenCount(stats.avgInput)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Avg output:</span>
<span className="font-mono text-emerald-600 dark:text-emerald-400">{formatTokenCount(stats.avgOutput)}</span>
</div>
</div>
)}
{/* Total composition */}
{totalComposition.total > 0 && (
<div className="pt-3 border-t border-muted/50">
<div className="flex items-start gap-2">
<div className="text-muted-foreground text-xs mt-0.5"></div>
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<Info className="h-3 w-3" />
Total Composition (all turns)
</div>
<CompositionBreakdown composition={totalComposition} />
</div>
</div>
</div>
)}
</div>
</div>
</div>
</ScrollArea>
</div>
);
}
@@ -0,0 +1,7 @@
/**
* Agent Feature - Exports
*/
export { AgentView } from "./agent-view";
export { AgentDetailsModal } from "./agent-details-modal";
export * from "./message-renderers";
@@ -0,0 +1,481 @@
/**
* OpenAI Content Renderer - Renders OpenAI Conversations API content types
* This is the CORRECT implementation that works with OpenAI types only
*/
import { useState, useEffect } from "react";
import {
Download,
FileText,
Code,
ChevronDown,
ChevronRight,
Music,
Check,
X,
Clock,
} from "lucide-react";
import type { MessageContent } from "@/types/openai";
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
interface ContentRendererProps {
content: MessageContent;
className?: string;
isStreaming?: boolean;
}
// Text content renderer
function TextContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
if (content.type !== "text" && content.type !== "input_text" && content.type !== "output_text") return null;
const text = content.text;
return (
<div className={`break-words ${className || ""}`}>
<MarkdownRenderer content={text} />
{isStreaming && text.length > 0 && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
);
}
// 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" && content.type !== "output_image") return null;
const imageUrl = content.image_url;
if (imageError) {
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-4 w-4" />
<span>Image could not be loaded</span>
</div>
</div>
);
}
return (
<div className={`my-2 ${className || ""}`}>
<img
src={imageUrl}
alt="Uploaded image"
className={`rounded-lg border max-w-full transition-all cursor-pointer ${
isExpanded ? "max-h-none" : "max-h-64"
}`}
onClick={() => setIsExpanded(!isExpanded)}
onError={() => setImageError(true)}
/>
{isExpanded && (
<div className="text-xs text-muted-foreground mt-1">
Click to collapse
</div>
)}
</div>
);
}
// Helper to convert base64 (or data URI) to blob URL for better browser compatibility
function useBase64ToBlobUrl(data: string | undefined, mimeType: string): string | null {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
useEffect(() => {
if (!data) {
setBlobUrl(null);
return;
}
try {
// Handle both data URI format and raw base64
let base64Data: string;
if (data.startsWith('data:')) {
// Extract base64 from data URI (e.g., "data:application/pdf;base64,...")
const parts = data.split(',');
if (parts.length !== 2) {
setBlobUrl(null);
return;
}
base64Data = parts[1];
} else {
// Raw base64 data
base64Data = data;
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
setBlobUrl(url);
// Cleanup on unmount or when data changes
return () => {
URL.revokeObjectURL(url);
};
} catch (error) {
console.error('Failed to convert base64 to blob URL:', error);
setBlobUrl(null);
}
}, [data, mimeType]);
return blobUrl;
}
// File content renderer (handles both input and output files)
function FileContentRenderer({ content, className }: ContentRendererProps) {
const [isExpanded, setIsExpanded] = useState(true);
// Determine file properties (must be before hooks for conditional logic)
const isFileContent = content.type === "input_file" || content.type === "output_file";
const fileUrl = isFileContent ? (content.file_url || content.file_data) : undefined;
const filename = isFileContent ? (content.filename || "file") : undefined;
// Determine file type from filename or data URI
const isPdf = filename?.toLowerCase().endsWith(".pdf") || fileUrl?.includes("application/pdf");
const isAudio = filename?.toLowerCase().match(/\.(mp3|wav|m4a|ogg|flac|aac)$/);
// Convert base64 to blob URL for PDFs (better browser compatibility)
// Use file_data (raw base64) if available, otherwise try file_url
// Hook must be called unconditionally - pass undefined if not a PDF
const pdfData = (isFileContent && isPdf) ? (content.file_data || content.file_url) : undefined;
const pdfBlobUrl = useBase64ToBlobUrl(pdfData, 'application/pdf');
// Early return after all hooks
if (!isFileContent) return null;
// Use blob URL if available, otherwise fall back to original URL
const effectivePdfUrl = pdfBlobUrl || fileUrl;
// Helper to open PDF in new tab
const openPdfInNewTab = () => {
if (effectivePdfUrl) {
window.open(effectivePdfUrl, '_blank');
}
};
// For PDFs - show a clean card with actions (inline preview is unreliable across browsers)
if (isPdf && fileUrl) {
return (
<div className={`my-2 ${className || ""}`}>
{/* Header with filename and controls */}
<div className="flex items-center gap-2 mb-2 px-1">
<FileText className="h-4 w-4 text-red-500" />
<span className="text-sm font-medium truncate flex-1">{filename}</span>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
{isExpanded ? (
<>
<ChevronDown className="h-3 w-3" />
Collapse
</>
) : (
<>
<ChevronRight className="h-3 w-3" />
Expand
</>
)}
</button>
</div>
{/* PDF Card with actions */}
{isExpanded && (
<div className="border rounded-lg p-6 bg-muted/50 flex flex-col items-center justify-center gap-4">
<FileText className="h-16 w-16 text-red-400" />
<div className="text-center">
<p className="text-sm font-medium mb-1">{filename}</p>
<p className="text-xs text-muted-foreground">PDF Document</p>
</div>
<div className="flex gap-3">
<button
onClick={openPdfInNewTab}
className="text-sm bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2 px-4 py-2 rounded-md transition-colors"
>
Open in new tab
</button>
<a
href={effectivePdfUrl || fileUrl}
download={filename}
className="text-sm text-foreground hover:bg-accent flex items-center gap-2 px-4 py-2 border rounded-md transition-colors"
>
<Download className="h-4 w-4" />
Download
</a>
</div>
</div>
)}
</div>
);
}
// For audio files
if (isAudio && fileUrl) {
return (
<div className={`my-2 p-3 border rounded-lg ${className || ""}`}>
<div className="flex items-center gap-2 mb-2">
<Music className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{filename}</span>
</div>
<audio controls className="w-full">
<source src={fileUrl} />
Your browser does not support audio playback.
</audio>
</div>
);
}
// Generic file display
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{filename}</span>
</div>
{fileUrl && (
<a
href={fileUrl}
download={filename}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<Download className="h-3 w-3" />
Download
</a>
)}
</div>
</div>
);
}
// 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 - compact version
function FunctionApprovalRequestRenderer({ content, className }: ContentRendererProps) {
// Hooks must be called unconditionally
const [isExpanded, setIsExpanded] = useState(false);
// Early return after hooks
if (content.type !== "function_approval_request") return null;
const { status, function_call } = content;
// Status styling - compact
const statusConfig = {
pending: {
icon: Clock,
label: "Awaiting approval",
iconClass: "text-amber-600 dark:text-amber-400",
},
approved: {
icon: Check,
label: "Approved",
iconClass: "text-green-600 dark:text-green-400",
},
rejected: {
icon: X,
label: "Rejected",
iconClass: "text-red-600 dark:text-red-400",
},
};
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={className}>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 px-2 py-1 text-xs rounded hover:bg-muted/50 transition-colors w-fit"
>
<StatusIcon className={`h-3 w-3 ${config.iconClass}`} />
<span className="text-muted-foreground font-mono">{function_call.name}</span>
<span className={`text-xs ${config.iconClass}`}>{config.label}</span>
{isExpanded ? (
<span className="text-xs text-muted-foreground"></span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</button>
{isExpanded && (
<div className="ml-5 mt-1 text-xs font-mono text-muted-foreground border-l-2 border-muted pl-3">
<pre className="whitespace-pre-wrap break-all">{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) {
case "text":
case "input_text":
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;
}
}
// Function call renderer (for displaying function calls in chat)
interface FunctionCallRendererProps {
name: string;
arguments: string;
className?: string;
}
export function FunctionCallRenderer({ name, arguments: args, className }: FunctionCallRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
let parsedArgs;
try {
parsedArgs = typeof args === "string" ? JSON.parse(args) : args;
} catch {
parsedArgs = args;
}
return (
<div className={`my-2 p-3 border rounded bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">
Function Call: {name}
</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
) : (
<ChevronRight className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
)}
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
<div className="text-blue-600 dark:text-blue-400 mb-1">Arguments:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedArgs, null, 2)}
</pre>
</div>
)}
</div>
);
}
// Function result renderer
interface FunctionResultRendererProps {
output: string;
call_id: string;
className?: string;
}
export function FunctionResultRenderer({ output, call_id, className }: FunctionResultRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
let parsedOutput;
try {
parsedOutput = typeof output === "string" ? JSON.parse(output) : output;
} catch {
parsedOutput = output;
}
return (
<div className={`my-2 p-3 border rounded bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-green-800 dark:text-green-300">
Function Result
</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
) : (
<ChevronRight className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
)}
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
<div className="text-green-600 dark:text-green-400 mb-1">Output:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedOutput, null, 2)}
</pre>
<div className="text-gray-500 text-[10px] mt-2">Call ID: {call_id}</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,77 @@
/**
* OpenAI Message Renderer - Renders OpenAI ConversationItem types
* This replaces the legacy AgentFramework-based renderer
*/
import type { ConversationItem } from "@/types/openai";
import {
OpenAIContentRenderer,
FunctionCallRenderer,
FunctionResultRenderer,
} from "./OpenAIContentRenderer";
interface OpenAIMessageRendererProps {
item: ConversationItem;
className?: string;
}
export function OpenAIMessageRenderer({
item,
className,
}: OpenAIMessageRendererProps) {
// Handle message items (user/assistant with content)
if (item.type === "message") {
// Determine if message is actively streaming
const isStreaming = item.status === "in_progress";
const hasContent = item.content.length > 0;
return (
<div className={className}>
{item.content.map((content, index) => (
<OpenAIContentRenderer
key={index}
content={content}
className={index > 0 ? "mt-2" : ""}
isStreaming={isStreaming}
/>
))}
{/* Show typing indicator when streaming with no content yet */}
{isStreaming && !hasContent && (
<div className="flex items-center space-x-1">
<div className="flex space-x-1">
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
</div>
</div>
)}
</div>
);
}
// Handle function call items
if (item.type === "function_call") {
return (
<FunctionCallRenderer
name={item.name}
arguments={item.arguments}
className={className}
/>
);
}
// Handle function result items
if (item.type === "function_call_output") {
return (
<FunctionResultRenderer
output={item.output}
call_id={item.call_id}
className={className}
/>
);
}
// Unknown item type
return null;
}
@@ -0,0 +1,7 @@
/**
* Message Renderer - Exports
* Uses OpenAI Responses API types exclusively
*/
export { OpenAIMessageRenderer } from "./OpenAIMessageRenderer";
export { OpenAIContentRenderer, FunctionCallRenderer, FunctionResultRenderer } from "./OpenAIContentRenderer";
@@ -0,0 +1,312 @@
/**
* GalleryView - Consolidated gallery component with card and grid logic
* Supports inline (empty state) and modal variants
*/
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Bot,
Workflow,
User,
TriangleAlert,
Key,
ChevronDown,
ArrowLeft,
Download,
BookOpen,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
SAMPLE_ENTITIES,
type SampleEntity,
getDifficultyColor,
} from "@/data/gallery";
import { SetupInstructionsModal } from "./setup-instructions-modal";
interface GalleryViewProps {
onClose?: () => void;
variant?: "inline" | "route" | "modal";
hasExistingEntities?: boolean;
}
// Internal: Sample Entity Card Component
function SampleEntityCard({
sample,
}: {
sample: SampleEntity;
}) {
const [showInstructions, setShowInstructions] = useState(false);
const TypeIcon = sample.type === "workflow" ? Workflow : Bot;
return (
<>
<Card className="hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full">
<CardHeader className="pb-3 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<TypeIcon className="h-5 w-5" />
<Badge variant="secondary" className="text-xs">
{sample.type}
</Badge>
</div>
<Badge
variant="outline"
className={cn(
"text-xs border",
getDifficultyColor(sample.difficulty)
)}
>
{sample.difficulty}
</Badge>
</div>
<CardTitle className="text-lg leading-tight">{sample.name}</CardTitle>
<CardDescription className="text-sm line-clamp-3">
{sample.description}
</CardDescription>
</CardHeader>
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
<div className="space-y-3 min-w-0">
{/* Tags */}
<div className="flex flex-wrap gap-1">
{sample.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{sample.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{sample.tags.length - 3}
</Badge>
)}
</div>
{/* Environment Variables Required - Collapsible */}
{sample.requiredEnvVars && sample.requiredEnvVars.length > 0 && (
<details className="group min-w-0 max-w-full overflow-hidden">
<summary className="cursor-pointer list-none p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md hover:bg-amber-100 dark:hover:bg-amber-950/30 transition-colors flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Key className="h-3.5 w-3.5 text-amber-600 dark:text-amber-500 flex-shrink-0" />
<span className="text-xs font-medium text-amber-900 dark:text-amber-100 truncate">
Requires {sample.requiredEnvVars.length} env var
{sample.requiredEnvVars.length > 1 ? "s" : ""}
</span>
</div>
<ChevronDown className="h-3 w-3 text-amber-600 dark:text-amber-500 flex-shrink-0 group-open:rotate-180 transition-transform" />
</summary>
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md space-y-2 min-w-0 max-w-full overflow-hidden">
{sample.requiredEnvVars.map((envVar) => (
<div key={envVar.name} className="text-xs min-w-0 max-w-full overflow-hidden">
<div className="font-mono font-medium text-amber-900 dark:text-amber-100 break-words">
{envVar.name}
</div>
<div className="text-amber-700 dark:text-amber-300 mt-0.5 break-words">
{envVar.description}
</div>
{envVar.example && (
<div className="font-mono text-amber-600 dark:text-amber-400 mt-0.5 break-all">
{envVar.example}
</div>
)}
</div>
))}
</div>
</details>
)}
{/* Features */}
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Key Features:
</div>
<ul className="text-xs space-y-1">
{sample.features.slice(0, 3).map((feature) => (
<li key={feature} className="flex items-center gap-1">
<div className="w-1 h-1 rounded-full bg-current opacity-50" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
</div>
</CardContent>
<CardFooter className="pt-3 flex-col gap-3">
{/* Metadata */}
<div className="w-full flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span>{sample.author}</span>
</div>
</div>
{/* Action Buttons */}
<div className="w-full flex gap-2">
<Button asChild className="flex-1" size="sm">
<a
href={sample.url}
download={`${sample.id}.py`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-4 w-4 mr-2" />
Download
</a>
</Button>
<Button
variant="outline"
className="flex-1"
size="sm"
onClick={() => setShowInstructions(true)}
>
<BookOpen className="h-4 w-4 mr-2" />
Setup Guide
</Button>
</div>
</CardFooter>
</Card>
<SetupInstructionsModal
sample={sample}
open={showInstructions}
onOpenChange={setShowInstructions}
/>
</>
);
}
// Internal: Sample Entity Grid Component
function SampleEntityGrid({ samples }: { samples: SampleEntity[] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{samples.map((sample) => (
<div key={sample.id} className="min-w-0">
<SampleEntityCard sample={sample} />
</div>
))}
</div>
);
}
// Main: Gallery View Component
export function GalleryView({
onClose,
variant = "inline",
hasExistingEntities = false,
}: GalleryViewProps) {
// Inline variant - for empty state in main app
if (variant === "inline") {
return (
<div className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Info Banner */}
<div className="mb-8 p-4 bg-muted/50 border border-border rounded-lg">
<div className="flex items-start gap-3">
<TriangleAlert className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-semibold mb-1">
No agents or workflows configured yet!
</h3>
<p className="text-sm text-muted-foreground mb-2">
You can configure agents or workflows by running{" "}
<code className="px-1.5 py-0.5 bg-background rounded text-xs">
devui
</code>{" "}
in a directory containing them.
</p>
<p className="text-sm text-muted-foreground">
Browse the sample agents and workflows below. Download them,
review the code, and run them locally to get started quickly.
</p>
</div>
</div>
</div>
{/* Sample Gallery */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-4">Sample Gallery</h3>
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
</div>
{/* Footer */}
<div className="text-center mt-12 pt-8 border-t">
<p className="text-sm text-muted-foreground">
Want to create your own agents or workflows? Check out the{" "}
<a
href="https://github.com/microsoft/agent-framework"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
documentation
</a>
</p>
</div>
</div>
</div>
);
}
// Route variant - for /gallery page
if (variant === "route") {
return (
<div className="h-full overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Header */}
<div className="mb-8">
{hasExistingEntities && (
<div className="mb-4">
<Button variant="ghost" onClick={onClose} className="gap-2">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</div>
)}
<div className="text-center">
<h2 className="text-2xl font-semibold mb-2">Sample Gallery</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Browse sample agents and workflows to learn the Agent
Framework. Download these curated examples and run them locally.
Examples range from beginner to advanced.
</p>
</div>
</div>
{/* Sample Gallery */}
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
{/* Footer */}
<div className="text-center mt-12 pt-8 border-t">
<p className="text-sm text-muted-foreground">
Want to create your own agents or workflows? Check out the{" "}
<a
href="https://github.com/microsoft/agent-framework"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
documentation
</a>
</p>
</div>
</div>
</div>
);
}
// Modal variant - for dropdown trigger (simplified, just the grid)
return <SampleEntityGrid samples={SAMPLE_ENTITIES} />;
}
@@ -0,0 +1,5 @@
/**
* Gallery component exports
*/
export { GalleryView } from './gallery-view';
@@ -0,0 +1,207 @@
/**
* SetupInstructionsModal - Shows step-by-step instructions for running a sample entity
*/
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Download,
ExternalLink,
Copy,
Check,
Lightbulb,
BookOpen,
} from "lucide-react";
import type { SampleEntity } from "@/data/gallery";
interface SetupInstructionsModalProps {
sample: SampleEntity;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function CodeBlock({ children, copyable = false }: { children: string; copyable?: boolean }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative">
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono">
<code>{children}</code>
</pre>
{copyable && (
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-6 w-6 p-0"
onClick={handleCopy}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
)}
</div>
);
}
function SetupStep({
number,
title,
description,
code,
action,
copyable = false,
}: {
number: number;
title: string;
description?: string;
code?: string;
action?: React.ReactNode;
copyable?: boolean;
}) {
return (
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold">
{number}
</div>
</div>
<div className="flex-1 space-y-2">
<h4 className="font-semibold">{title}</h4>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
{code && <CodeBlock copyable={copyable}>{code}</CodeBlock>}
{action && <div>{action}</div>}
</div>
</div>
);
}
export function SetupInstructionsModal({
sample,
open,
onOpenChange,
}: SetupInstructionsModalProps) {
const hasEnvVars = sample.requiredEnvVars && sample.requiredEnvVars.length > 0;
const stepOffset = hasEnvVars ? 0 : -1;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle>Setup: {sample.name}</DialogTitle>
<DialogDescription>
Follow these steps to run this sample {sample.type} locally
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-6">
<ScrollArea className="h-[500px]">
<div className="space-y-6 pr-4">
{/* Step 1: Download */}
<SetupStep
number={1}
title="Download the sample file"
action={
<Button asChild size="sm">
<a
href={sample.url}
download={`${sample.id}.py`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-4 w-4 mr-2" />
Download {sample.id}.py
</a>
</Button>
}
/>
{/* Step 2: Create folder */}
<SetupStep
number={2}
title="Create a project folder"
description="Create a dedicated folder for this sample and move the downloaded file there:"
code={`mkdir -p ~/my-agents/${sample.id}\nmv ~/Downloads/${sample.id}.py ~/my-agents/${sample.id}/`}
copyable
/>
{/* Step 3: Environment variables (conditional) */}
{hasEnvVars && (
<SetupStep
number={3}
title="Set up environment variables"
description="Create a .env file in the project folder with these required variables:"
code={sample.requiredEnvVars!
.map((v) => `${v.name}=${v.example || "your-value-here"}\n# ${v.description}`)
.join("\n\n")}
copyable
/>
)}
{/* Step 4: Run DevUI */}
<SetupStep
number={4 + stepOffset}
title="Run with DevUI"
description="Navigate to the folder and start DevUI:"
code={`cd ~/my-agents/${sample.id}\ndevui .`}
copyable
/>
{/* Alternative: Direct run */}
<Alert>
<Lightbulb className="h-4 w-4" />
<AlertTitle>Alternative: Run Programmatically</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-2">You can also run the {sample.type} directly in Python:</p>
<CodeBlock copyable>
{`from ${sample.id} import ${sample.type}
import asyncio
async def main():
response = await ${sample.type}.run("Hello!")
print(response)
asyncio.run(main())`}
</CodeBlock>
</AlertDescription>
</Alert>
{/* Help links */}
<div className="flex gap-2 pt-4 border-t">
<Button variant="outline" size="sm" asChild>
<a href={sample.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
View Source
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<a
href="https://github.com/microsoft/agent-framework#readme"
target="_blank"
rel="noopener noreferrer"
>
<BookOpen className="h-4 w-4 mr-2" />
Documentation
</a>
</Button>
</div>
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,395 @@
/**
* CheckpointInfoModal - Timeline view of workflow checkpoints
*/
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Clock,
MessageSquare,
AlertCircle,
Loader2,
Package,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/services/api";
import type { CheckpointItem, WorkflowSession, FullCheckpoint, PendingRequestInfoEvent } from "@/types";
interface CheckpointInfoModalProps {
session: WorkflowSession | null;
checkpoints: CheckpointItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CheckpointInfoModal({
session,
checkpoints,
open,
onOpenChange,
}: CheckpointInfoModalProps) {
const [selectedCheckpointId, setSelectedCheckpointId] = useState<string | null>(null);
const [fullCheckpoint, setFullCheckpoint] = useState<FullCheckpoint | null>(null);
const [loading, setLoading] = useState(false);
const [jsonExpanded, setJsonExpanded] = useState(true);
// Select first checkpoint when modal opens or checkpoints change
useEffect(() => {
if (open && checkpoints.length > 0) {
// Only reset selection if current selection is invalid
const currentSelectionValid = checkpoints.some(
cp => cp.checkpoint_id === selectedCheckpointId
);
if (!currentSelectionValid) {
setSelectedCheckpointId(checkpoints[0].checkpoint_id);
}
}
}, [open, checkpoints]);
// Load full checkpoint details
useEffect(() => {
if (!selectedCheckpointId || !session) return;
const loadDetails = async () => {
// Don't clear the previous checkpoint to avoid UI flash
setLoading(true);
try {
const item = await apiClient.getConversationItem(
session.conversation_id,
`checkpoint_${selectedCheckpointId}`
);
setFullCheckpoint((item as CheckpointItem).metadata?.full_checkpoint ?? null);
} catch (error) {
console.error("Failed to load checkpoint:", error);
setFullCheckpoint(null);
} finally {
setLoading(false);
}
};
loadDetails();
}, [selectedCheckpointId, session]);
if (!session) return null;
const selectedCheckpoint = checkpoints.find(
(cp) => cp.checkpoint_id === selectedCheckpointId
);
const executorIds = fullCheckpoint?.state?._executor_state
? Object.keys(fullCheckpoint.state._executor_state)
: [];
const messageExecutors = fullCheckpoint?.messages
? Object.keys(fullCheckpoint.messages)
: [];
// Format checkpoint size for display
const formatSize = (bytes?: number): string => {
if (!bytes) return "";
const kb = bytes / 1024;
if (kb < 1) {
return `${bytes} B`;
} else if (kb < 1024) {
return `${kb.toFixed(1)} KB`;
} else {
return `${(kb / 1024).toFixed(1)} MB`;
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[90vw] max-w-6xl min-w-[800px] h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex-1">
<DialogTitle>{session.metadata.name}</DialogTitle>
<div className="text-sm text-muted-foreground mt-1">
{checkpoints.length} checkpoint{checkpoints.length !== 1 ? "s" : ""}
</div>
<div className="text-xs text-muted-foreground mt-2 max-w-2xl">
This is a read only view of the current checkpoint ids in the checkpoint storage for this workflow run.
</div>
</div>
<DialogClose onClose={() => onOpenChange(false)} />
</div>
</DialogHeader>
{/* Main Content - Timeline + Details */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Timeline Sidebar */}
<div className="w-80 border-r flex flex-col">
<ScrollArea className="flex-1">
<div className="p-4 space-y-2">
{checkpoints.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-8">
No checkpoints yet
</div>
) : (
checkpoints.map((checkpoint, index) => {
const isSelected = checkpoint.checkpoint_id === selectedCheckpointId;
const hasHil = checkpoint.metadata.has_pending_hil;
return (
<div key={checkpoint.checkpoint_id} className="relative">
<button
onClick={() => setSelectedCheckpointId(checkpoint.checkpoint_id)}
className={cn(
"relative w-full text-left p-3 rounded-lg border transition-colors",
isSelected
? "bg-primary/10 border-primary"
: "hover:bg-muted/50 border-transparent"
)}
>
<div className="flex items-start gap-3">
{/* Timeline Dot */}
<div className="flex flex-col items-center pt-1">
<div
className={cn(
"w-2 h-2 rounded-full z-10",
hasHil
? "bg-blue-500 ring-2 ring-blue-500/20"
: "bg-muted-foreground/30"
)}
/>
</div>
{/* Checkpoint Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{checkpoint.metadata.iteration_count === 0 ? "Initial State" : `Step ${checkpoint.metadata.iteration_count}`}
</span>
<span className="text-[10px] font-mono text-muted-foreground/70" title={checkpoint.checkpoint_id}>
{checkpoint.checkpoint_id.slice(0, 8)}
</span>
{index === 0 && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">
Latest
</Badge>
)}
{hasHil && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">
{checkpoint.metadata.pending_hil_count} HIL
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span>{new Date(checkpoint.timestamp).toLocaleTimeString()}</span>
{checkpoint.metadata.size_bytes && (
<>
<span></span>
<span>{formatSize(checkpoint.metadata.size_bytes)}</span>
</>
)}
</div>
</div>
</div>
</button>
{/* Connecting Line - positioned absolutely */}
{index < checkpoints.length - 1 && (
<div className="absolute left-[18px] top-[30px] w-px h-[calc(100%+8px)] bg-border" />
)}
</div>
);
})
)}
</div>
</ScrollArea>
</div>
{/* Details Panel */}
<div className="flex-1 flex flex-col overflow-hidden">
{!fullCheckpoint && !loading ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Select a checkpoint to view details
</div>
) : (
<ScrollArea className="flex-1">
<div className="p-6 space-y-6 relative">
{/* Loading overlay */}
{loading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center z-10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Header */}
<div className="flex items-start justify-between pb-4 border-b">
<div>
<div className="flex items-center gap-2 mb-1">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{selectedCheckpoint?.metadata.iteration_count === 0
? "Initial State"
: `Step ${selectedCheckpoint?.metadata.iteration_count}`}
</span>
{selectedCheckpoint?.metadata.size_bytes && (
<span className="text-xs text-muted-foreground">
{formatSize(selectedCheckpoint.metadata.size_bytes)}
</span>
)}
</div>
<div className="text-sm text-muted-foreground">
{selectedCheckpoint &&
new Date(selectedCheckpoint.timestamp).toLocaleString()}
</div>
{selectedCheckpoint && (
<div className="text-xs font-mono text-muted-foreground/70 mt-1">
ID: {selectedCheckpoint.checkpoint_id}
</div>
)}
</div>
{selectedCheckpoint?.metadata.has_pending_hil && (
<Badge variant="secondary">
{selectedCheckpoint.metadata.pending_hil_count} HIL Pending
</Badge>
)}
</div>
{/* Executors */}
{executorIds.length > 0 && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<Package className="h-4 w-4" />
Active Executors ({executorIds.length})
</div>
<div className="flex flex-wrap gap-2">
{executorIds.map((execId) => (
<Badge key={execId} variant="outline" className="font-mono text-xs">
{execId}
</Badge>
))}
</div>
</div>
)}
{/* Messages */}
{messageExecutors.length > 0 && fullCheckpoint && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Messages
</div>
<div className="grid grid-cols-2 gap-3">
{messageExecutors.map((execId) => {
const count = (fullCheckpoint.messages[execId] as unknown[])?.length;
return (
<div key={execId} className="bg-muted/50 p-3 rounded-lg">
<div className="text-xs font-mono text-muted-foreground mb-1">
{execId}
</div>
<div className="font-medium">
{count} message{count !== 1 ? "s" : ""}
</div>
</div>
);
})}
</div>
</div>
)}
{/* HIL Requests */}
{fullCheckpoint?.pending_request_info_events &&
Object.keys(fullCheckpoint.pending_request_info_events).length > 0 && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
Pending HIL Requests (
{Object.keys(fullCheckpoint.pending_request_info_events).length})
</div>
<div className="space-y-2">
{Object.entries(fullCheckpoint.pending_request_info_events).map(
([reqId, reqData]: [string, PendingRequestInfoEvent]) => (
<div
key={reqId}
className="bg-muted/50 border border-border p-3 rounded-lg"
>
<div className="flex items-center justify-between mb-2">
<code className="text-xs bg-background px-2 py-1 rounded">
{reqId.slice(0, 24)}...
</code>
<Badge variant="outline" className="text-xs">
{reqData.source_executor_id}
</Badge>
</div>
<div className="text-xs space-y-1">
<div>
<span className="text-muted-foreground">Request:</span>{" "}
<code className="bg-background px-1 py-0.5 rounded">
{reqData.request_type?.split(".").pop() || reqData.request_type}
</code>
</div>
<div>
<span className="text-muted-foreground">Response:</span>{" "}
<code className="bg-background px-1 py-0.5 rounded">
{reqData.response_type?.split(".").pop() || reqData.response_type}
</code>
</div>
</div>
</div>
)
)}
</div>
</div>
)}
{/* Workflow State */}
<div>
<div className="text-sm font-medium mb-3">Workflow State</div>
{fullCheckpoint?.state && Object.keys(fullCheckpoint.state).filter(
(k) => k !== "_executor_state"
).length > 0 ? (
<div className="flex flex-wrap gap-2">
{Object.keys(fullCheckpoint.state)
.filter((k) => k !== "_executor_state")
.map((key) => (
<Badge key={key} variant="secondary" className="font-mono text-xs">
{key}
</Badge>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">No custom state</div>
)}
</div>
{/* Raw JSON (Collapsible) */}
<div className="border-t pt-6">
<button
onClick={() => setJsonExpanded(!jsonExpanded)}
className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full"
>
{jsonExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
Raw JSON
</button>
{jsonExpanded && (
<pre className="mt-3 text-[10px] font-mono bg-muted p-4 rounded overflow-x-auto">
{JSON.stringify(fullCheckpoint, null, 2)}
</pre>
)}
</div>
</div>
</ScrollArea>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,714 @@
/**
* 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 { HilTimelineItem } from "./hil-timeline-item";
import { RunWorkflowButton } from "./run-workflow-button";
import { ChatMessageInput } from "@/components/ui/chat-message-input";
import { isChatMessageSchema } from "@/utils/workflow-utils";
import {
Loader2,
CheckCircle,
XCircle,
AlertCircle,
ChevronDown,
ChevronRight,
Copy,
Check,
Square,
} from "lucide-react";
import type { ExtendedResponseStreamEvent, JSONSchemaProperty } from "@/types";
import type { ResponseInputContent } from "@/types/agent-framework";
import type { ExecutorState } from "./executor-node";
import { truncateText } from "@/utils/workflow-utils";
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;
// HIL support
pendingHilRequests?: Array<{
request_id: string;
request_data: Record<string, unknown>;
request_schema: import("@/types").JSONSchemaProperty;
}>;
hilResponses?: Record<string, Record<string, unknown>>;
onHilResponseChange?: (requestId: string, values: Record<string, unknown>) => void;
onHilSubmit?: () => void;
isSubmittingHil?: boolean;
// Workflow control props for bottom bar
inputSchema?: JSONSchemaProperty;
onRun?: (data: Record<string, unknown>, checkpointId?: string) => void;
onCancel?: () => void;
isCancelling?: boolean;
workflowState?: "ready" | "running" | "completed" | "error" | "cancelled";
wasCancelled?: boolean;
checkpoints?: import("@/types").CheckpointItem[];
}
function getStateIcon(state: ExecutorState, isStreaming: boolean = true) {
switch (state) {
case "running":
return <Loader2 className={`w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] ${isStreaming ? '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 getMessageText(item: unknown): string {
const content = (item as { content?: Array<{ type: string; text?: string }> }).content;
return content
?.filter((content) => content.type === "output_text" && content.text)
.map((content) => content.text)
.join("\n") ?? "";
}
function ExecutorRunItem({
run,
isExpanded,
onToggle,
onClick,
isSelected,
isStreaming,
}: {
run: ExecutorRun;
isExpanded: boolean;
onToggle: () => void;
onClick: () => void;
isSelected: boolean;
isStreaming: 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="grid grid-cols-[auto_auto_1fr_auto] items-center gap-2 mb-1">
<div className="w-3 text-muted-foreground">
{canExpand && (
<>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</>
)}
</div>
<div>{getStateIcon(run.state, isStreaming)}</div>
<span className="font-medium text-sm truncate overflow-hidden">
{run.executorName}
</span>
{run.runNumber > 1 ? (
<Badge variant="outline" className="text-xs whitespace-nowrap">
Run #{run.runNumber}
</Badge>
) : (
<div></div>
)}
</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,
pendingHilRequests = [],
hilResponses = {},
onHilResponseChange,
onHilSubmit,
isSubmittingHil = false,
// New props
inputSchema,
onRun,
onCancel,
isCancelling = false,
workflowState = "ready",
wasCancelled = false,
checkpoints = [],
}: 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);
const hilFormRef = 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 import("@/types/openai").ResponseOutputItemAddedEvent).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && "executor_id" in item && item.id) {
const executorId = String(item.executor_id);
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: truncateText(executorId, 35),
itemId,
state: "running",
output: itemOutputs[itemId] || "",
timestamp: uiTimestamp,
runNumber,
});
} else if (item && item.type === "message" && "metadata" in item && item.id) {
// Handle message items from Magentic agents
const metadata = item.metadata as {
agent_id?: string;
executor_id?: string;
source?: string;
workflow_output_kind?: string;
} | undefined;
if (metadata?.agent_id && metadata?.source === "magentic") {
const executorId = metadata.agent_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: truncateText(executorId, 35),
itemId,
state: "running",
output: itemOutputs[itemId] || "",
timestamp: uiTimestamp,
runNumber,
});
} else if (metadata?.executor_id && metadata.workflow_output_kind === "intermediate") {
const executorId = metadata.executor_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: truncateText(executorId, 35),
itemId,
state: item.status === "completed" ? "completed" : "running",
output: itemOutputs[itemId] || getMessageText(item),
timestamp: uiTimestamp,
runNumber,
});
}
}
}
// Handle completion events
if (event.type === "response.output_item.done") {
const item = (event as import("@/types/openai").ResponseOutputItemDoneEvent).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && "executor_id" in item && 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" && "error" in item && item.error) {
existingRun.error = String(item.error);
}
}
} else if (item && item.type === "message" && "metadata" in item && item.id) {
// Handle message completion from Magentic agents
const metadata = item.metadata as {
agent_id?: string;
executor_id?: string;
source?: string;
workflow_output_kind?: string;
} | undefined;
if (metadata?.agent_id && metadata?.source === "magentic") {
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] || "";
}
} else if (metadata?.executor_id && metadata.workflow_output_kind === "intermediate") {
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] || getMessageText(item);
}
}
}
}
// 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 using the run counter for guaranteed uniqueness.
// Using uiTimestamp here caused collisions when the same executor ran
// twice within the same second (both fallback entries would share an ID).
const syntheticItemId = `fallback_${executorId}_run${runNumber}`;
runs.push({
executorId,
executorName: truncateText(executorId, 35),
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]);
// Auto-scroll when streaming ends to show final executor state
// (Ensures last executor's completion is visible, even if no workflow result)
useEffect(() => {
// Only scroll when streaming ends AND no HIL requests are pending
// (HIL has its own scroll handler below)
if (!isStreaming && executorRuns.length > 0 && pendingHilRequests.length === 0) {
setTimeout(() => {
timelineEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}, 100);
}
}, [isStreaming, pendingHilRequests.length, executorRuns.length]);
// Auto-scroll to HIL form when it appears
useEffect(() => {
if (pendingHilRequests.length > 0 && hilFormRef.current) {
// Small delay to ensure the form is rendered
setTimeout(() => {
hilFormRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
// Add highlight animation
hilFormRef.current?.classList.add('highlight-attention');
setTimeout(() => {
hilFormRef.current?.classList.remove('highlight-attention');
}, 1000);
}, 100);
}
}, [pendingHilRequests.length]);
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">
{isStreaming ? "Workflow is running..." : "Ready to run workflow"}
</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}
isStreaming={isStreaming}
/>
);
})}
{/* HIL Request Items */}
{pendingHilRequests.length > 0 && (
<div ref={hilFormRef} data-hil-form className="transition-all duration-300">
{pendingHilRequests.map((request) => (
<HilTimelineItem
key={request.request_id}
request={request}
response={hilResponses[request.request_id] || {}}
onResponseChange={(values) => onHilResponseChange?.(request.request_id, values)}
onSubmit={() => onHilSubmit?.()}
isSubmitting={isSubmittingHil}
/>
))}
</div>
)}
</>
)}
{/* Workflow final output card */}
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && !wasCancelled && (
<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>
)}
{/* Workflow cancelled card */}
{wasCancelled && !isStreaming && (
<div className="border rounded-lg border-orange-500/40 bg-orange-500/5 dark:bg-orange-500/10">
<div className="px-4 py-3 flex items-center gap-2">
<Square className="w-4 h-4 text-orange-500 dark:text-orange-400 fill-current" />
<span className="font-medium text-sm text-orange-700 dark:text-orange-300">Execution stopped by user</span>
</div>
</div>
)}
{/* Invisible element at the end for scroll target */}
<div ref={timelineEndRef} />
</div>
</ScrollArea>
{/* Bottom Control Bar - Sticky (hidden when HIL is active) */}
{(onRun || onCancel) && pendingHilRequests.length === 0 && (
<div className="border-t p-3 bg-background flex-shrink-0">
{inputSchema && isChatMessageSchema(inputSchema) ? (
<ChatMessageInput
onSubmit={async (content: ResponseInputContent[]) => {
// Wrap in OpenAI message format (same as run-workflow-button modal)
const openaiInput = [
{ type: "message", role: "user", content },
];
onRun?.(openaiInput as unknown as Record<string, unknown>);
}}
isSubmitting={workflowState === "running"}
isStreaming={workflowState === "running"}
onCancel={onCancel}
isCancelling={isCancelling}
placeholder="Message workflow..."
showFileUpload={true}
entityName="workflow"
/>
) : (
<RunWorkflowButton
inputSchema={inputSchema}
onRun={onRun || (() => {})}
onCancel={onCancel}
isSubmitting={workflowState === "running"}
isCancelling={isCancelling}
workflowState={workflowState}
checkpoints={checkpoints}
showCheckpoints={false}
/>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,220 @@
import { memo, useState } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
Workflow,
Home,
Loader2,
ChevronRight,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { truncateText } from "@/utils/workflow-utils";
export type ExecutorState =
| "pending"
| "running"
| "completed"
| "failed"
| "cancelled";
export interface ExecutorNodeData extends Record<string, unknown> {
executorId: string;
executorType?: string;
name?: string;
state: ExecutorState;
inputData?: unknown;
outputData?: unknown;
error?: string;
isSelected?: boolean;
isStartNode?: boolean;
isEndNode?: boolean;
layoutDirection?: "LR" | "TB";
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
isStreaming?: boolean;
}
const getExecutorStateConfig = (state: ExecutorState) => {
switch (state) {
case "running":
return {
borderColor: "border-[#643FB2] dark:border-[#8B5CF6]",
glow: "shadow-lg shadow-[#643FB2]/20",
badgeColor: "bg-[#643FB2] dark:bg-[#8B5CF6]",
};
case "completed":
return {
borderColor: "border-green-500 dark:border-green-400",
glow: "shadow-lg shadow-green-500/20",
badgeColor: "bg-green-500 dark:bg-green-400",
};
case "failed":
return {
borderColor: "border-red-500 dark:border-red-400",
glow: "shadow-lg shadow-red-500/20",
badgeColor: "bg-red-500 dark:bg-red-400",
};
case "cancelled":
return {
borderColor: "border-orange-500 dark:border-orange-400",
glow: "shadow-lg shadow-orange-500/20",
badgeColor: "bg-orange-500 dark:bg-orange-400",
};
case "pending":
default:
return {
borderColor: "border-gray-300 dark:border-gray-600",
glow: "",
badgeColor: "bg-gray-400 dark:bg-gray-500",
};
}
};
export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const nodeData = data as ExecutorNodeData;
const config = getExecutorStateConfig(nodeData.state);
const [isOutputExpanded, setIsOutputExpanded] = useState(false);
const hasOutput = nodeData.outputData || nodeData.error;
const isRunning = nodeData.state === "running";
const shouldAnimate = isRunning && (nodeData.isStreaming ?? true); // Default to true for backwards compatibility
// Determine handle positions based on layout direction
const isVertical = nodeData.layoutDirection === "TB";
const targetPosition = isVertical ? Position.Top : Position.Left;
const sourcePosition = isVertical ? Position.Bottom : Position.Right;
// Helper to render output/error details when expanded
const renderDataDetails = () => {
if (nodeData.error && typeof nodeData.error === "string") {
const truncatedError = truncateText(nodeData.error, 200);
return (
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800 break-words max-h-32 overflow-auto">
{truncatedError}
</div>
);
}
if (nodeData.outputData) {
try {
const outputStr =
typeof nodeData.outputData === "string"
? nodeData.outputData
: JSON.stringify(nodeData.outputData, null, 2);
return (
<div className="text-xs text-gray-700 dark:text-gray-300 bg-muted/50 p-2 rounded border max-h-32 overflow-auto">
<pre className="whitespace-pre-wrap font-mono">{outputStr}</pre>
</div>
);
} catch {
return (
<div className="text-xs text-gray-600 dark:text-gray-400 bg-muted/50 p-2 rounded border">
[Unable to display output]
</div>
);
}
}
return null;
};
return (
<div
className={cn(
"group relative w-64 bg-card dark:bg-card rounded border-2 transition-all duration-200",
config.borderColor,
selected ? "ring-2 ring-blue-500 ring-offset-2" : "",
isRunning ? config.glow : "shadow-sm",
)}
>
{/* 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"
}}
/>
<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 */}
<div className="flex items-start gap-3">
<div className="flex-shrink-0 relative">
{/* Icon container with dark background */}
<div className="w-10 h-10 rounded-lg bg-gray-900/90 dark:bg-gray-800/90 flex items-center justify-center">
{nodeData.isStartNode ? (
<Home className="w-5 h-5 text-[#643FB2] dark:text-[#8B5CF6]" />
) : (
<Workflow className="w-5 h-5 text-gray-300 dark:text-gray-400" />
)}
</div>
</div>
<div className="flex-1 min-w-0">
<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] ${shouldAnimate ? '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}
</p>
)}
</div>
</div>
{/* Collapsible output section */}
{hasOutput && (
<div className="mt-2 border-t border-border/50 pt-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsOutputExpanded(!isOutputExpanded);
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
>
{isOutputExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<span>{nodeData.error ? "Show error" : "Show output"}</span>
</button>
{isOutputExpanded && (
<div className="mt-2">
{renderDataDetails()}
</div>
)}
</div>
)}
{/* Running animation overlay */}
{isRunning && (
<div className="absolute inset-0 rounded border-2 border-[#643FB2]/30 dark:border-[#8B5CF6]/30 animate-pulse pointer-events-none" />
)}
</div>
</div>
);
});
ExecutorNode.displayName = "ExecutorNode";
@@ -0,0 +1,153 @@
/**
* HilTimelineItem - Inline HIL request form for the ExecutionTimeline
* Shows HIL requests as part of the workflow execution flow
*/
import { useState } from "react";
import { Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
import type { JSONSchemaProperty } from "@/types";
export interface HilRequest {
request_id: string;
request_data: Record<string, unknown>;
request_schema: JSONSchemaProperty;
}
interface HilTimelineItemProps {
request: HilRequest;
response: Record<string, unknown>;
onResponseChange: (values: Record<string, unknown>) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export function HilTimelineItem({
request,
response,
onResponseChange,
onSubmit,
isSubmitting,
}: HilTimelineItemProps) {
const [isExpanded, setIsExpanded] = useState(true);
const handleResponseChange = (values: Record<string, unknown>) => {
onResponseChange(values);
};
const isValid = validateSchemaForm(request.request_schema, response);
return (
<div className="relative group">
{/* Main content - removed icon and adjusted layout */}
<div>
{/* Content area - removed pb-4 padding */}
<div className="flex-1">
<div className="border border-orange-200 dark:border-orange-800 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden rounded-lg">
{/* Header */}
<div
className="px-4 py-3 bg-orange-100/50 dark:bg-orange-950/30 border-b border-orange-200 dark:border-orange-800 flex items-center justify-between cursor-pointer hover:bg-orange-100 dark:hover:bg-orange-950/40 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-orange-900 dark:text-orange-100">
Workflow needs your input
</span>
<Badge
variant="outline"
className="text-xs font-mono border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300"
>
{request.request_id.slice(0, 8)}
</Badge>
{!isExpanded && (
<span className="text-xs text-orange-600 dark:text-orange-400 animate-pulse">
Click to respond
</span>
)}
</div>
{isSubmitting && (
<Badge variant="secondary" className="animate-pulse">
Submitting...
</Badge>
)}
</div>
{/* Expanded content */}
{isExpanded && (
<div className="p-4 space-y-4">
{/* Request context - scrollable */}
{Object.keys(request.request_data).length > 0 && (
<div className="bg-white/60 dark:bg-gray-900/30 rounded-md p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Context
</p>
<div className="max-h-48 overflow-y-auto space-y-1 pr-2">
{Object.entries(request.request_data)
.filter(
([key]) =>
!["request_id", "source_executor_id"].includes(key)
)
.map(([key, value]) => (
<div key={key} className="text-sm">
<span className="font-medium text-muted-foreground">
{key}:
</span>{" "}
<span className="text-foreground break-all">
{typeof value === "object"
? JSON.stringify(value, null, 2)
: String(value)}
</span>
</div>
))}
</div>
</div>
)}
{/* Description hint */}
{request.request_schema?.description && (
<div className="text-sm text-muted-foreground bg-blue-50 dark:bg-blue-950/20 p-3 rounded-md border border-blue-200 dark:border-blue-800">
<p className="font-medium text-blue-900 dark:text-blue-100 mb-1">
What's needed:
</p>
<p className="text-blue-800 dark:text-blue-200">
{request.request_schema.description}
</p>
</div>
)}
{/* Input form */}
<div className="space-y-3">
<SchemaFormRenderer
schema={request.request_schema}
values={response}
onChange={handleResponseChange}
/>
</div>
{/* Actions */}
<div className="space-y-2 pt-2">
<Button
size="default"
onClick={onSubmit}
disabled={!isValid || isSubmitting}
className="w-full gap-2"
>
<Send className="w-4 h-4" />
Submit Response
</Button>
{!isValid && (
<div className="text-xs text-muted-foreground text-center">
Please fill in all required fields
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,22 @@
/**
* Workflow Feature - Exports
*/
export { WorkflowView } from "./workflow-view";
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,
resolveSchemaType,
isShortField,
shouldFieldBeTextarea,
getFieldColumnSpan,
detectChatMessagePattern,
} from "./schema-form-renderer";
export { CheckpointInfoModal } from "./checkpoint-info-modal";
export { RunWorkflowButton } from "./run-workflow-button";
export type { RunWorkflowButtonProps } from "./run-workflow-button";
@@ -0,0 +1,427 @@
/**
* RunWorkflowButton - Shared component for running workflows with checkpoint support
* Features: Split button with dropdown for checkpoint selection, input validation, modal dialog
*/
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { WorkflowInputForm } from "./workflow-input-form";
import { ChatMessageInput } from "@/components/ui/chat-message-input";
import { isChatMessageSchema } from "@/utils/workflow-utils";
import {
ChevronDown,
Clock,
Loader2,
Play,
RotateCcw,
Settings,
Square,
RefreshCw,
} from "lucide-react";
import type { JSONSchemaProperty, CheckpointItem } from "@/types";
import type { ResponseInputContent } from "@/types/agent-framework";
export interface RunWorkflowButtonProps {
inputSchema?: JSONSchemaProperty;
onRun: (data: Record<string, unknown>, checkpointId?: string) => void;
onCancel?: () => void;
isSubmitting: boolean;
isCancelling?: boolean;
workflowState: "ready" | "running" | "completed" | "error" | "cancelled";
checkpoints?: CheckpointItem[];
// Optional prop to control whether to show checkpoints dropdown
showCheckpoints?: boolean;
}
export function RunWorkflowButton({
inputSchema,
onRun,
onCancel,
isSubmitting,
isCancelling = false,
workflowState,
checkpoints = [],
showCheckpoints = true,
}: RunWorkflowButtonProps) {
const [showModal, setShowModal] = useState(false);
// Handle escape key to close modal
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && showModal) {
setShowModal(false);
}
};
if (showModal) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [showModal]);
// Analyze input requirements
const inputAnalysis = useMemo(() => {
// Check if this is a Message schema (for AgentExecutor workflows)
const isChatMessage = isChatMessageSchema(inputSchema);
if (!inputSchema)
return {
needsInput: false,
hasDefaults: false,
fieldCount: 0,
canRunDirectly: true,
isChatMessage: false,
};
if (inputSchema.type === "string") {
return {
needsInput: !inputSchema.default,
hasDefaults: !!inputSchema.default,
fieldCount: 1,
canRunDirectly: !!inputSchema.default,
isChatMessage: false,
};
}
if (inputSchema.type === "object" && inputSchema.properties) {
const properties = inputSchema.properties;
const fields = Object.entries(properties);
const fieldsWithDefaults = fields.filter(
([, schema]: [string, JSONSchemaProperty]) =>
schema.default !== undefined ||
(schema.enum && schema.enum.length > 0)
);
return {
needsInput: fields.length > 0,
hasDefaults: fieldsWithDefaults.length > 0,
fieldCount: fields.length,
canRunDirectly:
fields.length === 0 || fieldsWithDefaults.length === fields.length,
isChatMessage,
};
}
return {
needsInput: false,
hasDefaults: false,
fieldCount: 0,
canRunDirectly: true,
isChatMessage: false,
};
}, [inputSchema]);
const handleDirectRun = () => {
if (workflowState === "running" && onCancel) {
onCancel();
} else if (inputAnalysis.canRunDirectly) {
// Build default data
const defaultData: Record<string, unknown> = {};
if (inputSchema?.type === "string" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "object" && inputSchema.properties) {
Object.entries(inputSchema.properties).forEach(
([key, schema]: [string, JSONSchemaProperty]) => {
if (schema.default !== undefined) {
defaultData[key] = schema.default;
} else if (schema.enum && schema.enum.length > 0) {
defaultData[key] = schema.enum[0];
}
}
);
}
onRun(defaultData);
} else {
setShowModal(true);
}
};
const handleRunFromCheckpoint = (checkpointId: string) => {
if (inputAnalysis.canRunDirectly) {
// Build default data
const defaultData: Record<string, unknown> = {};
if (inputSchema?.type === "string" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "object" && inputSchema.properties) {
Object.entries(inputSchema.properties).forEach(
([key, schema]: [string, JSONSchemaProperty]) => {
if (schema.default !== undefined) {
defaultData[key] = schema.default;
} else if (schema.enum && schema.enum.length > 0) {
defaultData[key] = schema.enum[0];
}
}
);
}
onRun(defaultData, checkpointId);
} else {
// TODO: Pass checkpoint ID to modal for custom inputs
setShowModal(true);
}
};
const hasCheckpoints = showCheckpoints && checkpoints.length > 0;
// Format checkpoint size for display
const formatSize = (bytes?: number): string => {
if (!bytes) return "";
const kb = bytes / 1024;
if (kb < 1) {
return `${bytes} B`;
} else if (kb < 1024) {
return `${kb.toFixed(1)} KB`;
} else {
return `${(kb / 1024).toFixed(1)} MB`;
}
};
// Build the button content based on state
const getButtonContent = () => {
const icon = isCancelling ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : workflowState === "running" && onCancel ? (
<Square className="w-4 h-4 fill-current" />
) : workflowState === "running" ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : workflowState === "error" ? (
<RotateCcw className="w-4 h-4" />
) : inputAnalysis.needsInput && !inputAnalysis.canRunDirectly ? (
<Settings className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
);
const text = isCancelling
? "Stopping..."
: workflowState === "running" && onCancel
? "Stop"
: workflowState === "running"
? "Running..."
: workflowState === "completed"
? "Run Again"
: workflowState === "error"
? "Retry"
: inputAnalysis.fieldCount === 0
? "Run Workflow"
: inputAnalysis.canRunDirectly
? "Run Workflow"
: "Configure & Run";
return { icon, text };
};
const { icon, text } = getButtonContent();
const isDisabled = (workflowState === "running" && !onCancel) || isCancelling;
const buttonVariant = workflowState === "error" ? "destructive" : "default";
// Unified layout for both variants
const renderButton = () => {
// Always show split button if there are checkpoints OR if inputs need configuration
const showDropdown = hasCheckpoints || inputAnalysis.needsInput;
if (!showDropdown) {
// Simple button - no dropdown needed
return (
<Button
onClick={handleDirectRun}
disabled={isDisabled}
variant={buttonVariant}
className="gap-2 w-full"
title={
workflowState === "running" && onCancel
? "Stop workflow execution"
: undefined
}
>
{icon}
{text}
</Button>
);
}
// Split button with dropdown
return (
<DropdownMenu>
<div className="flex w-full">
<Button
onClick={handleDirectRun}
disabled={isDisabled}
variant={buttonVariant}
className="gap-2 rounded-r-none flex-1"
title={
workflowState === "running" && onCancel
? "Stop workflow execution"
: undefined
}
>
{icon}
{text}
</Button>
<DropdownMenuTrigger asChild>
<Button
disabled={isDisabled}
variant={buttonVariant}
className="rounded-l-none border-l-0 px-2"
title="More options"
>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="end"
className="w-80 max-h-[400px] overflow-y-auto"
>
{/* Run Fresh option - only show when checkpoints are enabled */}
{hasCheckpoints && (
<DropdownMenuItem onClick={handleDirectRun}>
<Play className="w-4 h-4 mr-2" />
Run Fresh
</DropdownMenuItem>
)}
{/* Configure inputs option */}
{inputAnalysis.needsInput && (
<DropdownMenuItem onClick={() => setShowModal(true)}>
<Settings className="w-4 h-4 mr-2" />
Configure Inputs
</DropdownMenuItem>
)}
{/* Checkpoint options */}
{hasCheckpoints && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs text-muted-foreground">
Resume from checkpoint
</div>
{checkpoints.map((checkpoint, index) => (
<DropdownMenuItem
key={checkpoint.checkpoint_id}
onClick={() =>
handleRunFromCheckpoint(checkpoint.checkpoint_id)
}
className="flex flex-col items-start py-2"
>
<div className="flex items-center gap-2 w-full">
<RefreshCw className="w-4 h-4 flex-shrink-0" />
<span className="font-medium">
{checkpoint.metadata.iteration_count === 0
? "Initial State"
: `Step ${checkpoint.metadata.iteration_count}`}
</span>
{index === 0 && (
<Badge
variant="secondary"
className="text-[10px] h-4 px-1 ml-auto"
>
Latest
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-6 mt-0.5">
<Clock className="w-3 h-3" />
<span>
{new Date(checkpoint.timestamp).toLocaleTimeString()}
</span>
{checkpoint.metadata.size_bytes && (
<>
<span></span>
<span>
{formatSize(checkpoint.metadata.size_bytes)}
</span>
</>
)}
</div>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
return (
<>
{renderButton()}
{/* Modal for input configuration */}
{inputSchema && (
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="w-full min-w-[400px] max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-8 pt-6">
<DialogTitle>Configure Workflow Inputs</DialogTitle>
<DialogClose onClose={() => setShowModal(false)} />
</DialogHeader>
<div className="px-8 py-4 border-b flex-shrink-0">
<div className="text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span className="font-medium">Input Type:</span>
<Badge variant="secondary">
{inputAnalysis.isChatMessage
? "Chat Message"
: inputSchema.type === "string"
? "Simple Text"
: "Structured Data"}
</Badge>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto py-4 px-8">
{inputAnalysis.isChatMessage ? (
<ChatMessageInput
onSubmit={async (content: ResponseInputContent[]) => {
// Wrap in OpenAI message format (same structure as agent-view)
// This preserves multimodal content (images, files) for the backend
const openaiInput = [
{ type: "message", role: "user", content },
];
onRun(openaiInput as unknown as Record<string, unknown>);
setShowModal(false);
}}
isSubmitting={isSubmitting}
placeholder="Enter your message..."
entityName="workflow"
showFileUpload={true}
/>
) : (
<WorkflowInputForm
inputSchema={inputSchema}
inputTypeName="Input"
onSubmit={(values) => {
onRun(values as Record<string, unknown>);
setShowModal(false);
}}
isSubmitting={isSubmitting}
className="embedded"
/>
)}
</div>
</DialogContent>
</Dialog>
)}
</>
);
}
@@ -0,0 +1,604 @@
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 Helpers (exported for reuse)
// ============================================================================
export 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());
}
// Helper: Resolve anyOf/oneOf union types to get the primary type
// Pydantic generates these for Optional[T] as: anyOf: [{type: T}, {type: "null"}]
export function resolveSchemaType(schema: JSONSchemaProperty): JSONSchemaProperty {
// If schema has a direct type, return as-is
if (schema.type) {
return schema;
}
// Handle anyOf (common for Optional[T])
if (schema.anyOf && schema.anyOf.length > 0) {
// Filter out null type and get the first non-null type
const nonNullTypes = schema.anyOf.filter(
(s) => s.type !== "null" && s.type !== undefined
);
if (nonNullTypes.length > 0) {
// Merge the resolved type with original schema's metadata (default, description, etc.)
return {
...nonNullTypes[0],
default: schema.default ?? nonNullTypes[0].default,
description: schema.description ?? nonNullTypes[0].description,
title: schema.title ?? nonNullTypes[0].title,
};
}
}
// Handle oneOf similarly
if (schema.oneOf && schema.oneOf.length > 0) {
const nonNullTypes = schema.oneOf.filter(
(s) => s.type !== "null" && s.type !== undefined
);
if (nonNullTypes.length > 0) {
return {
...nonNullTypes[0],
default: schema.default ?? nonNullTypes[0].default,
description: schema.description ?? nonNullTypes[0].description,
title: schema.title ?? nonNullTypes[0].title,
};
}
}
// Fallback: return original schema (will render as JSON textarea)
return schema;
}
export function shouldFieldBeTextarea(
fieldName: string,
schema: JSONSchemaProperty
): boolean {
return (
schema.format === "textarea" ||
(!!schema.description && schema.description.length > 100) ||
(schema.type === "string" && !schema.enum && !isShortField(fieldName))
);
}
export 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 "";
}
// ============================================================================
// Message Pattern Detection (exported for reuse)
// ============================================================================
export 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 (internal - used by SchemaFormRenderer)
// ============================================================================
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: rawSchema,
value,
onChange,
isRequired = false,
isReadOnly = false,
}: FormFieldProps) {
// Resolve anyOf/oneOf union types (e.g., Optional[int] → int)
const schema = resolveSchemaType(rawSchema);
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[]; // Fields to display but not edit (for HIL)
hideFields?: string[]; // Fields to completely hide
showCollapsedByDefault?: boolean; // Control initial collapsed state
layout?: "stack" | "grid"; // Layout mode: "stack" (vertical) or "grid" (responsive 4-col)
}
export function SchemaFormRenderer({
schema,
values,
onChange,
disabled = false,
readOnlyFields = [],
hideFields = [],
showCollapsedByDefault = false,
layout = "stack",
}: SchemaFormRendererProps) {
const [showAdvancedFields, setShowAdvancedFields] = useState(
showCollapsedByDefault
);
// Container class based on layout mode
const containerClass =
layout === "grid"
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
: "space-y-3";
const properties = schema.properties || {};
const allFieldNames = Object.keys(properties).filter(
(name) => !hideFields.includes(name)
);
const requiredFields = (schema.required || []).filter(
(name) => !hideFields.includes(name)
);
// Detect Message 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 Message: 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,
});
};
// Full-width class for separator and toggle button in grid mode
const fullWidthClass =
layout === "grid" ? "md:col-span-2 lg:col-span-3 xl:col-span-4" : "";
return (
<div className={containerClass}>
{/* 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={fullWidthClass}>
<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={fullWidthClass}>
<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;
}
@@ -0,0 +1,56 @@
import { memo, type CSSProperties } from "react";
import { BaseEdge, useInternalNode } from "@xyflow/react";
interface SelfLoopEdgeProps {
id: string;
source: string;
markerEnd?: string;
style?: CSSProperties;
}
/**
* Custom edge for self-referencing nodes. Renders a bezier loop from output back to input.
*/
export const SelfLoopEdge = memo(function SelfLoopEdge({
id,
source,
markerEnd,
style,
}: SelfLoopEdgeProps) {
const sourceNode = useInternalNode(source);
if (!sourceNode) return null;
const { width, height } = sourceNode.measured;
const { x, y } = sourceNode.internals.positionAbsolute;
if (!width || !height) return null;
const nodeData = sourceNode.data as Record<string, unknown> | undefined;
const isVertical = nodeData?.layoutDirection === "TB";
const loopOffset = 100;
const riseOffset = 40;
let edgePath: string;
if (isVertical) {
// TB: bottom center → curves right → top center
const startX = x + width / 2;
const startY = y + height;
const endX = x + width / 2;
const endY = y;
const cpX = x + width + loopOffset;
edgePath = `M ${startX} ${startY} C ${startX} ${startY + riseOffset}, ${cpX} ${startY + riseOffset}, ${cpX} ${y + height / 2} C ${cpX} ${endY - riseOffset}, ${endX} ${endY - riseOffset}, ${endX} ${endY}`;
} else {
// LR: right center → curves down → left center
const startX = x + width;
const startY = y + height / 2;
const endX = x;
const endY = y + height / 2;
const cpY = y + height + loopOffset;
edgePath = `M ${startX} ${startY} C ${startX + riseOffset} ${startY}, ${startX + riseOffset} ${cpY}, ${x + width / 2} ${cpY} C ${endX - riseOffset} ${cpY}, ${endX - riseOffset} ${endY}, ${endX} ${endY}`;
}
return <BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />;
});
@@ -0,0 +1,169 @@
/**
* WorkflowDetailsModal - Responsive grid-based modal for displaying workflow metadata
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import {
Workflow as WorkflowIcon,
Package,
FolderOpen,
Database,
Globe,
CheckCircle,
XCircle,
PlayCircle,
} from "lucide-react";
import type { WorkflowInfo } from "@/types";
interface WorkflowDetailsModalProps {
workflow: WorkflowInfo;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface DetailCardProps {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
className?: string;
}
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
return (
<div className={`border rounded-lg p-4 bg-card ${className}`}>
<div className="flex items-center gap-2 mb-3">
{icon}
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
<div className="text-sm text-muted-foreground">{children}</div>
</div>
);
}
export function WorkflowDetailsModal({
workflow,
open,
onOpenChange,
}: WorkflowDetailsModalProps) {
const sourceIcon =
workflow.source === "directory" ? (
<FolderOpen className="h-4 w-4 text-muted-foreground" />
) : workflow.source === "in_memory" ? (
<Database className="h-4 w-4 text-muted-foreground" />
) : (
<Globe className="h-4 w-4 text-muted-foreground" />
);
const sourceLabel =
workflow.source === "directory"
? "Local"
: workflow.source === "in_memory"
? "In-Memory"
: "Gallery";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-6 pt-6 flex-shrink-0">
<DialogTitle>Workflow Details</DialogTitle>
<DialogClose onClose={() => onOpenChange(false)} />
</DialogHeader>
<div className="px-6 pb-6 overflow-y-auto flex-1">
{/* Header Section */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<WorkflowIcon className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">
{workflow.name || workflow.id}
</h2>
</div>
{workflow.description && (
<p className="text-muted-foreground">{workflow.description}</p>
)}
</div>
<div className="h-px bg-border mb-6" />
{/* Grid Layout for Metadata */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* Start Executor */}
<DetailCard
title="Start Executor"
icon={<PlayCircle className="h-4 w-4 text-muted-foreground" />}
>
<div className="font-mono text-foreground">
{workflow.start_executor_id}
</div>
</DetailCard>
{/* Source */}
<DetailCard title="Source" icon={sourceIcon}>
<div className="space-y-1">
<div className="text-foreground">{sourceLabel}</div>
{workflow.module_path && (
<div className="font-mono text-xs break-all">
{workflow.module_path}
</div>
)}
</div>
</DetailCard>
{/* Environment */}
<DetailCard
title="Environment"
icon={
workflow.has_env ? (
<XCircle className="h-4 w-4 text-orange-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)
}
className="md:col-span-2"
>
<div
className={
workflow.has_env
? "text-orange-600 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}
>
{workflow.has_env
? "Requires environment variables"
: "No environment variables required"}
</div>
</DetailCard>
</div>
{/* Executors */}
<DetailCard
title={`Executors (${workflow.executors.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
{workflow.executors.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{workflow.executors.map((executor, index) => (
<div
key={index}
className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded truncate"
title={executor}
>
{executor}
</div>
))}
</div>
) : (
<div className="text-muted-foreground">No executors configured</div>
)}
</DetailCard>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,592 @@
import { useMemo, useCallback, useEffect, memo } from "react";
import {
MoreVertical,
Map,
Grid3X3,
RotateCcw,
Maximize,
Shuffle,
Zap,
ArrowDown,
ArrowLeftRight,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
useReactFlow,
BackgroundVariant,
type NodeTypes,
type Node,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { ExecutorNode, type ExecutorNodeData } from "./executor-node";
import { SelfLoopEdge } from "./self-loop-edge";
import {
convertWorkflowDumpToNodes,
convertWorkflowDumpToEdges,
applyDagreLayout,
processWorkflowEvents,
updateNodesWithEvents,
updateEdgesWithSequenceAnalysis,
consolidateBidirectionalEdges,
type NodeUpdate,
} from "@/utils/workflow-utils";
import type { ExtendedResponseStreamEvent } from "@/types";
import type { Workflow } from "@/types/workflow";
const nodeTypes: NodeTypes = {
executor: ExecutorNode,
};
const edgeTypes = {
selfLoop: SelfLoopEdge,
};
// ViewOptions panel component that renders inside ReactFlow
function ViewOptionsPanel({
workflowDump,
onNodeSelect,
viewOptions,
onToggleViewOption,
layoutDirection,
onLayoutDirectionChange,
}: {
workflowDump?: Workflow;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
viewOptions: {
showMinimap: boolean;
showGrid: boolean;
animateRun: boolean;
consolidateBidirectionalEdges: boolean;
};
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
layoutDirection: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
}) {
const { fitView, setViewport, setNodes } = useReactFlow();
const handleResetZoom = () => {
setViewport({ x: 0, y: 0, zoom: 1 });
};
const handleFitToScreen = () => {
fitView({ padding: 0.2 });
};
const handleAutoArrange = () => {
if (!workflowDump) return;
const currentNodes = convertWorkflowDumpToNodes(
workflowDump,
onNodeSelect,
layoutDirection
);
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
const layoutedNodes = applyDagreLayout(
currentNodes,
currentEdges,
layoutDirection
);
setNodes(layoutedNodes);
};
return (
<div className="absolute top-4 right-4 z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0 bg-white/90 backdrop-blur-sm border-gray-200 shadow-sm hover:bg-white dark:bg-gray-800/90 dark:border-gray-600 dark:hover:bg-gray-800"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">View options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("showMinimap")}
>
<div className="flex items-center">
<Map className="mr-2 h-4 w-4" />
Show Minimap
</div>
<Checkbox checked={viewOptions.showMinimap} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("showGrid")}
>
<div className="flex items-center">
<Grid3X3 className="mr-2 h-4 w-4" />
Show Grid
</div>
<Checkbox checked={viewOptions.showGrid} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("animateRun")}
>
<div className="flex items-center">
<Zap className="mr-2 h-4 w-4" />
Animate Run
</div>
<Checkbox checked={viewOptions.animateRun} onChange={() => {}} />
</DropdownMenuItem>
<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"
onClick={() => {
const newDirection = layoutDirection === "LR" ? "TB" : "LR";
onLayoutDirectionChange?.(newDirection);
// Re-apply layout with new direction
if (workflowDump) {
const currentNodes = convertWorkflowDumpToNodes(
workflowDump,
onNodeSelect,
newDirection
);
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
const layoutedNodes = applyDagreLayout(
currentNodes,
currentEdges,
newDirection
);
setNodes(layoutedNodes);
}
}}
>
<div className="flex items-center">
<ArrowDown className="mr-2 h-4 w-4" />
Vertical Layout
</div>
<Checkbox checked={layoutDirection === "TB"} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleResetZoom}>
<RotateCcw className="mr-2 h-4 w-4" />
Reset Zoom
</DropdownMenuItem>
<DropdownMenuItem onClick={handleFitToScreen}>
<Maximize className="mr-2 h-4 w-4" />
Fit to Screen
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAutoArrange}>
<Shuffle className="mr-2 h-4 w-4" />
Auto-arrange
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
interface WorkflowFlowProps {
workflowDump?: Workflow;
events: ExtendedResponseStreamEvent[];
isStreaming: boolean;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
className?: string;
viewOptions?: {
showMinimap: boolean;
showGrid: boolean;
animateRun: boolean;
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
function WorkflowAnimationHandler({
nodes,
nodeUpdates,
isStreaming,
animateRun,
}: {
nodes: Node<ExecutorNodeData>[];
nodeUpdates: Record<string, NodeUpdate>;
isStreaming: boolean;
animateRun: boolean;
}) {
const { fitView } = useReactFlow();
// Smooth animation to center on running node when workflow starts/progresses
useEffect(() => {
if (!animateRun) return;
if (isStreaming) {
// Zoom in on running nodes during execution
const runningNodes = nodes.filter(
(node) => node.data.state === "running"
);
if (runningNodes.length > 0) {
const targetNode = runningNodes[0];
// Use fitView to smoothly focus on the running node with animation
fitView({
nodes: [targetNode],
duration: 800,
padding: 0.3,
minZoom: 0.8,
maxZoom: 1.5,
});
}
} else if (nodes.length > 0) {
// Zoom back out to show full workflow when execution completes
fitView({
duration: 1000,
padding: 0.2,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeUpdates, isStreaming, animateRun, nodes]);
return null; // This component doesn't render anything
}
// 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,
consolidateBidirectionalEdges: true,
},
onToggleViewOption,
layoutDirection = "TB",
onLayoutDirectionChange,
timelineVisible = false,
}: WorkflowFlowProps) {
// Create initial nodes and edges from workflow dump
const { initialNodes, initialEdges } = useMemo(() => {
if (!workflowDump) {
return { initialNodes: [], initialEdges: [] };
}
const nodes = convertWorkflowDumpToNodes(
workflowDump,
onNodeSelect,
layoutDirection
);
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, finalEdges, layoutDirection)
: nodes;
return {
initialNodes: layoutedNodes,
initialEdges: finalEdges,
};
}, [
workflowDump,
onNodeSelect,
layoutDirection,
viewOptions.consolidateBidirectionalEdges,
]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<ExecutorNodeData>>(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
// Process events and update node/edge states
const nodeUpdates = useMemo(() => {
return processWorkflowEvents(events, workflowDump?.start_executor_id);
}, [events, workflowDump?.start_executor_id]);
// Update nodes and edges with real-time state from events
useMemo(() => {
if (Object.keys(nodeUpdates).length > 0) {
setNodes((currentNodes) =>
updateNodesWithEvents(currentNodes, nodeUpdates, isStreaming)
);
} else if (events.length === 0) {
// Reset all nodes to pending state when events are cleared
setNodes((currentNodes) =>
currentNodes.map((node) => ({
...node,
data: {
...node.data,
state: "pending" as const,
outputData: undefined,
error: undefined,
isStreaming: false,
},
}))
);
}
}, [nodeUpdates, setNodes, events.length, isStreaming]);
// Update edges with sequence-based analysis (separate from nodeUpdates)
useMemo(() => {
if (events.length > 0) {
setEdges((currentEdges) => {
const updatedEdges = updateEdgesWithSequenceAnalysis(
currentEdges,
events
);
// 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) => {
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, viewOptions.consolidateBidirectionalEdges]);
// 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, viewOptions.consolidateBidirectionalEdges]); // Re-initialize when workflow or consolidation toggle changes
const onNodeClick = useCallback(
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
event.stopPropagation();
onNodeSelect?.(node.data.executorId, node.data);
},
[onNodeSelect]
);
if (!workflowDump) {
return (
<div
className={`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${className}`}
>
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="text-lg font-medium mb-2">No Workflow Data</div>
<div className="text-sm">Workflow dump is not available.</div>
</div>
</div>
);
}
if (initialNodes.length === 0) {
return (
<div
className={`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${className}`}
>
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="text-lg font-medium mb-2">No Executors Found</div>
<div className="text-sm">
Could not extract executors from workflow dump.
</div>
<details className="mt-2 text-xs">
<summary className="cursor-pointer">Debug Info</summary>
<pre className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-left overflow-auto">
{JSON.stringify(workflowDump, null, 2)}
</pre>
</details>
</div>
</div>
);
}
return (
<div className={`h-full w-full ${className}`}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={1.5}
defaultEdgeOptions={{
type: "default",
animated: false,
style: { stroke: "#6b7280", strokeWidth: 2 },
}}
nodesDraggable={!isStreaming} // Disable dragging during execution
nodesConnectable={false} // Disable connecting nodes
elementsSelectable={true}
proOptions={{ hideAttribution: true }}
>
{viewOptions.showGrid && (
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#e5e7eb"
className="dark:opacity-30"
/>
)}
<Controls
position="bottom-left"
showInteractive={false}
style={{
backgroundColor: "rgba(255, 255, 255, 0.9)",
border: "1px solid #e5e7eb",
borderRadius: "3px",
}}
className="dark:!bg-gray-800/90 dark:!border-gray-600"
/>
{viewOptions.showMinimap && (
<MiniMap
nodeColor={(node: Node) => {
const data = node.data as ExecutorNodeData;
const state = data?.state;
switch (state) {
case "running":
return "#643FB2";
case "completed":
return "#10b981";
case "failed":
return "#ef4444";
case "cancelled":
return "#f97316";
default:
return "#6b7280";
}
}}
maskColor="rgba(0, 0, 0, 0.1)"
position="bottom-right"
style={{
backgroundColor: "rgba(255, 255, 255, 0.9)",
border: "1px solid #e5e7eb",
borderRadius: "8px",
}}
className="dark:!bg-gray-800/90 dark:!border-gray-600"
/>
)}
<WorkflowAnimationHandler
nodes={nodes}
nodeUpdates={nodeUpdates}
isStreaming={isStreaming}
animateRun={viewOptions.animateRun}
/>
<TimelineResizeHandler timelineVisible={timelineVisible} />
<ViewOptionsPanel
workflowDump={workflowDump}
onNodeSelect={onNodeSelect}
viewOptions={viewOptions}
onToggleViewOption={onToggleViewOption}
layoutDirection={layoutDirection}
onLayoutDirectionChange={onLayoutDirectionChange}
/>
</ReactFlow>
{/* CSS for custom edge animations and dark theme controls */}
<style>{`
.react-flow__edge-path {
transition: stroke 0.3s ease, stroke-width 0.3s ease;
}
.react-flow__edge.animated .react-flow__edge-path {
stroke-dasharray: 5 5;
animation: dash 1s linear infinite;
}
@keyframes dash {
0% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -10; }
}
/* Dark theme styles for React Flow controls */
.dark .react-flow__controls {
background-color: rgba(31, 41, 55, 0.9) !important;
border-color: rgb(75, 85, 99) !important;
}
.dark .react-flow__controls-button {
background-color: rgba(31, 41, 55, 0.9) !important;
border-color: rgb(75, 85, 99) !important;
color: rgb(229, 231, 235) !important;
}
.dark .react-flow__controls-button:hover {
background-color: rgba(55, 65, 81, 0.9) !important;
color: rgb(255, 255, 255) !important;
}
.dark .react-flow__controls-button svg {
fill: rgb(229, 231, 235) !important;
}
.dark .react-flow__controls-button:hover svg {
fill: rgb(255, 255, 255) !important;
}
`}</style>
</div>
);
});
@@ -0,0 +1,289 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
DialogFooter,
} from "@/components/ui/dialog";
import { Send } from "lucide-react";
import { cn } from "@/lib/utils";
import type { JSONSchemaProperty } from "@/types";
import {
SchemaFormRenderer,
filterEmptyOptionalFields,
detectChatMessagePattern,
} from "./schema-form-renderer";
interface WorkflowInputFormProps {
inputSchema: JSONSchemaProperty;
inputTypeName: string;
onSubmit: (formData: unknown) => void;
isSubmitting?: boolean;
className?: string;
}
export function WorkflowInputForm({
inputSchema,
inputTypeName,
onSubmit,
isSubmitting = false,
className,
}: WorkflowInputFormProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
// Check if we're in embedded mode (being used inside another modal)
const isEmbedded = className?.includes("embedded");
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(false);
// Determine field info
const properties = inputSchema.properties || {};
const fieldNames = Object.keys(properties);
const requiredFields = inputSchema.required || [];
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
// Detect Message-like pattern for auto-filling role
const isChatMessageLike = detectChatMessagePattern(inputSchema, requiredFields);
// Validation: check if required fields are filled
const canSubmit = isSimpleInput
? formData.value !== undefined && formData.value !== ""
: requiredFields.length > 0
? requiredFields.every((fieldName) => {
// Auto-filled fields are always valid
if (
isChatMessageLike &&
fieldName === "role" &&
formData["role"] === "user"
) {
return true;
}
return formData[fieldName] !== undefined && formData[fieldName] !== "";
})
: Object.keys(formData).length > 0;
// Initialize form data with defaults
useEffect(() => {
if (inputSchema.type === "string") {
setFormData({ value: inputSchema.default || "" });
} else if (inputSchema.type === "object" && inputSchema.properties) {
const initialData: Record<string, unknown> = {};
Object.entries(inputSchema.properties).forEach(([key, fieldSchema]) => {
if (fieldSchema.default !== undefined) {
initialData[key] = fieldSchema.default;
} else if (fieldSchema.enum && fieldSchema.enum.length > 0) {
initialData[key] = fieldSchema.enum[0];
}
});
// Auto-fill role="user" for Message-like inputs
if (isChatMessageLike && !initialData["role"]) {
initialData["role"] = "user";
}
setFormData(initialData);
}
}, [inputSchema, isChatMessageLike]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Simplified submission logic
if (inputSchema.type === "string") {
onSubmit({ input: formData.value || "" });
} else if (inputSchema.type === "object") {
const properties = inputSchema.properties || {};
const fieldNames = Object.keys(properties);
if (fieldNames.length === 1) {
const fieldName = fieldNames[0];
onSubmit({ [fieldName]: formData[fieldName] || "" });
} else {
// Filter out empty optional fields before submission
const filteredData = filterEmptyOptionalFields(inputSchema, formData);
onSubmit(filteredData);
}
} else {
onSubmit(formData);
}
// Only close modal if not embedded
if (!isEmbedded) {
setIsModalOpen(false);
}
setLoading(false);
};
const handleFormChange = (newValues: Record<string, unknown>) => {
setFormData(newValues);
};
// Simple string input renderer (for non-object schemas)
const renderSimpleInput = () => (
<div className="space-y-2">
<Label htmlFor="simple-input">Input</Label>
<Textarea
id="simple-input"
value={typeof formData.value === "string" ? formData.value : ""}
onChange={(e) => setFormData({ value: e.target.value })}
placeholder={
typeof inputSchema.default === "string"
? inputSchema.default
: "Enter input"
}
rows={4}
className="min-w-[300px] w-full"
/>
{inputSchema.description && (
<p className="text-sm text-muted-foreground">{inputSchema.description}</p>
)}
</div>
);
// If embedded, just show the form directly
if (isEmbedded) {
return (
<form onSubmit={handleSubmit} className={className}>
{/* Simple input */}
{isSimpleInput && renderSimpleInput()}
{/* Complex form fields using SchemaFormRenderer */}
{!isSimpleInput && (
<SchemaFormRenderer
schema={inputSchema}
values={formData}
onChange={handleFormChange}
disabled={loading}
hideFields={isChatMessageLike ? ["role"] : []}
layout="grid"
/>
)}
<div className="flex gap-2 mt-4 justify-end">
<Button type="submit" disabled={loading || !canSubmit} size="default">
<Send className="h-4 w-4" />
{loading ? "Running..." : "Run Workflow"}
</Button>
</div>
</form>
);
}
return (
<>
{/* Sidebar Form Component */}
<div className={cn("flex flex-col", className)}>
{/* Header with Run Button */}
<div className="border-b border-border px-4 py-3 bg-muted">
<CardTitle className="text-sm mb-3">Run Workflow</CardTitle>
{/* Run Button - Opens Modal */}
<Button
onClick={() => setIsModalOpen(true)}
disabled={isSubmitting}
className="w-full"
size="default"
>
<Send className="h-4 w-4 mr-2" />
{isSubmitting ? "Running..." : "Run Workflow"}
</Button>
</div>
{/* Info Section */}
<div className="px-4 py-3">
<div className="text-sm text-muted-foreground">
<strong>Input Type:</strong>{" "}
<code className="bg-muted px-1 py-0.5 rounded">
{inputTypeName}
</code>
{inputSchema.type === "object" && inputSchema.properties && (
<span className="ml-2">
({Object.keys(inputSchema.properties).length} field
{Object.keys(inputSchema.properties).length !== 1 ? "s" : ""})
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-2">
Click "Run Workflow" to configure inputs and execute
</p>
</div>
</div>
{/* Modal with the actual form */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="w-full max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Run Workflow</DialogTitle>
<DialogClose onClose={() => setIsModalOpen(false)} />
</DialogHeader>
{/* Form Info */}
<div className="px-8 py-4 border-b flex-shrink-0">
<div className="text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span className="font-medium">Input Type:</span>
<code className="bg-muted px-3 py-1 text-xs font-mono">
{inputTypeName}
</code>
{inputSchema.type === "object" && (
<span className="text-xs text-muted-foreground">
{fieldNames.length} field
{fieldNames.length !== 1 ? "s" : ""}
</span>
)}
</div>
</div>
</div>
{/* Scrollable Form Content */}
<div className="px-8 py-6 overflow-y-auto flex-1 min-h-0">
<form id="workflow-modal-form" onSubmit={handleSubmit}>
{/* Simple input */}
{isSimpleInput && renderSimpleInput()}
{/* Complex form fields using SchemaFormRenderer */}
{!isSimpleInput && (
<SchemaFormRenderer
schema={inputSchema}
values={formData}
onChange={handleFormChange}
disabled={loading}
hideFields={isChatMessageLike ? ["role"] : []}
layout="grid"
/>
)}
</form>
</div>
{/* Footer */}
<div className="px-8 py-4 border-t flex-shrink-0">
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
form="workflow-modal-form"
disabled={loading || !canSubmit}
>
<Send className="h-4 w-4 mr-2" />
{loading ? "Running..." : "Run Workflow"}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,225 @@
/**
* 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 runtime = useDevUIStore((state) => state.runtime);
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: `Checkpoint Storage ${new Date().toLocaleString()}`,
});
setAvailableSessions([newSession]);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "Default checkpoint storage 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);
// Silently handle for .NET backend (doesn't support conversations yet)
// Only show error for Python backend where this is unexpected
if (runtime !== "dotnet") {
addToast({
message: "Failed to load workflow conversations",
type: "error",
});
}
} finally {
setLoadingSessions(false);
}
}, [workflowId, currentSession, runtime, 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: `Checkpoint Storage ${new Date().toLocaleString()}`,
});
addSession(newSession);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "New checkpoint storage created",
type: "success",
});
} catch (error) {
console.error("Failed to create checkpoint storage:", error);
addToast({
message: "Failed to create checkpoint storage",
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>
);
};
@@ -0,0 +1,111 @@
/**
* AppHeader - Global application header
* Features: Entity selection, global settings, theme toggle
*/
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, 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;
isLoading?: boolean;
onSettingsClick?: () => void;
}
export function AppHeader({
agents,
workflows,
entities,
selectedItem,
onSelect,
onBrowseGallery,
isLoading = false,
onSettingsClick,
}: AppHeaderProps) {
const { oaiMode, serverVersion } = useDevUIStore();
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
<div className="flex items-center gap-2 font-semibold">
<svg
width="24"
height="24"
viewBox="0 0 805 805"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="flex-shrink-0"
>
<path
d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z"
fill="url(#paint0_linear_510_1294)"
/>
<defs>
<linearGradient
id="paint0_linear_510_1294"
x1="255.628"
y1="-34.3245"
x2="618.483"
y2="632.032"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D59FFF" />
<stop offset="1" stopColor="#8562C5" />
</linearGradient>
</defs>
</svg>
Dev UI
{serverVersion && (
<span className="text-xs text-muted-foreground ml-1">
v{serverVersion}
</span>
)}
{/* Mode Badge */}
{oaiMode.enabled && (
<Badge variant="secondary" className="gap-1 ml-2">
<Zap className="h-3 w-3" />
OpenAI: {oaiMode.model}
</Badge>
)}
</div>
{/* 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 />
<Button
variant="ghost"
size="sm"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onSettingsClick?.();
}}
>
<Settings className="h-4 w-4" />
</Button>
</div>
</header>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,861 @@
/**
* DeploymentModal - Shows Azure deployment instructions and Docker templates
* Features: Docker setup files, Azure Container Apps deployment guide
*/
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Rocket,
Container,
Cloud,
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";
export function DeploymentModal({
open,
onClose,
agentName = "Agent",
entity,
}: DeploymentModalProps) {
// 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(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
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);
setCopiedTemplate(templateName);
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout with cleanup
timeoutRef.current = setTimeout(() => {
setCopiedTemplate(null);
timeoutRef.current = null;
}, 2000);
} catch {
// Reset state on error - clipboard write failed
setCopiedTemplate(null);
}
};
const dockerfileTemplate = `# Dockerfile for ${agentName}
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy agent/workflow directories
COPY . .
# Expose DevUI default port
EXPOSE 8080
# Run DevUI server
CMD ["devui", ".", "--port", "8080", "--host", "0.0.0.0"]
`;
const dockerComposeTemplate = `# docker-compose.yml
version: '3.8'
services:
${agentName.toLowerCase().replace(/\s+/g, "-")}:
build: .
environment:
# OpenAI
- OPENAI_API_KEY=\${OPENAI_API_KEY}
- OPENAI_CHAT_COMPLETION_MODEL=\${OPENAI_CHAT_COMPLETION_MODEL:-gpt-4o-mini}
# Or Azure OpenAI
- AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY}
- AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT}
- AZURE_OPENAI_MODEL=\${AZURE_OPENAI_MODEL}
# Optional: Enable instrumentation
- ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false}
ports:
- "8080:8080"
restart: unless-stopped
`;
const requirementsTemplate = `# requirements.txt
agent-framework-devui>=0.1.0
agent-framework>=0.1.0
# Chat clients (install what you need)
openai>=1.0.0
# azure-openai
# anthropic
`;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="w-[800px] max-w-[90vw]">
<DialogClose onClose={onClose} />
<DialogHeader className="p-6 pb-2">
<DialogTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
Deploy {agentName}
</DialogTitle>
<p className="text-sm text-muted-foreground pt-1">
Get started with containerizing your agent for deployment.
</p>
</DialogHeader>
{/* Tabs */}
<div className="flex border-b px-6">
<button
onClick={() => setActiveTab("docker")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${activeTab === "docker"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Container className="h-4 w-4 mr-2 inline" />
Docker
{activeTab === "docker" && (
<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 */}
<div className="px-6 pb-6 min-h-[400px]">
<ScrollArea className="h-[500px]">
<div className="pr-4">
{activeTab === "docker" && (
<div className="space-y-4 pt-4">
<div>
<h3 className="font-semibold mb-2">
Containerize with Docker
</h3>
<p className="text-sm text-muted-foreground">
Package your agent as a Docker container for consistent
deployment anywhere.
</p>
</div>
{/* Dockerfile */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Dockerfile</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
handleCopy(dockerfileTemplate, "dockerfile")
}
>
{copiedTemplate === "dockerfile" ? (
<>
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
{dockerfileTemplate}
</pre>
</div>
{/* docker-compose.yml */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
docker-compose.yml
</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
handleCopy(dockerComposeTemplate, "compose")
}
>
{copiedTemplate === "compose" ? (
<>
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
{dockerComposeTemplate}
</pre>
</div>
{/* requirements.txt */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
requirements.txt
</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
handleCopy(requirementsTemplate, "requirements")
}
>
{copiedTemplate === "requirements" ? (
<>
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
{requirementsTemplate}
</pre>
</div>
{/* Quick Start */}
<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">Quick Start</h4>
<ol className="text-xs space-y-1 list-decimal list-inside text-muted-foreground">
<li>Save the files above to your project directory</li>
<li>
Build:{" "}
<code className="bg-muted px-1 rounded">
docker build -t {agentName.toLowerCase()}-agent .
</code>
</li>
<li>
Run:{" "}
<code className="bg-muted px-1 rounded">
docker-compose up
</code>
</li>
<li>Your agent is now running in a container!</li>
</ol>
</div>
{/* Production Warnings */}
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded-md p-3">
<h4 className="text-sm font-semibold mb-2 text-amber-900 dark:text-amber-100">
Production Considerations
</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-amber-800 dark:text-amber-200">
<li>
<strong>In-memory state:</strong> Conversations are lost
when container restarts
</li>
<li>
<strong>No authentication:</strong> Add reverse proxy
(nginx, Caddy) with auth for production
</li>
<li>
<strong>Security:</strong> Use Azure Key Vault for
secrets management
</li>
<li>
<strong>Scaling:</strong> Single instance only due to
in-memory conversation store
</li>
</ul>
</div>
{/* Deployment Checklist */}
<div className="border-t pt-4">
<h4 className="font-semibold text-sm mb-3">
Pre-Deployment Checklist
</h4>
<div className="space-y-2 text-sm">
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Set environment variables (API keys, secrets)
</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Test agent locally in container
</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Configure logging and monitoring
</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Set up error handling and retries
</span>
</div>
</div>
</div>
</div>
)}
{activeTab === "azure" && (
<div className="space-y-4 pt-4">
<div>
<h3 className="font-semibold mb-2">
Deploy to Azure Container Apps
</h3>
<p className="text-sm text-muted-foreground">
{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 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>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>
{/* 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 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
1
</div>
<h5 className="font-medium text-sm">
Create Azure Container Registry
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`# Create resource group
az group create --name myResourceGroup --location eastus
# Create container registry
az acr create --resource-group myResourceGroup \\
--name myregistry --sku Basic`}
</pre>
</div>
{/* Step 2 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
2
</div>
<h5 className="font-medium text-sm">
Build and Push Docker Image
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`# Build and push in one command
az acr build --registry myregistry \\
--image ${agentName.toLowerCase()}-agent:latest .`}
</pre>
</div>
{/* Step 3 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
3
</div>
<h5 className="font-medium text-sm">
Create Container Apps Environment
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`az containerapp env create --name myEnvironment \\
--resource-group myResourceGroup \\
--location eastus`}
</pre>
</div>
{/* Step 4 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
4
</div>
<h5 className="font-medium text-sm">
Deploy Container App
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`az containerapp create --name ${agentName.toLowerCase()}-app \\
--resource-group myResourceGroup \\
--environment myEnvironment \\
--image myregistry.azurecr.io/${agentName.toLowerCase()}-agent:latest \\
--target-port 8080 \\
--ingress 'external' \\
--registry-server myregistry.azurecr.io \\
--env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_COMPLETION_MODEL=gpt-4o-mini`}
</pre>
</div>
{/* Step 5 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
5
</div>
<h5 className="font-medium text-sm">
Get Application URL
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`az containerapp show --name ${agentName.toLowerCase()}-app \\
--resource-group myResourceGroup \\
--query properties.configuration.ingress.fqdn`}
</pre>
</div>
</div>
</div>
{/* Learn More */}
<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">Learn More</h4>
<p className="text-xs text-muted-foreground mb-3">
Explore Azure Container Apps documentation for advanced
features like scaling, monitoring, and CI/CD integration.
</p>
<Button
size="sm"
variant="outline"
className="w-full"
asChild
>
<a
href="https://learn.microsoft.com/azure/container-apps/"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-3 w-3 mr-1" />
View Azure Container Apps Documentation
</a>
</Button>
</div>
</>
)}
</div>
)}
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,229 @@
/**
* EntitySelector - Dropdown for selecting agents/workflows
* Features: Loading states, descriptions, lazy loading indicators
*/
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ChevronDown, Bot, Workflow, Plus, Loader2 } from "lucide-react";
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;
isLoading?: boolean;
}
const getTypeIcon = (type: "agent" | "workflow") => {
return type === "workflow" ? Workflow : Bot;
};
export function EntitySelector({
agents,
workflows,
entities,
selectedItem,
onSelect,
onBrowseGallery,
isLoading = false,
}: EntitySelectorProps) {
const [open, setOpen] = useState(false);
// Use entities if provided (preserves backend order), otherwise combine agents and workflows
const allItems = entities || [...agents, ...workflows];
const handleSelect = (item: AgentInfo | WorkflowInfo) => {
onSelect(item);
setOpen(false);
};
const TypeIcon = selectedItem ? getTypeIcon(selectedItem.type) : Bot;
const displayName = selectedItem?.name || selectedItem?.id || "Select Agent or Workflow";
const isLoaded = selectedItem?.metadata?.lazy_loaded !== false;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-64 justify-between font-mono text-sm"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<LoadingSpinner size="sm" />
<span className="text-muted-foreground">Loading...</span>
</div>
) : (
<>
<div className="flex items-center gap-2 min-w-0">
<TypeIcon className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{displayName}</span>
{selectedItem && !isLoaded && (
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin ml-auto flex-shrink-0" />
)}
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80 font-mono">
{/* 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");
// 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>
</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>
<div className="text-center text-muted-foreground py-2">
{isLoading ? "Loading agents and workflows..." : "No agents or workflows found"}
</div>
</DropdownMenuItem>
)}
{/* Browse Gallery option */}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-primary"
onClick={() => {
onBrowseGallery?.();
setOpen(false);
}}
>
<Plus className="h-4 w-4 mr-2" />
Browse Gallery
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,9 @@
/**
* Layout Components - Exports
*/
export { AppHeader } from "./app-header";
export { EntitySelector } from "./entity-selector";
export { DebugPanel } from "./debug-panel";
export { SettingsModal } from "./settings-modal";
export { DeploymentModal } from "./deployment-modal";
@@ -0,0 +1,720 @@
/**
* Settings Modal - Tabbed settings dialog with About and Settings tabs
*/
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { ExternalLink, RotateCcw, Info, ChevronRight } from "lucide-react";
import { useDevUIStore } from "@/stores";
interface SettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onBackendUrlChange?: (url: string) => void;
}
type Tab = "general" | "proxy" | "about";
// 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, auth status, server capabilities, streaming, and version from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities, serverVersion, runtime, uiMode, streamingEnabled, setStreamingEnabled } = 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 : "";
const [backendUrl, setBackendUrl] = useState(() => {
return localStorage.getItem("devui_backend_url") || defaultUrl;
});
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 {
new URL(tempUrl);
localStorage.setItem("devui_backend_url", tempUrl);
setBackendUrl(tempUrl);
onBackendUrlChange?.(tempUrl);
onOpenChange(false);
// Reload to apply new backend URL
window.location.reload();
} catch {
alert("Please enter a valid URL (e.g., http://localhost:8080)");
}
};
const handleReset = () => {
localStorage.removeItem("devui_backend_url");
setTempUrl(defaultUrl);
setBackendUrl(defaultUrl);
onBackendUrlChange?.(defaultUrl);
// Reload to apply default backend URL
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] 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 flex-shrink-0">
<button
onClick={() => setActiveTab("general")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "general"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
General
{activeTab === "general" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
{serverCapabilities.openai_proxy && (
<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>
)}
<button
onClick={() => setActiveTab("about")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "about"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
About
{activeTab === "about" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
</div>
{/* 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">
<div className="flex items-center justify-between">
<Label htmlFor="backend-url" className="text-sm font-medium">
Backend URL
</Label>
{!isDefault && (
<Button
variant="ghost"
size="sm"
onClick={handleReset}
className="h-7 text-xs"
title="Reset to default"
>
<RotateCcw className="h-3 w-3 mr-1" />
Reset
</Button>
)}
</div>
<Input
id="backend-url"
type="url"
value={tempUrl}
onChange={(e) => setTempUrl(e.target.value)}
placeholder="http://localhost:8080"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Default: <span className="font-mono">{defaultUrl}</span>
</p>
{/* Reserve space for buttons to prevent layout shift */}
<div className="flex gap-2 pt-2 min-h-[36px]">
{isModified && (
<>
<Button onClick={handleSave} size="sm" className="flex-1">
Apply & Reload
</Button>
<Button
onClick={() => setTempUrl(backendUrl)}
variant="outline"
size="sm"
className="flex-1"
>
Cancel
</Button>
</>
)}
</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 - Only show if backend supports deployment */}
{serverCapabilities.deployment && (
<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>
)}
{/* UI Settings */}
<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">
Show Tool Calls
</Label>
<p className="text-xs text-muted-foreground">
Display function/tool calls and results in chat messages
</p>
</div>
<Switch
checked={useDevUIStore.getState().showToolCalls}
onCheckedChange={(checked) => useDevUIStore.getState().setShowToolCalls(checked)}
/>
</div>
</div>
{/* Streaming Mode 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">
Streaming Mode
</Label>
<p className="text-xs text-muted-foreground">
Stream responses token-by-token as they're generated
</p>
</div>
<Switch
checked={streamingEnabled}
onCheckedChange={setStreamingEnabled}
/>
</div>
{!streamingEnabled && (
<div className="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 p-3 rounded">
<Info className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">Non-streaming mode limitations:</p>
<ul className="mt-1 space-y-0.5 list-disc list-inside text-amber-600/80 dark:text-amber-400/80">
<li>Tool calls won't display in real-time</li>
<li>No typing indicator during generation</li>
<li>Response appears all at once when complete</li>
</ul>
</div>
</div>
)}
</div>
</div>
)}
{activeTab === "proxy" && serverCapabilities.openai_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>
)}
{activeTab === "about" && (
<div className="space-y-4 pt-4">
<p className="text-sm text-muted-foreground">
DevUI is a sample app for getting started with Agent Framework.
</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Version:</span>
<span className="font-mono">{serverVersion || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Runtime:</span>
<span className="font-mono capitalize">{runtime || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">UI Mode:</span>
<span className="font-mono capitalize">{uiMode || 'Unknown'}</span>
</div>
</div>
{/* Capabilities section - only show if we have capability data */}
{(serverCapabilities || authRequired !== undefined) && (
<div className="space-y-2 pt-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Capabilities</p>
<div className="space-y-1 text-sm">
{serverCapabilities?.instrumentation !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Instrumentation:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.instrumentation ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.instrumentation ? 'Enabled' : 'Disabled'}
</span>
</div>
)}
{serverCapabilities?.openai_proxy !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">OpenAI Proxy:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.openai_proxy ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.openai_proxy ? 'Available' : 'Not Configured'}
</span>
</div>
)}
{serverCapabilities?.deployment !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Deployment:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.deployment ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.deployment ? 'Available' : 'Disabled'}
</span>
</div>
)}
{authRequired !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Authentication:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${authRequired ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400' : 'bg-muted text-muted-foreground'}`}>
{authRequired ? 'Required' : 'Not Required'}
</span>
</div>
)}
</div>
</div>
)}
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
"https://github.com/microsoft/agent-framework",
"_blank"
)
}
className="text-xs"
>
<ExternalLink className="h-3 w-3 mr-1" />
Learn More about Agent Framework
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,39 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
interface ThemeProviderProps {
children: React.ReactNode
attribute?: "class" | "data-theme" | "data-mode"
defaultTheme?: string
enableSystem?: boolean
disableTransitionOnChange?: boolean
}
export function ThemeProvider({
children,
attribute = "class",
defaultTheme = "dark",
enableSystem = true,
disableTransitionOnChange = true,
...props
}: ThemeProviderProps) {
return (
<NextThemesProvider
attribute={attribute}
defaultTheme={defaultTheme}
enableSystem={enableSystem}
disableTransitionOnChange={disableTransitionOnChange}
{...props}
>
{children}
</NextThemesProvider>
)
}
@@ -0,0 +1,48 @@
/**
* Alert component - Simple alert/callout component
*/
import * as React from "react";
import { cn } from "@/lib/utils";
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
className
)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
@@ -0,0 +1,123 @@
/**
* AttachmentGallery - Shows uploaded files with thumbnails and remove options
*/
import { useState } from "react";
import { FileText, Image, Trash2, Music } from "lucide-react";
export interface AttachmentItem {
id: string;
file: File;
preview?: string; // Data URL for preview
type: "image" | "pdf" | "audio" | "other";
}
interface AttachmentGalleryProps {
attachments: AttachmentItem[];
onRemoveAttachment: (id: string) => void;
className?: string;
}
export function AttachmentGallery({
attachments,
onRemoveAttachment,
className = "",
}: AttachmentGalleryProps) {
if (attachments.length === 0) return null;
return (
<div className={`flex flex-wrap gap-2 p-2 bg-muted rounded-lg ${className}`}>
{attachments.map((attachment) => (
<AttachmentPreview
key={attachment.id}
attachment={attachment}
onRemove={() => onRemoveAttachment(attachment.id)}
/>
))}
</div>
);
}
interface AttachmentPreviewProps {
attachment: AttachmentItem;
onRemove: () => void;
}
function AttachmentPreview({ attachment, onRemove }: AttachmentPreviewProps) {
const [isHovered, setIsHovered] = useState(false);
const renderPreview = () => {
switch (attachment.type) {
case "image":
return attachment.preview ? (
<img
src={attachment.preview}
alt={attachment.file.name}
className="w-full h-full object-cover"
/>
) : (
<div className="flex items-center justify-center w-full h-full bg-gray-200">
<Image className="h-6 w-6 text-gray-400" />
</div>
);
case "pdf":
return (
<div className="flex flex-col items-center justify-center w-full h-full bg-red-50">
<FileText className="h-6 w-6 text-red-500 mb-1" />
<span className="text-xs text-red-600">PDF</span>
</div>
);
case "audio":
return (
<div className="flex flex-col items-center justify-center w-full h-full bg-purple-50">
<Music className="h-6 w-6 text-purple-500 mb-1" />
<span className="text-xs text-purple-600">AUDIO</span>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center w-full h-full bg-gray-100">
<FileText className="h-6 w-6 text-gray-500 mb-1" />
<span className="text-xs text-gray-600">FILE</span>
</div>
);
}
};
return (
<div
className="relative w-16 h-16 rounded border overflow-hidden group cursor-pointer"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
title={attachment.file.name}
>
{renderPreview()}
{/* Dark overlay with centered delete icon on hover */}
<div
className={`absolute inset-0 bg-black/60 flex items-center justify-center transition-all duration-200 ease-in-out ${
isHovered
? 'opacity-100 backdrop-blur-sm'
: 'opacity-0 pointer-events-none'
}`}
onClick={onRemove}
>
<div className={`transition-all duration-200 ease-in-out ${
isHovered
? 'scale-100 opacity-100'
: 'scale-75 opacity-0'
}`}>
<Trash2 className="h-5 w-5 text-white drop-shadow-lg" />
</div>
</div>
{/* File name tooltip */}
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-75 text-white text-xs p-1 truncate opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{attachment.file.name}
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge };
@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,471 @@
/**
* ChatMessageInput - Reusable chat input component with file upload and rich text support
* Features: Text input, file upload, drag & drop, paste handling, attachments
*/
import { useState, useRef, useCallback, useEffect } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { FileUpload } from "@/components/ui/file-upload";
import {
AttachmentGallery,
type AttachmentItem,
} from "@/components/ui/attachment-gallery";
import {
SendHorizontal,
Square,
FileText,
Paperclip,
} from "lucide-react";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { ResponseInputContent, ResponseInputTextParam, ResponseInputImageParam, ResponseInputFileParam } from "@/types/agent-framework";
export interface ChatMessageInputProps {
onSubmit: (content: ResponseInputContent[]) => Promise<void>;
isSubmitting?: boolean;
isStreaming?: boolean;
onCancel?: () => void;
isCancelling?: boolean;
placeholder?: string;
showFileUpload?: boolean;
maxAttachments?: number;
className?: string;
disabled?: boolean;
entityName?: string; // For placeholder text
/** Files dropped from parent (via useDragDrop hook) */
externalFiles?: File[];
/** Called after external files have been processed */
onExternalFilesProcessed?: () => void;
}
export function ChatMessageInput({
onSubmit,
isSubmitting = false,
isStreaming = false,
onCancel,
isCancelling = false,
placeholder,
showFileUpload = true,
maxAttachments = 10,
className = "",
disabled = false,
entityName = "assistant",
externalFiles,
onExternalFilesProcessed,
}: ChatMessageInputProps) {
const [inputValue, setInputValue] = useState("");
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Process external files from parent (via useDragDrop hook)
useEffect(() => {
if (externalFiles && externalFiles.length > 0) {
handleFilesSelected(externalFiles);
onExternalFilesProcessed?.();
}
}, [externalFiles, onExternalFilesProcessed]);
// Constants for text-to-file conversion
const TEXT_THRESHOLD = 10000; // 10KB threshold for converting to file
// Helper functions
const getFileType = (file: File): AttachmentItem["type"] => {
if (file.type.startsWith("image/")) return "image";
if (file.type === "application/pdf") return "pdf";
if (file.type.startsWith("audio/")) return "audio";
return "other";
};
const readFileAsDataURL = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
// Detect file extension from content
const detectFileExtension = (text: string): string => {
const trimmed = text.trim();
const lines = trimmed.split("\n");
// JSON detection
if (/^{[\s\S]*}$|^\[[\s\S]*\]$/.test(trimmed)) return ".json";
// XML/HTML detection
if (/^<\?xml|^<html|^<!DOCTYPE/i.test(trimmed)) return ".html";
// Markdown detection (code blocks)
if (/^```/.test(trimmed)) return ".md";
// TSV detection (tabs with multiple lines)
if (/\t/.test(text) && lines.length > 1) return ".tsv";
// CSV detection (more strict) - need multiple lines with consistent comma patterns
if (lines.length > 2) {
const commaLines = lines.filter((line) => line.includes(","));
const semicolonLines = lines.filter((line) => line.includes(";"));
// If >50% of lines have commas and it looks tabular
if (commaLines.length > lines.length * 0.5) {
const avgCommas =
commaLines.reduce(
(sum, line) => sum + (line.match(/,/g) || []).length,
0
) / commaLines.length;
if (avgCommas >= 2) return ".csv";
}
// If >50% of lines have semicolons and it looks tabular
if (semicolonLines.length > lines.length * 0.5) {
const avgSemicolons =
semicolonLines.reduce(
(sum, line) => sum + (line.match(/;/g) || []).length,
0
) / semicolonLines.length;
if (avgSemicolons >= 2) return ".csv";
}
}
return ".txt";
};
// Handle file selection
const handleFilesSelected = useCallback(
async (files: File[]) => {
if (attachments.length + files.length > maxAttachments) {
console.warn(`Cannot add more than ${maxAttachments} attachments`);
return;
}
const newAttachments: AttachmentItem[] = [];
for (const file of files) {
const attachment: AttachmentItem = {
id: `${Date.now()}-${Math.random()}`,
file,
type: getFileType(file),
};
// Generate preview for images
if (file.type.startsWith("image/")) {
try {
attachment.preview = await readFileAsDataURL(file);
} catch (error) {
console.error("Failed to generate preview:", error);
}
}
newAttachments.push(attachment);
}
setAttachments((prev) => [...prev, ...newAttachments]);
},
[attachments.length, maxAttachments]
);
// Handle attachment removal
const handleRemoveAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
// Handle drag and drop
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await handleFilesSelected(files);
}
};
// Handle paste events
const handlePaste = async (e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const files: File[] = [];
let hasProcessedText = false;
for (const item of items) {
// Handle images (including screenshots)
if (item.type.startsWith("image/")) {
e.preventDefault();
const blob = item.getAsFile();
if (blob) {
const timestamp = Date.now();
files.push(
new File([blob], `screenshot-${timestamp}.png`, { type: blob.type })
);
}
}
// Handle text - only process first text item (browsers often duplicate)
else if (item.type === "text/plain" && !hasProcessedText) {
hasProcessedText = true;
// We need to check the text synchronously to decide whether to prevent default
// Unfortunately, getAsString is async, so we'll prevent default for all text
// and then decide whether to actually create a file or manually insert the text
e.preventDefault();
await new Promise<void>((resolve) => {
item.getAsString((text) => {
// Check if text should be converted to file
const lineCount = (text.match(/\n/g) || []).length;
const shouldConvert =
text.length > TEXT_THRESHOLD ||
lineCount > 50 || // Many lines suggests logs/data
/^\s*[{[][\s\S]*[}\]]\s*$/.test(text) || // JSON-like
/^<\?xml|^<html|^<!DOCTYPE/i.test(text); // XML/HTML
if (shouldConvert) {
// Create file for large/complex text
const extension = detectFileExtension(text);
const timestamp = Date.now();
const blob = new Blob([text], { type: "text/plain" });
files.push(
new File([blob], `pasted-text-${timestamp}${extension}`, {
type: "text/plain",
})
);
} else {
// For small text, manually insert into textarea since we prevented default
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const currentValue = textarea.value;
const newValue =
currentValue.slice(0, start) + text + currentValue.slice(end);
setInputValue(newValue);
// Restore cursor position after the inserted text
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd =
start + text.length;
textarea.focus();
}, 0);
}
}
resolve();
});
});
}
}
// Process collected files
if (files.length > 0) {
await handleFilesSelected(files);
// Show notification with appropriate icon
const message =
files.length === 1
? files[0].name.includes("screenshot")
? "Screenshot added as attachment"
: "Large text converted to file"
: `${files.length} files added`;
setPasteNotification(message);
setTimeout(() => setPasteNotification(null), 3000);
}
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (
(!inputValue.trim() && attachments.length === 0) ||
isSubmitting ||
disabled
)
return;
const messageText = inputValue.trim();
const content: ResponseInputContent[] = [];
// Add text content if present
if (messageText) {
content.push({
text: messageText,
type: "input_text",
} as ResponseInputTextParam);
}
// Add attachments
for (const attachment of attachments) {
const dataUri = await readFileAsDataURL(attachment.file);
if (attachment.file.type.startsWith("image/")) {
// Image attachment
content.push({
detail: "auto",
type: "input_image",
image_url: dataUri,
} as ResponseInputImageParam);
} else if (
attachment.file.type === "text/plain" &&
(attachment.file.name.includes("pasted-text-") ||
attachment.file.name.endsWith(".txt") ||
attachment.file.name.endsWith(".csv") ||
attachment.file.name.endsWith(".json") ||
attachment.file.name.endsWith(".html") ||
attachment.file.name.endsWith(".md") ||
attachment.file.name.endsWith(".tsv"))
) {
// Convert text files back to input_text
const text = await attachment.file.text();
content.push({
text: text,
type: "input_text",
} as ResponseInputTextParam);
} else {
// Other file types
const base64Data = dataUri.split(",")[1]; // Extract base64 part
content.push({
type: "input_file",
file_data: base64Data,
file_url: dataUri,
filename: attachment.file.name,
} as ResponseInputFileParam);
}
}
// Call the onSubmit callback
await onSubmit(content);
// Clear input and attachments after successful submission
setInputValue("");
setAttachments([]);
};
const canSendMessage =
!disabled &&
!isSubmitting &&
!isStreaming &&
(inputValue.trim() || attachments.length > 0);
return (
<div
className={`relative ${className}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDragOver && (
<div className="absolute inset-2 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg bg-blue-50/80 dark:bg-blue-950/40 backdrop-blur-sm flex items-center justify-center transition-all duration-200 ease-in-out z-10">
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-sm font-medium mb-1">
Drop files here
</div>
<div className="text-blue-500 dark:text-blue-500 text-xs">
Images, PDFs, and other files
</div>
</div>
</div>
)}
{/* Attachment gallery */}
{attachments.length > 0 && (
<div className="mb-3">
<AttachmentGallery
attachments={attachments}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
)}
{/* Paste notification */}
{pasteNotification && (
<div
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20
bg-blue-500 text-white px-4 py-2 rounded-full text-sm
animate-in slide-in-from-bottom-2 fade-in duration-200
flex items-center gap-2 shadow-lg"
>
{pasteNotification.includes("screenshot") ? (
<Paperclip className="h-3 w-3" />
) : (
<FileText className="h-3 w-3" />
)}
{pasteNotification}
</div>
)}
{/* Input form */}
<form onSubmit={handleSubmit} className="flex gap-2 items-end">
<Textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
// Submit on Enter (without shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
placeholder={placeholder || `Message ${entityName}... (Shift+Enter for new line)`}
disabled={disabled || isSubmitting || isStreaming}
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
style={{ fieldSizing: "content" } as React.CSSProperties}
/>
{showFileUpload && (
<FileUpload
onFilesSelected={handleFilesSelected}
disabled={disabled || isSubmitting || isStreaming}
/>
)}
{isStreaming && onCancel ? (
<Button
type="button"
size="icon"
onClick={onCancel}
disabled={isCancelling}
className="shrink-0 h-10 transition-all"
title="Stop generating"
aria-label="Stop generating response"
>
{isCancelling ? (
<LoadingSpinner size="sm" />
) : (
<Square className="h-4 w-4 fill-current" />
)}
</Button>
) : (
<Button
type="submit"
size="icon"
disabled={!canSendMessage}
className="shrink-0 h-10 transition-all"
title="Send message"
aria-label="Send message"
>
{isSubmitting ? (
<LoadingSpinner size="sm" />
) : (
<SendHorizontal className="h-4 w-4" />
)}
</Button>
)}
</form>
</div>
);
}
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -0,0 +1,126 @@
import React from "react";
import { X } from "lucide-react";
import { Button } from "./button";
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}
interface DialogContentProps {
children: React.ReactNode;
className?: string;
}
interface DialogHeaderProps {
children: React.ReactNode;
className?: string;
}
interface DialogTitleProps {
children: React.ReactNode;
className?: string;
}
interface DialogDescriptionProps {
children: React.ReactNode;
className?: string;
}
interface DialogFooterProps {
children: React.ReactNode;
}
export function Dialog({ open, onOpenChange, children }: DialogProps) {
if (!open) return null;
const handleBackdropClick = () => {
// Close the modal when backdrop is clicked
onOpenChange(false);
};
const handleContentClick = (e: React.MouseEvent) => {
// Stop any clicks inside the content from bubbling to backdrop
e.stopPropagation();
};
const handleContentMouseDown = (e: React.MouseEvent) => {
// Prevent mousedown from bubbling during text selection
e.stopPropagation();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop - handles clicks to close */}
<div
className="absolute inset-0 bg-black/50"
onClick={handleBackdropClick}
/>
{/* Modal content - positioned above backdrop with z-index */}
<div
className="relative z-10"
onClick={handleContentClick}
onMouseDown={handleContentMouseDown}
onMouseUp={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}
export function DialogContent({
children,
className = "",
}: DialogContentProps) {
// Default width classes if none provided
const hasWidthClass = className.includes('w-[') || className.includes('w-full') || className.includes('max-w-');
const defaultWidthClasses = hasWidthClass ? '' : 'max-w-lg w-full';
return (
<div
className={`relative bg-background border rounded-lg shadow-lg max-h-[90vh] overflow-hidden ${defaultWidthClasses} ${className}`}
>
{children}
</div>
);
}
export function DialogHeader({ children, className = "" }: DialogHeaderProps) {
return (
<div className={`space-y-2 ${className}`}>
{children}
</div>
);
}
export function DialogTitle({ children, className = "" }: DialogTitleProps) {
return <h2 className={`text-lg font-semibold ${className}`}>{children}</h2>;
}
export function DialogDescription({ children, className = "" }: DialogDescriptionProps) {
return <p className={`text-sm text-muted-foreground ${className}`}>{children}</p>;
}
export function DialogClose({ onClose }: { onClose: () => void }) {
return (
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="absolute top-4 right-4 h-8 w-8 p-0 rounded-sm opacity-70 hover:opacity-100"
>
<X className="h-4 w-4" />
</Button>
);
}
export function DialogFooter({ children }: DialogFooterProps) {
return (
<div className="flex justify-end gap-2 p-4 border-t bg-muted/50">
{children}
</div>
);
}
@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,141 @@
/**
* FileUpload - Upload button with drag & drop support
*/
import { useRef } from "react";
import { Upload } from "lucide-react";
import { Button } from "./button";
interface FileUploadProps {
onFilesSelected: (files: File[]) => void;
accept?: string;
multiple?: boolean;
maxSize?: number; // in bytes
disabled?: boolean;
className?: string;
}
export function FileUpload({
onFilesSelected,
accept = "image/*,.pdf,audio/*,.wav,.mp3,.m4a,.ogg",
multiple = true,
maxSize = 50 * 1024 * 1024, // 50MB default for local dev tool
disabled = false,
className = "",
}: FileUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (files: FileList | null) => {
if (!files || files.length === 0) return;
const validFiles: File[] = [];
const errors: string[] = [];
Array.from(files).forEach((file) => {
// Size validation
if (file.size > maxSize) {
errors.push(`${file.name} is too large (max ${formatFileSize(maxSize)})`);
return;
}
// Type validation (basic)
if (accept && !isFileAccepted(file, accept)) {
errors.push(`${file.name} is not an accepted file type`);
return;
}
validFiles.push(file);
});
if (errors.length > 0) {
console.warn("File upload errors:", errors);
// In a production app, you might want to show these errors to the user
}
if (validFiles.length > 0) {
onFilesSelected(validFiles);
}
};
const handleButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleFileSelect(e.target.files);
// Reset input to allow selecting the same file again
e.target.value = "";
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
const files = e.dataTransfer.files;
handleFileSelect(files);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
return (
<div className={className}>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileInputChange}
className="hidden"
disabled={disabled}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleButtonClick}
disabled={disabled}
onDrop={handleDrop}
onDragOver={handleDragOver}
className="shrink-0 transition-colors hover:bg-muted"
title="Upload files (images, PDFs, audio)"
>
<Upload className="h-4 w-4" />
</Button>
</div>
);
}
// Helper functions
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function isFileAccepted(file: File, accept: string): boolean {
const acceptPatterns = accept.split(",").map((pattern) => pattern.trim());
return acceptPatterns.some((pattern) => {
if (pattern.startsWith(".")) {
// File extension check
return file.name.toLowerCase().endsWith(pattern.toLowerCase());
} else if (pattern.includes("/*")) {
// MIME type wildcard check (e.g., "image/*")
const [mainType] = pattern.split("/");
return file.type.startsWith(mainType + "/");
} else {
// Exact MIME type check
return file.type === pattern;
}
});
}
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,23 @@
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
interface LoadingSpinnerProps {
size?: "sm" | "md" | "lg"
className?: string
}
export function LoadingSpinner({ size = "md", className }: LoadingSpinnerProps) {
return (
<Loader2
className={cn(
"animate-spin",
{
"h-4 w-4": size === "sm",
"h-6 w-6": size === "md",
"h-8 w-8": size === "lg",
},
className
)}
/>
)
}
@@ -0,0 +1,52 @@
import { LoadingSpinner } from "./loading-spinner"
import { cn } from "@/lib/utils"
interface LoadingStateProps {
message?: string
description?: string
size?: "sm" | "md" | "lg"
className?: string
fullPage?: boolean
}
export function LoadingState({
message = "Loading...",
description,
size = "md",
className,
fullPage = false
}: LoadingStateProps) {
const content = (
<div className={cn(
"flex flex-col items-center justify-center gap-3",
fullPage ? "min-h-[50vh]" : "py-8",
className
)}>
<LoadingSpinner size={size} className="text-muted-foreground" />
<div className="text-center space-y-1">
<p className={cn(
"font-medium text-muted-foreground",
size === "sm" && "text-sm",
size === "lg" && "text-lg"
)}>
{message}
</p>
{description && (
<p className="text-sm text-muted-foreground/80">
{description}
</p>
)}
</div>
</div>
)
if (fullPage) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
{content}
</div>
)
}
return content
}
@@ -0,0 +1,565 @@
/**
* Lightweight Markdown Renderer
*
* A minimal markdown renderer with zero dependencies for rendering LLM responses.
* Handles the most common markdown patterns without bloating bundle size.
*
* Supported syntax:
* - **bold** and __bold__
* - *italic* and _italic_
* - `inline code`
* - ```code blocks``` (with copy button on hover)
* - [links](url)
* - **[bold links](url)** and *[italic links](url)*
* - # Headers (H1-H6)
* - Lists (ordered and unordered)
* - > Blockquotes
* - Tables (| col1 | col2 |)
* - Horizontal rules (---)
*/
import React, { useState, useRef, useEffect } from "react";
interface MarkdownRendererProps {
content: string;
className?: string;
}
interface CodeBlockProps {
code: string;
language?: string;
}
/**
* Code block component with copy button
*/
function CodeBlock({ code, language }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout and store reference
timeoutRef.current = setTimeout(() => {
setCopied(false);
timeoutRef.current = null;
}, 2000);
} catch (err) {
console.error("Failed to copy code:", err);
}
};
return (
<div className="relative group">
<pre className="my-3 p-3 bg-foreground/5 dark:bg-foreground/10 rounded overflow-x-auto border border-foreground/10">
<code className="text-xs font-mono block whitespace-pre-wrap break-words">
{language && (
<span className="opacity-60 text-[10px] mb-1 block uppercase">
{language}
</span>
)}
{code}
</code>
</pre>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md border shadow-sm
bg-background hover:bg-accent
text-muted-foreground hover:text-foreground
transition-all duration-200
opacity-0 group-hover:opacity-100"
title={copied ? "Copied!" : "Copy code"}
>
{copied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-green-600 dark:text-green-400"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
)}
</button>
</div>
);
}
/**
* Parse markdown text into React elements
*/
export function MarkdownRenderer({
content,
className = "",
}: MarkdownRendererProps) {
const lines = content.split("\n");
const elements: React.ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Code blocks (multiline)
if (line.trim().startsWith("```")) {
const codeLines: string[] = [];
const langMatch = line.trim().match(/^```(\w+)?/);
const language = langMatch?.[1] || "";
i++; // Skip opening ```
while (i < lines.length && !lines[i].trim().startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // Skip closing ```
elements.push(
<CodeBlock
key={elements.length}
code={codeLines.join("\n")}
language={language}
/>
);
continue;
}
// Headers
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const text = headerMatch[2];
const sizes = [
"text-2xl",
"text-xl",
"text-lg",
"text-base",
"text-sm",
"text-sm",
];
const className = `${
sizes[level - 1]
} font-semibold mt-4 mb-2 first:mt-0 break-words`;
// Render appropriate header level
const header =
level === 1 ? (
<h1 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h1>
) : level === 2 ? (
<h2 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h2>
) : level === 3 ? (
<h3 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h3>
) : level === 4 ? (
<h4 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h4>
) : level === 5 ? (
<h5 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h5>
) : (
<h6 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h6>
);
elements.push(header);
i++;
continue;
}
// Unordered lists
if (line.match(/^[\s]*[-*+]\s+/)) {
const listItems: string[] = [];
while (i < lines.length && lines[i].match(/^[\s]*[-*+]\s+/)) {
const itemText = lines[i].replace(/^[\s]*[-*+]\s+/, "");
listItems.push(itemText);
i++;
}
elements.push(
<ul
key={elements.length}
className="my-2 ml-4 list-disc space-y-1 break-words"
>
{listItems.map((item, idx) => (
<li key={idx} className="text-sm break-words">
{parseInlineMarkdown(item)}
</li>
))}
</ul>
);
continue;
}
// Ordered lists
if (line.match(/^[\s]*\d+\.\s+/)) {
const listItems: string[] = [];
while (i < lines.length && lines[i].match(/^[\s]*\d+\.\s+/)) {
const itemText = lines[i].replace(/^[\s]*\d+\.\s+/, "");
listItems.push(itemText);
i++;
}
elements.push(
<ol
key={elements.length}
className="my-2 ml-4 list-decimal space-y-1 break-words"
>
{listItems.map((item, idx) => (
<li key={idx} className="text-sm break-words">
{parseInlineMarkdown(item)}
</li>
))}
</ol>
);
continue;
}
// Tables
if (line.trim().startsWith("|") && line.trim().endsWith("|")) {
const tableLines: string[] = [];
// Collect all table lines
while (
i < lines.length &&
lines[i].trim().startsWith("|") &&
lines[i].trim().endsWith("|")
) {
tableLines.push(lines[i].trim());
i++;
}
// Parse table (need at least 2 lines: header + separator)
if (tableLines.length >= 2) {
const headerCells = tableLines[0]
.split("|")
.slice(1, -1)
.map((cell) => cell.trim());
// Check if second line is a separator (contains dashes)
const isSeparator = tableLines[1].match(/^\|[\s\-:|]+\|$/);
if (isSeparator) {
const bodyRows = tableLines.slice(2).map((row) =>
row
.split("|")
.slice(1, -1)
.map((cell) => cell.trim())
);
elements.push(
<div key={elements.length} className="my-3 overflow-x-auto">
<table className="min-w-full border border-foreground/10 text-sm">
<thead className="bg-foreground/5">
<tr>
{headerCells.map((header, idx) => (
<th
key={idx}
className="border-b border-foreground/10 px-3 py-2 text-left font-semibold break-words"
>
{parseInlineMarkdown(header)}
</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row, rowIdx) => (
<tr
key={rowIdx}
className="border-b border-foreground/5 last:border-b-0"
>
{row.map((cell, cellIdx) => (
<td
key={cellIdx}
className="px-3 py-2 border-r border-foreground/5 last:border-r-0 break-words"
>
{parseInlineMarkdown(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
continue;
}
}
// Not a valid table, render as regular paragraphs
for (const tableLine of tableLines) {
elements.push(
<p key={elements.length} className="my-1">
{parseInlineMarkdown(tableLine)}
</p>
);
}
continue;
}
// Blockquotes
if (line.trim().startsWith(">")) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith(">")) {
quoteLines.push(lines[i].replace(/^>\s?/, ""));
i++;
}
elements.push(
<blockquote
key={elements.length}
className="my-2 pl-4 border-l-4 border-current/30 opacity-80 italic break-words"
>
{quoteLines.map((quoteLine, idx) => (
<div key={idx} className="break-words">
{parseInlineMarkdown(quoteLine)}
</div>
))}
</blockquote>
);
continue;
}
// Horizontal rule
if (line.match(/^[\s]*[-*_]{3,}[\s]*$/)) {
elements.push(
<hr key={elements.length} className="my-4 border-t border-border" />
);
i++;
continue;
}
// Empty line
if (line.trim() === "") {
elements.push(<div key={elements.length} className="h-2" />);
i++;
continue;
}
// Regular paragraph
elements.push(
<p key={elements.length} className="my-1 break-words">
{parseInlineMarkdown(line)}
</p>
);
i++;
}
return (
<div className={`markdown-content break-words ${className}`}>
{elements}
</div>
);
}
/**
* Parse inline markdown patterns (bold, italic, code, links)
*/
function parseInlineMarkdown(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
// Pattern priority: code > bold > italic > links
// This prevents conflicts between overlapping patterns
while (remaining.length > 0) {
// Inline code (highest priority to avoid parsing inside code)
const codeMatch = remaining.match(/`([^`]+)`/);
if (codeMatch && codeMatch.index !== undefined) {
// Add text before code
if (codeMatch.index > 0) {
parts.push(
<span key={key++}>
{parseBoldItalicLinks(remaining.slice(0, codeMatch.index))}
</span>
);
}
// Add code
parts.push(
<code
key={key++}
className="px-1.5 py-0.5 bg-foreground/10 rounded text-xs font-mono border border-foreground/20"
>
{codeMatch[1]}
</code>
);
remaining = remaining.slice(codeMatch.index + codeMatch[0].length);
continue;
}
// No more special patterns, parse remaining text for bold/italic/links
parts.push(<span key={key++}>{parseBoldItalicLinks(remaining)}</span>);
break;
}
return parts;
}
/**
* Parse bold, italic, and links (after code has been extracted)
*/
function parseBoldItalicLinks(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
// Try to match patterns in order
// IMPORTANT: Handle **[link](url)** pattern first (bold markers around link)
const patterns = [
{ regex: /\*\*\[([^\]]+)\]\(([^)]+)\)\*\*/, component: "strong-link" }, // **[text](url)**
{ regex: /__\[([^\]]+)\]\(([^)]+)\)__/, component: "strong-link" }, // __[text](url)__
{ regex: /\*\[([^\]]+)\]\(([^)]+)\)\*/, component: "em-link" }, // *[text](url)*
{ regex: /_\[([^\]]+)\]\(([^)]+)\)_/, component: "em-link" }, // _[text](url)_
{ regex: /\[([^\]]+)\]\(([^)]+)\)/, component: "link" }, // [text](url)
{ regex: /\*\*(.+?)\*\*/, component: "strong" }, // **bold**
{ regex: /__(.+?)__/, component: "strong" }, // __bold__
{ regex: /\*(.+?)\*/, component: "em" }, // *italic*
{ regex: /_(.+?)_/, component: "em" }, // _italic_
];
let matched = false;
for (const pattern of patterns) {
const match = remaining.match(pattern.regex);
if (match && match.index !== undefined) {
// Add text before match
if (match.index > 0) {
parts.push(remaining.slice(0, match.index));
}
// Add matched element
if (pattern.component === "strong") {
parts.push(
<strong key={key++} className="font-semibold">
{match[1]}
</strong>
);
} else if (pattern.component === "em") {
parts.push(
<em key={key++} className="italic">
{match[1]}
</em>
);
} else if (pattern.component === "strong-link") {
// **[text](url)** - Bold link
const linkText = match[1];
const linkUrl = match[2];
const formattedLinkText = parseBoldItalicLinks(linkText);
parts.push(
<strong key={key++} className="font-semibold">
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-words"
>
{formattedLinkText}
</a>
</strong>
);
} else if (pattern.component === "em-link") {
// *[text](url)* - Italic link
const linkText = match[1];
const linkUrl = match[2];
const formattedLinkText = parseBoldItalicLinks(linkText);
parts.push(
<em key={key++} className="italic">
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-words"
>
{formattedLinkText}
</a>
</em>
);
} else if (pattern.component === "link") {
// [text](url) - Regular link
const linkText = match[1];
const linkUrl = match[2];
const formattedLinkText = parseBoldItalicLinks(linkText);
parts.push(
<a
key={key++}
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-words"
>
{formattedLinkText}
</a>
);
}
remaining = remaining.slice(match.index + match[0].length);
matched = true;
break;
}
}
// No pattern matched, add remaining text and exit
if (!matched) {
if (remaining.length > 0) {
parts.push(remaining);
}
break;
}
}
return parts;
}
@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
@@ -0,0 +1,183 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,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 }
@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,89 @@
/**
* Simple toast notification component
* Displays floating notifications in the top-right corner
*/
import { useEffect, useState } from "react";
import { X } from "lucide-react";
export interface ToastProps {
message: string;
type?: "info" | "success" | "warning" | "error";
duration?: number;
onClose: () => void;
}
export function Toast({ message, type = "info", duration = 4000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(onClose, 300); // Wait for fade out animation
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const bgColorClass = {
info: "bg-primary/10 border-primary/20",
success: "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800",
warning: "bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800",
error: "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800",
}[type];
const textColorClass = {
info: "text-primary",
success: "text-green-800 dark:text-green-200",
warning: "text-orange-800 dark:text-orange-200",
error: "text-red-800 dark:text-red-200",
}[type];
return (
<div
className={`fixed top-4 right-4 z-50 flex items-start gap-3 p-4 rounded-lg border shadow-lg max-w-md transition-all duration-300 ${
isVisible ? "opacity-100 translate-x-0" : "opacity-0 translate-x-4"
} ${bgColorClass}`}
>
<p className={`text-sm flex-1 ${textColorClass}`}>{message}</p>
<button
onClick={() => {
setIsVisible(false);
setTimeout(onClose, 300);
}}
className={`flex-shrink-0 hover:opacity-70 transition-opacity ${textColorClass}`}
>
<X className="h-4 w-4" />
</button>
</div>
);
}
// Toast container for managing multiple toasts
export interface ToastData {
id: string;
message: string;
type?: "info" | "success" | "warning" | "error";
duration?: number;
}
interface ToastContainerProps {
toasts: ToastData[];
onRemove: (id: string) => void;
}
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
duration={toast.duration}
onClose={() => onRemove(toast.id)}
/>
))}
</div>
);
}
@@ -0,0 +1,30 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,5 @@
/**
* Gallery data exports
*/
export * from './sample-entities';
@@ -0,0 +1,155 @@
/**
* Sample entities for the gallery - curated examples to help users learn Agent Framework
*/
export interface EnvVarRequirement {
name: string;
description: string;
required: boolean;
example?: string;
}
export interface SampleEntity {
id: string;
name: string;
description: string;
type: "agent" | "workflow";
url: string;
tags: string[];
author: string;
difficulty: "beginner" | "intermediate" | "advanced";
features: string[];
requiredEnvVars?: EnvVarRequirement[];
}
export const SAMPLE_ENTITIES: SampleEntity[] = [
// Beginner Agents
{
id: "foundry-weather-agent",
name: "Azure AI Weather Agent",
description:
"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",
tags: ["azure-ai", "foundry", "tools"],
author: "Microsoft",
difficulty: "beginner",
features: [
"Azure AI Agent integration",
"Azure CLI authentication",
"Mock weather tools",
],
requiredEnvVars: [
{
name: "FOUNDRY_PROJECT_ENDPOINT",
description: "Azure AI Foundry project endpoint URL",
required: true,
example: "https://your-project.api.azureml.ms",
},
{
name: "FOUNDRY_MODEL",
description: "Name of the deployed model in Azure AI Foundry",
required: true,
example: "gpt-4o",
},
],
},
{
id: "weather-agent-azure",
name: "Azure OpenAI Weather Agent",
description:
"Weather agent using Azure OpenAI with API key authentication",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",
tags: ["azure", "openai", "tools"],
author: "Microsoft",
difficulty: "beginner",
features: [
"Azure OpenAI integration",
"API key authentication",
"Function calling",
"Mock weather tools",
],
requiredEnvVars: [
{
name: "AZURE_OPENAI_API_KEY",
description: "Azure OpenAI API key",
required: true,
},
{
name: "AZURE_OPENAI_MODEL",
description: "Name of the deployed model in Azure OpenAI",
required: true,
example: "gpt-4o",
},
{
name: "AZURE_OPENAI_ENDPOINT",
description: "Azure OpenAI endpoint URL",
required: true,
example: "https://your-resource.openai.azure.com",
},
],
},
// Beginner Workflows
{
id: "spam-workflow",
name: "Spam Detection Workflow",
description:
"5-step workflow demonstrating email spam detection with branching logic",
type: "workflow",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",
tags: ["workflow", "branching", "multi-step"],
author: "Microsoft",
difficulty: "beginner",
features: [
"Sequential execution",
"Conditional branching",
"Mock spam detection",
],
},
// Advanced Workflows
{
id: "fanout-workflow",
name: "Complex Fan-In/Fan-Out Workflow",
description:
"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",
type: "workflow",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",
tags: ["workflow", "fan-out", "fan-in", "parallel"],
author: "Microsoft",
difficulty: "advanced",
features: [
"Fan-out pattern",
"Parallel execution",
"Complex state management",
"Multi-stage processing",
],
},
];
// Group samples by category for better organization
export const SAMPLE_CATEGORIES = {
all: SAMPLE_ENTITIES,
agents: SAMPLE_ENTITIES.filter((e) => e.type === "agent"),
workflows: SAMPLE_ENTITIES.filter((e) => e.type === "workflow"),
beginner: SAMPLE_ENTITIES.filter((e) => e.difficulty === "beginner"),
intermediate: SAMPLE_ENTITIES.filter((e) => e.difficulty === "intermediate"),
advanced: SAMPLE_ENTITIES.filter((e) => e.difficulty === "advanced"),
};
// Get difficulty color for badges
export const getDifficultyColor = (difficulty: SampleEntity["difficulty"]) => {
switch (difficulty) {
case "beginner":
return "bg-green-100 text-green-700 border-green-200";
case "intermediate":
return "bg-yellow-100 text-yellow-700 border-yellow-200";
case "advanced":
return "bg-red-100 text-red-700 border-red-200";
default:
return "bg-gray-100 text-gray-700 border-gray-200";
}
};
@@ -0,0 +1,3 @@
export { useCancellableRequest, isAbortError } from './useCancellableRequest';
export { useDragDrop } from './use-drag-drop';
export type { UseDragDropOptions, UseDragDropReturn } from './use-drag-drop';
@@ -0,0 +1,98 @@
/**
* useDragDrop - Hook for handling drag and drop file uploads at parent level
* Provides drag state and handlers that can be spread on a container element
*/
import { useState, useCallback, useRef } from "react";
export interface UseDragDropOptions {
/** Called when files are dropped */
onDrop?: (files: File[]) => void;
/** Whether drag/drop is disabled */
disabled?: boolean;
}
export interface UseDragDropReturn {
/** Whether a drag is currently over the drop zone */
isDragOver: boolean;
/** Files that were dropped (cleared after processing) */
droppedFiles: File[];
/** Clear the dropped files after they've been processed */
clearDroppedFiles: () => void;
/** Event handlers to spread on the container element */
dragHandlers: {
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
};
}
export function useDragDrop(options: UseDragDropOptions = {}): UseDragDropReturn {
const { onDrop, disabled = false } = options;
const [isDragOver, setIsDragOver] = useState(false);
const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
const dragCounterRef = useRef(0);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragOver(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
}, [disabled]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
dragCounterRef.current = 0;
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
setDroppedFiles(files);
onDrop?.(files);
}
}, [disabled, onDrop]);
const clearDroppedFiles = useCallback(() => {
setDroppedFiles([]);
}, []);
return {
isDragOver,
droppedFiles,
clearDroppedFiles,
dragHandlers: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
},
};
}
@@ -0,0 +1,70 @@
/**
* Custom hook for managing cancellable requests with AbortController
* Reduces duplication across agent and workflow views
*/
import { useState, useRef, useCallback } from "react";
/**
* Hook for managing cancellable requests with AbortController
* @returns Object with cancellation state and methods
*/
export function useCancellableRequest() {
const [isCancelling, setIsCancelling] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
/**
* Creates a new AbortController and returns its signal
* Resets the cancelling state
*/
const createAbortSignal = useCallback((): AbortSignal => {
abortControllerRef.current = new AbortController();
setIsCancelling(false);
return abortControllerRef.current.signal;
}, []);
/**
* Cancels the current request if one exists
*/
const handleCancel = useCallback(() => {
if (abortControllerRef.current) {
setIsCancelling(true);
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
/**
* Resets the cancelling state - useful in error handlers
*/
const resetCancelling = useCallback(() => {
setIsCancelling(false);
}, []);
/**
* Cleanup function to be called when component unmounts
*/
const cleanup = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
return {
isCancelling,
createAbortSignal,
handleCancel,
resetCancelling,
cleanup,
};
}
/**
* Utility function to check if an error is an AbortError
* @param error - The error to check
* @returns true if the error is an AbortError
*/
export function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}
@@ -0,0 +1,184 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.48 0.18 290);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.62 0.20 290);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Mermaid diagram styles removed - visualization coming soon */
/* Style workflow completion/error states */
.workflow-chat-view .border-green-200 {
@apply border-emerald-200;
}
.workflow-chat-view .bg-green-50 {
@apply bg-emerald-50;
}
.workflow-chat-view .bg-green-100 {
@apply bg-emerald-100;
}
.workflow-chat-view .text-green-600 {
@apply text-emerald-600;
}
.workflow-chat-view .text-green-700 {
@apply text-emerald-700;
}
.workflow-chat-view .text-green-800 {
@apply text-emerald-800;
}
/* HIL Timeline Item Animations */
.highlight-attention {
animation: highlight-flash 1s ease-out;
}
@keyframes highlight-flash {
0% {
background-color: rgb(251 146 60 / 0.3);
transform: scale(1.02);
}
100% {
background-color: transparent;
transform: scale(1);
}
}
/* Pulsing glow effect for HIL waiting state */
.hil-waiting-glow {
box-shadow:
0 0 0 0 rgb(251 146 60 / 0.4),
inset 0 0 0 1px rgb(251 146 60 / 0.2);
animation: pulse-glow 2s infinite;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow:
0 0 0 0 rgb(251 146 60 / 0.4),
inset 0 0 0 1px rgb(251 146 60 / 0.2);
}
50% {
box-shadow:
0 0 20px 5px rgb(251 146 60 / 0.2),
inset 0 0 0 2px rgb(251 146 60 / 0.3);
}
}
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,22 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ThemeProvider } from "./components/theme-provider"
import { initStreamingState } from "./services/api"
// Initialize streaming state management (clears expired states)
initStreamingState();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<App />
</ThemeProvider>
</StrictMode>,
)
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More