Python:DevUI Fixes (#1035)

* fix event reset on thread change, enable multiline input, enable pasting of files and screenshots

* UI updates and improved remove discovery

* ui and other fixes

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
This commit is contained in:
Victor Dibia
2025-09-30 17:21:22 -07:00
committed by GitHub
Unverified
parent 042099009f
commit fa9f5c1aed
41 changed files with 2949 additions and 814 deletions
+25 -26
View File
@@ -1,6 +1,6 @@
# DevUI - Agent Framework Debug Interface
# 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 both **directory-based discovery** and **in-memory entity registration**.
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.
@@ -12,11 +12,6 @@ A lightweight, standalone sample app interface for running entities (agents/work
```bash
# Install
pip install agent-framework-devui
# Launch web UI + API server
devui ./agents --port 8080
# → Web UI: http://localhost:8080
# → API: http://localhost:8080/v1/*
```
You can also launch it programmatically
@@ -42,6 +37,18 @@ 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/*
```
When DevUI starts with no discovered entities, it displays a **sample entity gallery** with curated examples from the Agent Framework repository to help you get started quickly.
## 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`).
@@ -61,6 +68,14 @@ agents/
└── .env # Optional: shared environment variables
```
## Viewing Telemetry (Otel Traces) in DevUI
Agent Framework emits OpenTelemetry (Otel) traces for various operations. You can view these traces in DevUI by enabling tracing when starting the server.
```bash
devui ./agents --tracing framework
```
## OpenAI-Compatible API
For convenience, you can interact with the agents/workflows using the standard OpenAI API format. Just specify the `entity_id` in the `extra_body` field. This can be an `agent_id` or `workflow_id`.
@@ -75,27 +90,9 @@ curl -X POST http://localhost:8080/v1/responses \
"input": "Hello world",
"extra_body": {"entity_id": "weather_agent"}
}
EOF
```
Messages and events from agents/workflows are mapped to OpenAI response types in `agent_framework_devui/_mapper.py`. See the mapping table below:
| Agent Framework Content | OpenAI Event | Type |
| --------------------------------- | ----------------------------------------- | -------- |
| `TextContent` | `ResponseTextDeltaEvent` | Official |
| `TextReasoningContent` | `ResponseReasoningTextDeltaEvent` | Official |
| `FunctionCallContent` | `ResponseFunctionCallArgumentsDeltaEvent` | Official |
| `FunctionResultContent` | `ResponseFunctionResultComplete` | Custom |
| `ErrorContent` | `ResponseErrorEvent` | Official |
| `UsageContent` | `ResponseUsageEventComplete` | Custom |
| `DataContent` | `ResponseTraceEventComplete` | Custom |
| `UriContent` | `ResponseTraceEventComplete` | Custom |
| `HostedFileContent` | `ResponseTraceEventComplete` | Custom |
| `HostedVectorStoreContent` | `ResponseTraceEventComplete` | Custom |
| `FunctionApprovalRequestContent` | Custom event | Custom |
| `FunctionApprovalResponseContent` | Custom event | Custom |
| `WorkflowEvent` | `ResponseWorkflowEventComplete` | Custom |
## CLI Options
```bash
@@ -114,6 +111,8 @@ Options:
- `GET /v1/entities` - List discovered agents/workflows
- `GET /v1/entities/{entity_id}/info` - Get detailed entity information
- `POST /v1/entities/add` - Add entity from URL (for gallery samples)
- `DELETE /v1/entities/{entity_id}` - Remove remote entity
- `POST /v1/responses` - Execute agent/workflow (streaming or sync)
- `GET /health` - Health check
- `POST /v1/threads` - Create thread for agent (optional)
@@ -9,7 +9,7 @@ from typing import Any
from ._server import DevServer
from .models import AgentFrameworkRequest, OpenAIError, OpenAIResponse, ResponseStreamEvent
from .models._discovery_models import DiscoveryResponse, EntityInfo
from .models._discovery_models import DiscoveryResponse, EntityInfo, EnvVarRequirement
logger = logging.getLogger(__name__)
@@ -27,6 +27,7 @@ def serve(
auto_open: bool = False,
cors_origins: list[str] | None = None,
ui_enabled: bool = True,
tracing_enabled: bool = False,
) -> None:
"""Launch Agent Framework DevUI with simple API.
@@ -38,6 +39,7 @@ def serve(
auto_open: Whether to automatically open browser
cors_origins: List of allowed CORS origins
ui_enabled: Whether to enable the UI
tracing_enabled: Whether to enable OpenTelemetry tracing
"""
import re
@@ -51,6 +53,23 @@ def serve(
if not isinstance(port, int) or not (1 <= port <= 65535):
raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535")
# Configure tracing environment variables if enabled
if tracing_enabled:
import os
# Only set if not already configured by user
if not os.environ.get("ENABLE_OTEL"):
os.environ["ENABLE_OTEL"] = "true"
logger.info("Set ENABLE_OTEL=true for tracing")
if not os.environ.get("ENABLE_SENSITIVE_DATA"):
os.environ["ENABLE_SENSITIVE_DATA"] = "true"
logger.info("Set ENABLE_SENSITIVE_DATA=true for tracing")
if not os.environ.get("OTLP_ENDPOINT"):
os.environ["OTLP_ENDPOINT"] = "http://localhost:4317"
logger.info("Set OTLP_ENDPOINT=http://localhost:4317 for tracing")
# Create server with direct parameters
server = DevServer(
entities_dir=entities_dir, port=port, host=host, cors_origins=cors_origins, ui_enabled=ui_enabled
@@ -123,6 +142,7 @@ __all__ = [
"DevServer",
"DiscoveryResponse",
"EntityInfo",
"EnvVarRequirement",
"OpenAIError",
"OpenAIResponse",
"ResponseStreamEvent",
@@ -28,6 +28,7 @@ Examples:
devui ./agents # Scan specific directory
devui --port 8000 # Custom port
devui --headless # API only, no UI
devui --tracing # Enable OpenTelemetry tracing
""",
)
@@ -52,6 +53,8 @@ Examples:
parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
parser.add_argument("--tracing", action="store_true", help="Enable OpenTelemetry tracing for Agent Framework")
parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}")
return parser
@@ -119,7 +122,12 @@ def main() -> None:
from . import serve
serve(
entities_dir=entities_dir, port=args.port, host=args.host, auto_open=not args.no_open, ui_enabled=ui_enabled
entities_dir=entities_dir,
port=args.port,
host=args.host,
auto_open=not args.no_open,
ui_enabled=ui_enabled,
tracing_enabled=args.tracing,
)
except KeyboardInterrupt:
@@ -2,6 +2,9 @@
"""Agent Framework entity discovery implementation."""
from __future__ import annotations
import hashlib
import importlib
import importlib.util
import logging
@@ -10,6 +13,7 @@ import uuid
from pathlib import Path
from typing import Any
import httpx
from dotenv import load_dotenv
from .models._discovery_models import EntityInfo
@@ -29,6 +33,7 @@ class EntityDiscovery:
self.entities_dir = entities_dir
self._entities: dict[str, EntityInfo] = {}
self._loaded_objects: dict[str, Any] = {}
self._remote_cache_dir = Path.home() / ".agent_framework_devui" / "remote_cache"
async def discover_entities(self) -> list[EntityInfo]:
"""Scan for Agent Framework entities.
@@ -88,12 +93,15 @@ class EntityDiscovery:
self._loaded_objects[entity_id] = entity_object
logger.debug(f"Registered entity: {entity_id} ({entity_info.type})")
async def create_entity_info_from_object(self, entity_object: Any, entity_type: str | None = None) -> EntityInfo:
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
@@ -121,7 +129,7 @@ class EntityDiscovery:
description = getattr(entity_object, "description", "")
# Generate entity ID using Agent Framework specific naming
entity_id = self._generate_entity_id(entity_object, entity_type)
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)
@@ -343,9 +351,9 @@ class EntityDiscovery:
continue
if self._is_valid_entity(obj, obj_type):
entity_id = f"{obj_type}_{base_id}"
await self._register_entity_from_object(entity_id, obj, obj_type, module_path)
entities_found.append(entity_id)
# Pass source as "directory" for directory-discovered entities
await self._register_entity_from_object(obj, obj_type, module_path, source="directory")
entities_found.append(obj_type)
return entities_found
@@ -405,35 +413,31 @@ class EntityDiscovery:
# Check for workflow - must have run_stream method and executors
return hasattr(obj, "run_stream") and (hasattr(obj, "executors") or hasattr(obj, "get_executors_list"))
async def _register_entity_from_object(self, entity_id: str, obj: Any, obj_type: str, module_path: str) -> None:
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:
entity_id: Unique entity identifier
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:
# For directory-based entities, prefer directory name over UUID
# entity_id format: "workflow_fanout_workflow" or "agent_weather_agent"
if entity_id and "_" in entity_id:
# Directory-based: use formatted directory name (remove type prefix)
directory_name = entity_id.split("_", 1)[1] if "_" in entity_id else entity_id
name = directory_name.replace("_", " ").title()
entity_id_raw = getattr(obj, "id", None)
if entity_id_raw:
# Truncate UUID to first 8 characters for readability
short_id = str(entity_id_raw)[:8] if len(str(entity_id_raw)) > 8 else str(entity_id_raw)
name = f"{obj_type.title()} {short_id}"
else:
# In-memory: use ID with entity type prefix
entity_id_raw = getattr(obj, "id", None)
if entity_id_raw:
# Truncate UUID to first 8 characters for readability
short_id = str(entity_id_raw)[:8] if len(str(entity_id_raw)) > 8 else str(entity_id_raw)
name = f"{obj_type.title()} {short_id}"
else:
# Final fallback to class name
name = f"{obj_type.title()} {obj.__class__.__name__}"
name = f"{obj_type.title()} {obj.__class__.__name__}"
description = getattr(obj, "description", None)
tools = await self._extract_tools_from_object(obj, obj_type)
@@ -452,7 +456,7 @@ class EntityDiscovery:
metadata={
"module_path": module_path,
"entity_type": obj_type,
"source": "module_import",
"source": source,
"has_run_stream": hasattr(obj, "run_stream"),
"class_name": obj.__class__.__name__ if hasattr(obj, "__class__") else str(type(obj)),
},
@@ -462,7 +466,7 @@ class EntityDiscovery:
self.register_entity(entity_id, entity_info, obj)
except Exception as e:
logger.error(f"Error registering entity {entity_id}: {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.
@@ -517,34 +521,204 @@ class EntityDiscovery:
return tools
def _generate_entity_id(self, entity: Any, entity_type: str) -> str:
"""Generate entity ID with priority: name -> id -> class_name -> uuid.
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:
Generated entity ID
Unique entity ID with format: {type}_{source}_{name}_{uuid8}
"""
import re
# Priority 1: entity.name
# Extract base name with priority: name -> id -> class_name
if hasattr(entity, "name") and entity.name:
name = str(entity.name).lower().replace(" ", "-").replace("_", "-")
return f"{entity_type}_{name}"
# Priority 2: entity.id
if hasattr(entity, "id") and entity.id:
entity_id = str(entity.id).lower().replace(" ", "-").replace("_", "-")
return f"{entity_type}_{entity_id}"
# Priority 3: class name
if hasattr(entity, "__class__"):
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
class_name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", class_name).lower()
return f"{entity_type}_{class_name}"
base_name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", class_name).lower()
else:
base_name = "entity"
# Priority 4: fallback to uuid
return f"{entity_type}_{uuid.uuid4().hex[:8]}"
# Generate short UUID (8 chars = 4 billion combinations)
short_uuid = uuid.uuid4().hex[:8]
return f"{entity_type}_{source}_{base_name}_{short_uuid}"
async def fetch_remote_entity(
self, url: str, metadata: dict[str, Any] | None = None
) -> tuple[EntityInfo | None, str | None]:
"""Fetch and register entity from URL.
Args:
url: URL to Python file containing entity
metadata: Additional metadata (source, sampleId, etc.)
Returns:
Tuple of (EntityInfo if successful, error_message if failed)
"""
try:
normalized_url = self._normalize_url(url)
logger.info(f"Normalized URL: {normalized_url}")
content = await self._fetch_url_content(normalized_url)
if not content:
error_msg = "Failed to fetch content from URL. The file may not exist or is not accessible."
logger.warning(error_msg)
return None, error_msg
if not self._validate_python_syntax(content):
error_msg = "Invalid Python syntax in the file. Please check the file contains valid Python code."
logger.warning(error_msg)
return None, error_msg
entity_object = await self._load_entity_from_content(content, url)
if not entity_object:
error_msg = (
"No valid agent or workflow found in the file. "
"Make sure the file contains an 'agent' or 'workflow' variable."
)
logger.warning(error_msg)
return None, error_msg
entity_info = await self.create_entity_info_from_object(
entity_object,
entity_type=None, # Auto-detect
source="remote",
)
entity_info.source = metadata.get("source", "remote_gallery") if metadata else "remote_gallery"
entity_info.original_url = url
if metadata:
entity_info.metadata.update(metadata)
self.register_entity(entity_info.id, entity_info, entity_object)
logger.info(f"Successfully added remote entity: {entity_info.id}")
return entity_info, None
except Exception as e:
error_msg = f"Unexpected error: {e!s}"
logger.error(f"Error fetching remote entity from {url}: {e}", exc_info=True)
return None, error_msg
def _normalize_url(self, url: str) -> str:
"""Convert various Git hosting URLs to raw content URLs."""
# GitHub: blob -> raw
if "github.com" in url and "/blob/" in url:
return url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/")
# GitLab: blob -> raw
if "gitlab.com" in url and "/-/blob/" in url:
return url.replace("/-/blob/", "/-/raw/")
# Bitbucket: src -> raw
if "bitbucket.org" in url and "/src/" in url:
return url.replace("/src/", "/raw/")
return url
async def _fetch_url_content(self, url: str, max_size_mb: int = 10) -> str | None:
"""Fetch content from URL with size and timeout limits."""
try:
timeout = 30.0 # 30 second timeout
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url)
if response.status_code != 200:
logger.warning(f"HTTP {response.status_code} for {url}")
return None
# Check content length
content_length = response.headers.get("content-length")
if content_length and int(content_length) > max_size_mb * 1024 * 1024:
logger.warning(f"File too large: {content_length} bytes")
return None
# Read with size limit
content = response.text
if len(content.encode("utf-8")) > max_size_mb * 1024 * 1024:
logger.warning("Content too large after reading")
return None
return content
except Exception as e:
logger.error(f"Error fetching {url}: {e}")
return None
def _validate_python_syntax(self, content: str) -> bool:
"""Validate that content is valid Python code."""
try:
compile(content, "<remote>", "exec")
return True
except SyntaxError as e:
logger.warning(f"Python syntax error: {e}")
return False
async def _load_entity_from_content(self, content: str, source_url: str) -> Any | None:
"""Load entity object from Python content string using disk-based import.
This method caches remote entities to disk and uses importlib for loading,
making it consistent with local entity discovery and avoiding exec() security warnings.
"""
try:
# Create cache directory if it doesn't exist
self._remote_cache_dir.mkdir(parents=True, exist_ok=True)
# Generate a unique filename based on URL hash
url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:16]
module_name = f"remote_entity_{url_hash}"
cached_file = self._remote_cache_dir / f"{module_name}.py"
# Write content to cache file
cached_file.write_text(content, encoding="utf-8")
logger.debug(f"Cached remote entity to {cached_file}")
# Load module from cached file using importlib (same as local scanning)
module = self._load_module_from_file(cached_file, module_name)
if not module:
logger.warning(f"Failed to load module from cached file: {cached_file}")
return None
# Look for agent or workflow objects in the loaded module
for name in dir(module):
if name.startswith("_"):
continue
obj = getattr(module, name)
# Check for explicitly named entities first
if name in ["agent", "workflow"] and self._is_valid_entity(obj, name):
return obj
# Also check if any object looks like an agent/workflow
if self._is_valid_agent(obj) or self._is_valid_workflow(obj):
return obj
return None
except Exception as e:
logger.error(f"Error loading entity from content: {e}")
return None
def remove_remote_entity(self, entity_id: str) -> bool:
"""Remove a remote entity by ID."""
if entity_id in self._entities:
entity_info = self._entities[entity_id]
if entity_info.source in ["remote_gallery", "remote"]:
del self._entities[entity_id]
if entity_id in self._loaded_objects:
del self._loaded_objects[entity_id]
logger.info(f"Removed remote entity: {entity_id}")
return True
logger.warning(f"Cannot remove local entity: {entity_id}")
return False
return False
@@ -71,18 +71,17 @@ class AgentFrameworkExecutor:
def _setup_agent_framework_tracing(self) -> None:
"""Set up Agent Framework's built-in tracing."""
# Configure Agent Framework tracing only if OTLP endpoint is configured
otlp_endpoint = os.environ.get("OTLP_ENDPOINT")
if otlp_endpoint:
# Configure Agent Framework tracing only if ENABLE_OTEL is set
if os.environ.get("ENABLE_OTEL"):
try:
from agent_framework.observability import setup_observability
setup_observability(enable_sensitive_data=True, otlp_endpoint=otlp_endpoint)
logger.info(f"Enabled Agent Framework observability with endpoint: {otlp_endpoint}")
setup_observability(enable_sensitive_data=True)
logger.info("Enabled Agent Framework observability")
except Exception as e:
logger.warning(f"Failed to enable Agent Framework observability: {e}")
else:
logger.debug("No OTLP endpoint configured, skipping observability setup")
logger.debug("ENABLE_OTEL not set, skipping observability setup")
# Thread Management Methods
def create_thread(self, agent_id: str) -> str:
@@ -118,7 +117,6 @@ class AgentFrameworkExecutor:
if thread_id not in self.thread_storage:
return False
# Remove from agent mapping
for _agent_id, thread_ids in self.agent_threads.items():
if thread_id in thread_ids:
thread_ids.remove(thread_id)
@@ -128,7 +126,7 @@ class AgentFrameworkExecutor:
return True
async def get_thread_messages(self, thread_id: str) -> list[dict[str, Any]]:
"""Get messages from a thread's message store, filtering for UI display."""
"""Get messages from a thread's message store, preserving all content types for UI display."""
thread = self.get_thread(thread_id)
if not thread or not thread.message_store:
return []
@@ -142,21 +140,21 @@ class AgentFrameworkExecutor:
# Extract role value (handle enum)
role = af_msg.role.value if hasattr(af_msg.role, "value") else str(af_msg.role)
# Skip tool/function messages - only show user and assistant text
# Skip tool/function messages - only show user and assistant messages
if role not in ["user", "assistant"]:
continue
# Extract user-facing text content only
text_content = self._extract_display_text(af_msg.contents)
# Extract all user-facing content (text, images, files, etc.)
display_contents = self._extract_display_contents(af_msg.contents)
# Skip messages with no displayable text
if not text_content:
# Skip messages with no displayable content
if not display_contents:
continue
ui_message = {
"id": af_msg.message_id or f"restored-{i}",
"role": role,
"contents": [{"type": "text", "text": text_content}],
"contents": display_contents,
"timestamp": __import__("datetime").datetime.now().isoformat(),
"author_name": af_msg.author_name,
"message_id": af_msg.message_id,
@@ -174,14 +172,18 @@ class AgentFrameworkExecutor:
logger.error(traceback.format_exc())
return []
def _extract_display_text(self, contents: list[Any]) -> str:
"""Extract user-facing text from message contents, filtering out internal mechanics."""
text_parts = []
def _extract_display_contents(self, contents: list[Any]) -> list[dict[str, Any]]:
"""Extract all user-facing content (text, images, files, etc.) from message contents.
Filters out internal mechanics like function calls/results while preserving
all content types that should be displayed in the UI.
"""
display_contents = []
for content in contents:
content_type = getattr(content, "type", None)
# Only include text content for display
# Text content
if content_type == "text":
text = getattr(content, "text", "")
@@ -194,15 +196,31 @@ class AgentFrameworkExecutor:
if parsed.get("contents"):
for sub_content in parsed["contents"]:
if sub_content.get("type") == "text":
text_parts.append(sub_content.get("text", ""))
display_contents.append({"type": "text", "text": sub_content.get("text", "")})
except Exception:
text_parts.append(text) # Fallback to raw text
display_contents.append({"type": "text", "text": text})
else:
text_parts.append(text)
display_contents.append({"type": "text", "text": text})
# Data content (images, files, PDFs, etc.)
elif content_type == "data":
display_contents.append({
"type": "data",
"uri": getattr(content, "uri", ""),
"media_type": getattr(content, "media_type", None),
})
# URI content (external links to images/files)
elif content_type == "uri":
display_contents.append({
"type": "uri",
"uri": getattr(content, "uri", ""),
"media_type": getattr(content, "media_type", None),
})
# Skip function_call, function_result, and other internal content types
return " ".join(text_parts).strip()
return display_contents
async def serialize_thread(self, thread_id: str) -> dict[str, Any] | None:
"""Serialize thread state for persistence."""
@@ -380,7 +398,6 @@ class AgentFrameworkExecutor:
else:
logger.warning(f"Thread {thread_id} not found, proceeding without thread")
# Debug logging - handle both string and ChatMessage
if isinstance(user_message, str):
logger.debug(f"Executing agent with text input: {user_message[:100]}...")
else:
@@ -389,19 +406,15 @@ class AgentFrameworkExecutor:
# Use Agent Framework's native streaming with optional thread
if thread:
async for update in agent.run_stream(user_message, thread=thread):
# Yield any pending trace events first
for trace_event in trace_collector.get_pending_events():
yield trace_event
# Then yield the execution update
yield update
else:
async for update in agent.run_stream(user_message):
# Yield any pending trace events first
for trace_event in trace_collector.get_pending_events():
yield trace_event
# Then yield the execution update
yield update
except Exception as e:
@@ -546,6 +559,17 @@ class AgentFrameworkExecutor:
media_type = "application/pdf"
elif filename.lower().endswith((".png", ".jpg", ".jpeg", ".gif")):
media_type = f"image/{filename.split('.')[-1].lower()}"
elif filename.lower().endswith((
".wav",
".mp3",
".m4a",
".ogg",
".flac",
".aac",
)):
ext = filename.split(".")[-1].lower()
# Normalize extensions to match audio MIME types
media_type = "audio/mp4" if ext == "m4a" else f"audio/{ext}"
# Use file_data or file_url
if file_data:
@@ -562,8 +586,17 @@ class AgentFrameworkExecutor:
if not contents:
contents.append(TextContent(text=""))
# Create ChatMessage with user role
return ChatMessage(role=Role.USER, contents=contents)
chat_message = ChatMessage(role=Role.USER, contents=contents)
logger.info(f"Created ChatMessage with {len(contents)} contents:")
for idx, content in enumerate(contents):
content_type = content.__class__.__name__
if hasattr(content, "media_type"):
logger.info(f" [{idx}] {content_type} - media_type: {content.media_type}")
else:
logger.info(f" [{idx}] {content_type}")
return chat_message
def _extract_user_message_fallback(self, input_data: Any) -> str:
"""Fallback method to extract user message as string.
@@ -2,6 +2,7 @@
"""FastAPI server implementation."""
import inspect
import json
import logging
from collections.abc import AsyncGenerator
@@ -19,8 +20,6 @@ from ._mapper import MessageMapper
from .models import AgentFrameworkRequest, OpenAIError
from .models._discovery_models import DiscoveryResponse, EntityInfo
# Removed ExecutionEngine import - using direct executor approach
logger = logging.getLogger(__name__)
@@ -72,7 +71,7 @@ class DevServer:
discovery = self.executor.entity_discovery
for entity in self._pending_entities:
try:
entity_info = await discovery.create_entity_info_from_object(entity)
entity_info = await discovery.create_entity_info_from_object(entity, source="in-memory")
discovery.register_entity(entity_info.id, entity_info, entity)
logger.info(f"Registered in-memory entity: {entity_info.id}")
except Exception as e:
@@ -85,6 +84,33 @@ class DevServer:
return self.executor
async def _cleanup_entities(self) -> None:
"""Cleanup entity resources (close clients, credentials, etc.)."""
if not self.executor:
return
logger.info("Cleaning up entity resources...")
entities = self.executor.entity_discovery.list_entities()
closed_count = 0
for entity_info in entities:
try:
entity_obj = self.executor.entity_discovery.get_entity_object(entity_info.id)
if entity_obj and hasattr(entity_obj, "chat_client"):
client = entity_obj.chat_client
if hasattr(client, "close") and callable(client.close):
if inspect.iscoroutinefunction(client.close):
await client.close()
else:
client.close()
closed_count += 1
logger.debug(f"Closed client for entity: {entity_info.id}")
except Exception as e:
logger.warning(f"Error closing entity {entity_info.id}: {e}")
if closed_count > 0:
logger.info(f"Closed {closed_count} entity client(s)")
def create_app(self) -> FastAPI:
"""Create the FastAPI application."""
@@ -97,6 +123,10 @@ class DevServer:
# Shutdown
logger.info("Shutting down Agent Framework Server")
# Cleanup entity resources (e.g., close credentials, clients)
if self.executor:
await self._cleanup_entities()
app = FastAPI(
title="Agent Framework Server",
description="OpenAI-compatible API server for Agent Framework and other AI frameworks",
@@ -125,7 +155,8 @@ class DevServer:
async def health_check() -> dict[str, Any]:
"""Health check endpoint."""
executor = await self._ensure_executor()
entities = await executor.discover_entities()
# Use list_entities() to avoid re-discovering and re-registering entities
entities = executor.entity_discovery.list_entities()
return {"status": "healthy", "entities_count": len(entities), "framework": "agent_framework"}
@@ -235,11 +266,75 @@ class DevServer:
logger.error(f"Error getting entity info for {entity_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get entity info: {e!s}") from e
@app.post("/v1/entities/add")
async def add_entity(request: dict[str, Any]) -> dict[str, Any]:
"""Add entity from URL."""
try:
url = request.get("url")
metadata = request.get("metadata", {})
if not url:
raise HTTPException(status_code=400, detail="URL is required")
logger.info(f"Attempting to add entity from URL: {url}")
executor = await self._ensure_executor()
entity_info, error_msg = await executor.entity_discovery.fetch_remote_entity(url, metadata)
if not entity_info:
# Sanitize error message - only return safe, user-friendly errors
logger.error(f"Failed to fetch or validate entity from {url}: {error_msg}")
safe_error = error_msg if error_msg else "Failed to fetch or validate entity"
raise HTTPException(status_code=400, detail=safe_error)
logger.info(f"Successfully added entity: {entity_info.id}")
return {"success": True, "entity": entity_info.model_dump()}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error adding entity: {e}", exc_info=True)
# Don't expose internal error details to client
raise HTTPException(
status_code=500, detail="An unexpected error occurred while adding the entity"
) from e
@app.delete("/v1/entities/{entity_id}")
async def remove_entity(entity_id: str) -> dict[str, Any]:
"""Remove entity by ID."""
try:
executor = await self._ensure_executor()
# Cleanup entity resources before removal
try:
entity_obj = executor.entity_discovery.get_entity_object(entity_id)
if entity_obj and hasattr(entity_obj, "chat_client"):
client = entity_obj.chat_client
if hasattr(client, "close") and callable(client.close):
if inspect.iscoroutinefunction(client.close):
await client.close()
else:
client.close()
logger.info(f"Closed client for entity: {entity_id}")
except Exception as e:
logger.warning(f"Error closing entity {entity_id} during removal: {e}")
# Remove entity from registry
success = executor.entity_discovery.remove_remote_entity(entity_id)
if success:
return {"success": True}
raise HTTPException(status_code=404, detail="Entity not found or cannot be removed")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error removing entity {entity_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to remove entity: {e!s}") from e
@app.post("/v1/responses")
async def create_response(request: AgentFrameworkRequest, raw_request: Request) -> Any:
"""OpenAI Responses API endpoint."""
try:
# Debug: log the incoming request
raw_body = await raw_request.body()
logger.info(f"Raw request body: {raw_body.decode()}")
logger.info(f"Parsed request: model={request.model}, extra_body={request.extra_body}")
@@ -385,15 +480,21 @@ class DevServer:
try:
# Direct call to executor - simple and clean
async for event in executor.execute_streaming(request):
if hasattr(event, "to_json") and callable(getattr(event, "to_json", None)):
payload = event.to_json() # type: ignore[attr-defined]
elif hasattr(event, "model_dump_json"):
# IMPORTANT: Check model_dump_json FIRST because to_json() can have newlines (pretty-printing)
# which breaks SSE format. model_dump_json() returns single-line JSON.
if hasattr(event, "model_dump_json"):
payload = event.model_dump_json() # type: ignore[attr-defined]
elif hasattr(event, "to_json") and callable(getattr(event, "to_json", None)):
payload = event.to_json() # type: ignore[attr-defined]
# Strip newlines from pretty-printed JSON for SSE compatibility
payload = payload.replace("\n", "").replace("\r", "")
elif isinstance(event, dict):
# Handle plain dict events (e.g., error events from executor)
payload = json.dumps(event)
elif hasattr(event, "to_dict") and callable(getattr(event, "to_dict", None)):
payload = json.dumps(event.to_dict()) # type: ignore[attr-defined]
else:
if hasattr(event, "to_dict") and callable(getattr(event, "to_dict", None)):
payload = json.dumps(event.to_dict()) # type: ignore[attr-defined]
else:
payload = json.dumps(str(event))
payload = json.dumps(str(event))
yield f"data: {payload}\n\n"
# Send final done event
@@ -402,7 +503,7 @@ class DevServer:
except Exception as e:
logger.error(f"Error in streaming execution: {e}")
error_event = {"id": "error", "object": "error", "error": {"message": str(e), "type": "execution_error"}}
yield f"data: {error_event}\n\n"
yield f"data: {json.dumps(error_event)}\n\n"
def _mount_ui(self, app: FastAPI) -> None:
"""Mount the UI as static files."""
@@ -2,11 +2,22 @@
"""Discovery API models for entity information."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
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."""
@@ -19,6 +30,13 @@ class EntityInfo(BaseModel):
tools: list[str | dict[str, Any]] | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
# Source information
source: str = "directory" # "directory", "in_memory", "remote_gallery"
original_url: str | None = None
# Environment variable requirements
required_env_vars: list[EnvVarRequirement] | None = None
# Workflow-specific fields (populated only for detailed info requests)
executors: list[str] | None = None
workflow_dump: dict[str, Any] | None = None
@@ -6,6 +6,8 @@ These are custom event types that extend beyond the standard OpenAI Responses AP
to support Agent Framework specific features like workflows, traces, and function results.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
@@ -177,7 +179,7 @@ class OpenAIError(BaseModel):
error: dict[str, Any]
@classmethod
def create(cls, message: str, type: str = "invalid_request_error", code: str | None = None) -> "OpenAIError":
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)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Framework Dev UI</title>
<script type="module" crossorigin src="/assets/index-BESRiUNX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BZfe_njJ.css">
<script type="module" crossorigin src="/assets/index-DPEaaIdK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D1AmQWga.css">
</head>
<body>
<div id="root"></div>
+24 -2
View File
@@ -1,6 +1,6 @@
# Testing DevUI - Quick Setup Guide
Hi everyone! Here are the step-by-step instructions to test the new DevUI feature:
Here are the step-by-step instructions to test the new DevUI feature:
## 1. Get the Code
@@ -9,6 +9,8 @@ git pull
git checkout victordibia/devui
```
(or use the latest main branch if merged)
## 2. Setup Environment
Navigate to the Python directory and install dependencies:
@@ -80,9 +82,29 @@ curl -X POST http://localhost:8080/v1/responses \
}'
```
## API Mapping
Messages and events from agents/workflows are mapped to OpenAI response types in `agent_framework_devui/_mapper.py`. See the mapping table below:
| Agent Framework Content | OpenAI Event | Type |
| --------------------------------- | ----------------------------------------- | -------- |
| `TextContent` | `ResponseTextDeltaEvent` | Official |
| `TextReasoningContent` | `ResponseReasoningTextDeltaEvent` | Official |
| `FunctionCallContent` | `ResponseFunctionCallArgumentsDeltaEvent` | Official |
| `FunctionResultContent` | `ResponseFunctionResultComplete` | Custom |
| `ErrorContent` | `ResponseErrorEvent` | Official |
| `UsageContent` | `ResponseUsageEventComplete` | Custom |
| `DataContent` | `ResponseTraceEventComplete` | Custom |
| `UriContent` | `ResponseTraceEventComplete` | Custom |
| `HostedFileContent` | `ResponseTraceEventComplete` | Custom |
| `HostedVectorStoreContent` | `ResponseTraceEventComplete` | Custom |
| `FunctionApprovalRequestContent` | Custom event | Custom |
| `FunctionApprovalResponseContent` | Custom event | Custom |
| `WorkflowEvent` | `ResponseWorkflowEventComplete` | Custom |
## Troubleshooting
- **Missing API key**: Make sure your `.env` file is in the `python/` directory with valid credentials
- **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
@@ -10,6 +10,7 @@
/build
.env.*
claude.md
# misc
.DS_Store
+238 -80
View File
@@ -7,12 +7,14 @@ import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { AppHeader } from "@/components/shared/app-header";
import { DebugPanel } from "@/components/shared/debug-panel";
import { AboutModal } from "@/components/shared/about-modal";
import { SettingsModal } from "@/components/shared/settings-modal";
import { GalleryView } from "@/components/gallery";
import { AgentView } from "@/components/agent/agent-view";
import { WorkflowView } from "@/components/workflow/workflow-view";
import { LoadingState } from "@/components/ui/loading-state";
import { apiClient } from "@/services/api";
import { ChevronLeft } from "lucide-react";
import { ChevronLeft, ChevronDown, ServerOff } from "lucide-react";
import type { SampleEntity } from "@/data/gallery";
import type {
AgentInfo,
WorkflowInfo,
@@ -27,23 +29,23 @@ export default function App() {
isLoading: true,
});
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>(
[]
);
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>([]);
const [debugPanelOpen, setDebugPanelOpen] = useState(true);
const [debugPanelWidth, setDebugPanelWidth] = useState(() => {
// Initialize from localStorage or default to 320
const savedWidth = localStorage.getItem("debugPanelWidth");
return savedWidth ? parseInt(savedWidth, 10) : 320;
});
const [isResizing, setIsResizing] = useState(false);
const [showAboutModal, setShowAboutModal] = useState(false);
const [showGallery, setShowGallery] = useState(false);
const [addingEntityId, setAddingEntityId] = useState<string | null>(null);
const [errorEntityId, setErrorEntityId] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Initialize app - load agents and workflows
useEffect(() => {
const loadData = async () => {
try {
// Load agents and workflows in parallel
const [agents, workflows] = await Promise.all([
apiClient.getAgents(),
apiClient.getWorkflows(),
@@ -135,6 +137,109 @@ export default function App() {
}
}, []);
// Handle adding sample entity
const handleAddSample = useCallback(async (sample: SampleEntity) => {
setAddingEntityId(sample.id);
setErrorEntityId(null);
setErrorMessage(null);
try {
// Call backend to fetch and add entity
const newEntity = await apiClient.addEntity(sample.url, {
source: 'remote_gallery',
originalUrl: sample.url,
sampleId: sample.id
});
// Convert backend entity to frontend format
const convertedEntity = {
id: newEntity.id,
name: newEntity.name,
description: newEntity.description,
type: newEntity.type,
source: (newEntity.source as "directory" | "in_memory" | "remote_gallery") || 'remote_gallery',
has_env: false,
module_path: undefined
};
// Update app state
if (newEntity.type === 'agent') {
const agentEntity = {
...convertedEntity,
tools: (newEntity.tools || []).map(tool =>
typeof tool === 'string' ? tool : JSON.stringify(tool)
)
} as AgentInfo;
setAppState(prev => ({
...prev,
agents: [...prev.agents, agentEntity],
selectedAgent: agentEntity
}));
} else {
const workflowEntity = {
...convertedEntity,
executors: (newEntity.tools || []).map(tool =>
typeof tool === 'string' ? tool : JSON.stringify(tool)
),
input_schema: { type: "string" },
input_type_name: "Input",
start_executor_id: (newEntity.tools && newEntity.tools.length > 0)
? (typeof newEntity.tools[0] === 'string' ? newEntity.tools[0] : JSON.stringify(newEntity.tools[0]))
: "unknown"
} as WorkflowInfo;
setAppState(prev => ({
...prev,
workflows: [...prev.workflows, workflowEntity],
selectedAgent: workflowEntity
}));
}
// Close gallery and clear debug events
setShowGallery(false);
setDebugEvents([]);
} catch (error) {
const errMsg = error instanceof Error ? error.message : 'Failed to add sample entity';
console.error('Failed to add sample entity:', errMsg);
setErrorEntityId(sample.id);
setErrorMessage(errMsg);
} finally {
setAddingEntityId(null);
}
}, []);
const handleClearError = useCallback(() => {
setErrorEntityId(null);
setErrorMessage(null);
}, []);
// Handle removing entity
const handleRemoveEntity = useCallback(async (entityId: string) => {
try {
await apiClient.removeEntity(entityId);
// Update app state
setAppState(prev => ({
...prev,
agents: prev.agents.filter(a => a.id !== entityId),
workflows: prev.workflows.filter(w => w.id !== entityId),
selectedAgent: prev.selectedAgent?.id === entityId
? undefined
: prev.selectedAgent
}));
// Clear debug events if we removed the selected entity
if (appState.selectedAgent?.id === entityId) {
setDebugEvents([]);
}
} catch (error) {
console.error('Failed to remove entity:', error);
}
}, [appState.selectedAgent?.id]);
// Show loading state while initializing
if (appState.isLoading) {
return (
@@ -167,54 +272,77 @@ export default function App() {
workflows={[]}
selectedItem={undefined}
onSelect={() => {}}
onRemove={handleRemoveEntity}
isLoading={false}
onSettingsClick={() => setShowAboutModal(true)}
/>
{/* Error Content */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4 max-w-md">
<div className="text-destructive text-lg font-medium">
Failed to load entities
<div 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">
<ServerOff className="h-12 w-12 text-muted-foreground" />
</div>
</div>
<p className="text-muted-foreground text-sm">{appState.error}</p>
<Button onClick={() => window.location.reload()} variant="outline">
Retry
{/* Heading */}
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-foreground">
Can't Connect to Backend
</h2>
<p className="text-muted-foreground text-base">
No worries! Just start the DevUI backend server and you'll be good to go.
</p>
</div>
{/* Command Instructions */}
<div className="space-y-3">
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-foreground">Start the backend:</p>
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
devui ./agents --port 8080
</code>
<p className="text-xs text-muted-foreground">
Or launch programmatically with <code className="text-xs">serve(entities=[agent])</code>
</p>
</div>
<p className="text-xs text-muted-foreground">
Default: <span className="font-mono">http://localhost:8080</span>
</p>
</div>
{/* Error Details (Collapsible) */}
{appState.error && (
<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">
{appState.error}
</p>
</details>
)}
{/* Retry Button */}
<Button
onClick={() => window.location.reload()}
variant="default"
className="mt-2"
>
Retry Connection
</Button>
</div>
</div>
</div>
);
}
// Show empty state if no agents or workflows are available
if (
!appState.isLoading &&
appState.agents.length === 0 &&
appState.workflows.length === 0
) {
return (
<div className="h-screen flex flex-col bg-background">
<AppHeader
agents={[]}
workflows={[]}
selectedItem={undefined}
onSelect={() => {}}
isLoading={false}
{/* Settings Modal */}
<SettingsModal
open={showAboutModal}
onOpenChange={setShowAboutModal}
/>
{/* Empty State Content */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4 max-w-md">
<div className="text-lg font-medium">No entities configured</div>
<p className="text-muted-foreground text-sm">
No agents or workflows were found in your configuration. Please
check your setup and ensure entities are properly configured.
</p>
<Button onClick={() => window.location.reload()} variant="outline">
Retry
</Button>
</div>
</div>
</div>
);
}
@@ -226,35 +354,63 @@ export default function App() {
workflows={appState.workflows}
selectedItem={appState.selectedAgent}
onSelect={handleEntitySelect}
onRemove={handleRemoveEntity}
onBrowseGallery={() => setShowGallery(true)}
isLoading={appState.isLoading}
onSettingsClick={() => setShowAboutModal(true)}
/>
{/* Main Content - Split Panel */}
{/* Main Content - Split Panel or Gallery */}
<div className="flex flex-1 overflow-hidden">
{/* Left Panel - Main View */}
<div className="flex-1 min-w-0">
{appState.selectedAgent ? (
appState.selectedAgent.type === "agent" ? (
<AgentView
selectedAgent={appState.selectedAgent as AgentInfo}
onDebugEvent={handleDebugEvent}
/>
) : (
<WorkflowView
selectedWorkflow={appState.selectedAgent as WorkflowInfo}
onDebugEvent={handleDebugEvent}
/>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select an agent or workflow to get started.
{showGallery ? (
// Show gallery full screen (w-full ensures it takes entire width)
<div className="flex-1 w-full">
<GalleryView
variant="route"
onAdd={handleAddSample}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={handleClearError}
onClose={() => setShowGallery(false)}
hasExistingEntities={appState.agents.length > 0 || appState.workflows.length > 0}
/>
</div>
) : appState.agents.length === 0 && appState.workflows.length === 0 ? (
// Empty state - show gallery inline (full width, no debug panel)
<GalleryView
variant="inline"
onAdd={handleAddSample}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={handleClearError}
/>
) : (
<>
{/* Left Panel - Main View */}
<div className="flex-1 min-w-0">
{appState.selectedAgent ? (
appState.selectedAgent.type === "agent" ? (
<AgentView
selectedAgent={appState.selectedAgent as AgentInfo}
onDebugEvent={handleDebugEvent}
/>
) : (
<WorkflowView
selectedWorkflow={appState.selectedAgent as WorkflowInfo}
onDebugEvent={handleDebugEvent}
/>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select an agent or workflow to get started.
</div>
)}
</div>
)}
</div>
{/* Resize Handle */}
{debugPanelOpen && (
{/* Resize Handle */}
{debugPanelOpen && (
<div
className={`w-1 cursor-col-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
@@ -288,22 +444,24 @@ export default function App() {
</div>
)}
{/* Right Panel - Debug */}
{debugPanelOpen && (
<div
className="flex-shrink-0"
style={{ width: `${debugPanelWidth}px` }}
>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
/>
</div>
{/* Right Panel - Debug */}
{debugPanelOpen && (
<div
className="flex-shrink-0"
style={{ width: `${debugPanelWidth}px` }}
>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
/>
</div>
)}
</>
)}
</div>
{/* About Modal */}
<AboutModal
{/* Settings Modal */}
<SettingsModal
open={showAboutModal}
onOpenChange={setShowAboutModal}
/>
@@ -5,7 +5,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FileUpload } from "@/components/ui/file-upload";
import {
@@ -21,7 +21,22 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Send, User, Bot, Plus, AlertCircle } from "lucide-react";
import {
SendHorizontal,
User,
Bot,
Plus,
AlertCircle,
Paperclip,
FileText,
ChevronDown,
Package,
FolderOpen,
Database,
Globe,
CheckCircle,
XCircle,
} from "lucide-react";
import { apiClient } from "@/services/api";
import type {
AgentInfo,
@@ -36,7 +51,7 @@ interface ChatState {
isStreaming: boolean;
}
type DebugEventHandler = (event: ExtendedResponseStreamEvent | 'clear') => void;
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
interface AgentViewProps {
selectedAgent: AgentInfo;
@@ -136,10 +151,15 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const [loadingThreads, setLoadingThreads] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const [pasteNotification, setPasteNotification] = useState<string | null>(
null
);
const [detailsExpanded, setDetailsExpanded] = useState(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const accumulatedText = useRef<string>("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
@@ -163,7 +183,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Load messages for the selected thread
try {
const threadMessages = await apiClient.getThreadMessages(mostRecentThread.id);
const threadMessages = await apiClient.getThreadMessages(
mostRecentThread.id
);
setChatState({
messages: threadMessages,
isStreaming: false,
@@ -262,10 +284,137 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
}
};
// Paste handler
const handlePaste = async (e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const files: File[] = [];
let hasProcessedText = false;
const TEXT_THRESHOLD = 8000; // Convert to file if text is larger than this
for (const item of items) {
// Handle pasted images (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);
}
};
// 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";
};
// 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";
};
@@ -304,6 +453,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setCurrentThread(thread);
// Clear debug panel when switching threads
onDebugEvent("clear");
try {
// Load thread messages from backend
const threadMessages = await apiClient.getThreadMessages(threadId);
@@ -354,10 +506,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
} as import("@/types/agent-framework").DataContent);
} else if (contentItem.type === "input_file") {
const dataUri = `data:application/octet-stream;base64,${contentItem.file_data}`;
// Determine media type from filename
const filename = (contentItem as import("@/types/agent-framework").ResponseInputFileParam).filename || "";
let mediaType = "application/octet-stream";
if (filename.endsWith(".pdf")) mediaType = "application/pdf";
else if (filename.endsWith(".txt")) mediaType = "text/plain";
else if (filename.endsWith(".json")) mediaType = "application/json";
else if (filename.endsWith(".csv")) mediaType = "text/csv";
else if (filename.endsWith(".html")) mediaType = "text/html";
else if (filename.endsWith(".md")) mediaType = "text/markdown";
attachmentContents.push({
type: "data",
uri: dataUri,
media_type: "application/pdf", // Should be dynamic based on filename
media_type: mediaType,
} as import("@/types/agent-framework").DataContent);
}
}
@@ -427,7 +590,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
accumulatedText.current = "";
// Clear debug panel events for new agent run
onDebugEvent('clear');
onDebugEvent("clear");
// Use OpenAI-compatible API streaming - direct event handling
const streamGenerator = apiClient.streamAgentExecutionOpenAI(
@@ -576,8 +739,24 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
type: "input_image",
image_url: dataUri,
} as import("@/types/agent-framework").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 all text files (from pasted large text) back to input_text
const text = await attachment.file.text();
content.push({
text: text,
type: "input_text",
} as import("@/types/agent-framework").ResponseInputTextParam);
} else {
// EXACT OpenAI ResponseInputFileParam (but we need to handle the required fields)
// EXACT OpenAI ResponseInputFileParam for other files
const base64Data = dataUri.split(",")[1]; // Extract base64 part
content.push({
type: "input_file",
@@ -641,22 +820,36 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
{/* Header */}
<div className="border-b pb-2 p-4 flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-sm">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Chat with {selectedAgent.name || selectedAgent.id}
</div>
</h2>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3">
<div className="flex items-center gap-2 min-w-0">
<h2 className="font-semibold text-sm truncate">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 flex-shrink-0" />
<span className="truncate">Chat with {selectedAgent.name || selectedAgent.id}</span>
</div>
</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsExpanded(!detailsExpanded)}
className="h-6 w-6 p-0 flex-shrink-0"
>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
detailsExpanded ? "rotate-180" : ""
}`}
/>
</Button>
</div>
{/* Thread Controls */}
<div className="flex items-center gap-2">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-shrink-0">
<Select
value={currentThread?.id || ""}
onValueChange={handleThreadSelect}
disabled={loadingThreads || isSubmitting}
>
<SelectTrigger className="w-48">
<SelectTrigger className="w-full sm:w-48">
<SelectValue
placeholder={
loadingThreads
@@ -690,6 +883,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
size="lg"
onClick={handleNewThread}
disabled={!selectedAgent || isSubmitting}
className="whitespace-nowrap"
>
<Plus className="h-4 w-4 mr-2" />
New Thread
@@ -702,6 +896,68 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
{selectedAgent.description}
</p>
)}
{/* Collapsible Details Section */}
<div
className={`overflow-hidden transition-all duration-200 ease-in-out ${
detailsExpanded ? "max-h-40 mt-3" : "max-h-0"
}`}
>
<div className="space-y-2 text-xs">
{/* Tools */}
<div className="flex items-center gap-2">
<Package className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Tools:</span>
<span className="font-mono">
{selectedAgent.tools.length > 0
? selectedAgent.tools.join(", ")
: "No tools"}
</span>
<span className="text-muted-foreground">
({selectedAgent.tools.length})
</span>
</div>
{/* Source */}
<div className="flex items-center gap-2">
{selectedAgent.source === "directory" ? (
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
) : selectedAgent.source === "in_memory" ? (
<Database className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">Source:</span>
<span>
{selectedAgent.source === "directory"
? "Local"
: selectedAgent.source === "in_memory"
? "In-Memory"
: "Gallery"}
</span>
{selectedAgent.module_path && (
<span className="text-muted-foreground font-mono text-[11px]">
({selectedAgent.module_path})
</span>
)}
</div>
{/* Environment */}
<div className="flex items-center gap-2">
{selectedAgent.has_env ? (
<XCircle className="h-3.5 w-3.5 text-orange-500" />
) : (
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
)}
<span className="text-muted-foreground">Environment:</span>
<span>
{selectedAgent.has_env
? "Requires environment variables"
: "No environment variables required"}
</span>
</div>
</div>
</div>
</div>
{/* Messages */}
@@ -764,16 +1020,43 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
</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">
<Input
<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={`Message ${
selectedAgent.name || selectedAgent.id
}...`}
}... (Shift+Enter for new line)`}
disabled={isSubmitting || chatState.isStreaming}
className="flex-1"
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
style={{ fieldSizing: "content" } as React.CSSProperties}
/>
<FileUpload
onFilesSelected={handleFilesSelected}
@@ -783,12 +1066,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
type="submit"
size="icon"
disabled={!canSendMessage}
className="shrink-0"
className="shrink-0 h-10"
>
{isSubmitting ? (
<LoadingSpinner size="sm" />
) : (
<Send className="h-4 w-4" />
<SendHorizontal className="h-4 w-4" />
)}
</Button>
</form>
@@ -0,0 +1,412 @@
/**
* 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,
Plus,
Loader2,
User,
TriangleAlert,
AlertCircle,
X,
Key,
ChevronDown,
ArrowLeft,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
SAMPLE_ENTITIES,
type SampleEntity,
getDifficultyColor,
} from "@/data/gallery";
interface GalleryViewProps {
onAdd: (sample: SampleEntity) => Promise<void>;
addingEntityId?: string | null;
errorEntityId?: string | null;
errorMessage?: string | null;
onClearError?: (sampleId: string) => void;
onClose?: () => void;
variant?: "inline" | "route" | "modal";
hasExistingEntities?: boolean;
}
// Internal: Sample Entity Card Component
function SampleEntityCard({
sample,
onAdd,
isAdding = false,
hasError = false,
errorMessage,
onClearError,
}: {
sample: SampleEntity;
onAdd: (sample: SampleEntity) => Promise<void>;
isAdding?: boolean;
hasError?: boolean;
errorMessage?: string | null;
onClearError?: (sampleId: string) => void;
}) {
const [isLoading, setIsLoading] = useState(false);
const handleAdd = async () => {
if (isLoading || isAdding) return;
setIsLoading(true);
try {
await onAdd(sample);
} finally {
setIsLoading(false);
}
};
const TypeIcon = sample.type === "workflow" ? Workflow : Bot;
const isDisabled = isLoading || isAdding;
return (
<Card
className={cn(
"hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full",
hasError && "border-destructive"
)}
>
<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">
{/* Error Banner */}
{hasError && errorMessage && (
<div className="mb-3 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-xs text-destructive font-medium mb-1">
Failed to add
</p>
<p className="text-xs text-muted-foreground">{errorMessage}</p>
</div>
{onClearError && (
<button
onClick={() => onClearError(sample.id)}
className="text-muted-foreground hover:text-foreground"
aria-label="Dismiss error"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
)}
<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>
{/* Add Button - Full width on its own line */}
<Button
onClick={handleAdd}
disabled={isDisabled}
className="w-full"
size="sm"
variant={hasError ? "outline" : "default"}
>
{isDisabled ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Adding...
</>
) : hasError ? (
<>
<Plus className="h-4 w-4 mr-2" />
Retry
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Add Sample
</>
)}
</Button>
</CardFooter>
</Card>
);
}
// Internal: Sample Entity Grid Component
function SampleEntityGrid({
samples,
onAdd,
addingEntityId,
errorEntityId,
errorMessage,
onClearError,
}: {
samples: SampleEntity[];
onAdd: (sample: SampleEntity) => Promise<void>;
addingEntityId?: string | null;
errorEntityId?: string | null;
errorMessage?: string | null;
onClearError?: (sampleId: string) => void;
}) {
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}
onAdd={onAdd}
isAdding={addingEntityId === sample.id}
hasError={errorEntityId === sample.id}
errorMessage={errorMessage}
onClearError={onClearError}
/>
</div>
))}
</div>
);
}
// Main: Gallery View Component
export function GalleryView({
onAdd,
addingEntityId,
errorEntityId,
errorMessage,
onClearError,
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">
You can also import any of the sample agents and workflows
below 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}
onAdd={onAdd}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={onClearError}
/>
</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 and add sample agents and workflows to learn the Agent
Framework. These are curated examples ranging from beginner to
advanced.
</p>
</div>
</div>
{/* Sample Gallery */}
<SampleEntityGrid
samples={SAMPLE_ENTITIES}
onAdd={onAdd}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={onClearError}
/>
{/* 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}
onAdd={onAdd}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={onClearError}
/>
);
}
@@ -0,0 +1,5 @@
/**
* Gallery component exports
*/
export { GalleryView } from './gallery-view';
@@ -3,7 +3,15 @@
*/
import { useState } from "react";
import { Download, FileText, AlertCircle, Code } from "lucide-react";
import {
Download,
FileText,
AlertCircle,
Code,
ChevronDown,
ChevronUp,
Music,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import type { RenderProps } from "./types";
import {
@@ -13,14 +21,52 @@ import {
} from "@/types/agent-framework";
function TextContentRenderer({ content, isStreaming, className }: RenderProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (!isTextContent(content)) return null;
const text = content.text;
const TRUNCATE_LENGTH = 1600;
const shouldTruncate = text.length > TRUNCATE_LENGTH && !isStreaming;
const displayText =
shouldTruncate && !isExpanded
? text.slice(0, TRUNCATE_LENGTH) + "..."
: text;
return (
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
{content.text}
<div
className={
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
}
>
{displayText}
</div>
{isStreaming && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
{shouldTruncate && (
<div className="flex justify-end mt-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="inline-flex items-center gap-1 text-xs
bg-background/80 hover:bg-background border border-border/50 hover:border-border
text-muted-foreground hover:text-foreground
transition-colors cursor-pointer px-2 py-1 rounded"
>
{isExpanded ? (
<>
less <ChevronUp className="h-3 w-3" />
</>
) : (
<>
{(text.length - TRUNCATE_LENGTH).toLocaleString()} more{" "}
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
</div>
)}
</div>
);
}
@@ -38,6 +84,7 @@ function DataContentRenderer({ content, className }: RenderProps) {
const isImage = mediaType.startsWith("image/");
const isPdf = mediaType === "application/pdf";
const isAudio = mediaType.startsWith("audio/");
if (isImage && !imageError) {
return (
@@ -58,7 +105,23 @@ function DataContentRenderer({ content, className }: RenderProps) {
);
}
// Fallback for non-images or failed images
if (isAudio) {
return (
<div className={`my-2 p-3 border rounded-lg bg-purple-50 dark:bg-purple-950/20 ${className || ""}`}>
<div className="flex items-center gap-2 mb-2">
<Music className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium text-purple-800 dark:text-purple-300">Audio File</span>
<span className="text-xs text-muted-foreground">({mediaType})</span>
</div>
<audio controls className="w-full max-w-md">
<source src={dataUri} type={mediaType} />
Your browser does not support the audio element.
</audio>
</div>
);
}
// Fallback for non-images/non-audio or failed images
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center gap-2">
@@ -115,9 +178,7 @@ function FunctionCallRenderer({ content, className }: RenderProps) {
<span className="text-sm font-medium text-blue-800">
Function Call: {content.name}
</span>
<span className="text-xs text-blue-600">
{isExpanded ? "▼" : "▶"}
</span>
<span className="text-xs text-blue-600">{isExpanded ? "▼" : "▶"}</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
@@ -137,7 +198,9 @@ function FunctionResultRenderer({ content, className }: RenderProps) {
if (!isFunctionResultContent(content)) return null;
return (
<div className={`my-2 p-3 border rounded-lg bg-green-50 ${className || ""}`}>
<div
className={`my-2 p-3 border rounded-lg bg-green-50 ${className || ""}`}
>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -146,9 +209,7 @@ function FunctionResultRenderer({ content, className }: RenderProps) {
<span className="text-sm font-medium text-green-800">
Function Result
</span>
<span className="text-xs text-green-600">
{isExpanded ? "▼" : "▶"}
</span>
<span className="text-xs text-green-600">{isExpanded ? "▼" : "▶"}</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
@@ -230,7 +291,11 @@ function UriContentRenderer({ content, className }: RenderProps) {
);
}
export function ContentRenderer({ content, isStreaming, className }: RenderProps) {
export function ContentRenderer({
content,
isStreaming,
className,
}: RenderProps) {
switch (content.type) {
case "text":
return (
@@ -245,19 +310,17 @@ export function ContentRenderer({ content, isStreaming, className }: RenderProps
case "uri":
return <UriContentRenderer content={content} className={className} />;
case "function_call":
return (
<FunctionCallRenderer content={content} className={className} />
);
return <FunctionCallRenderer content={content} className={className} />;
case "function_result":
return (
<FunctionResultRenderer content={content} className={className} />
);
return <FunctionResultRenderer content={content} className={className} />;
case "error":
return <ErrorContentRenderer content={content} className={className} />;
default:
// Fallback for unsupported content types
return (
<div className={`my-2 p-2 bg-gray-100 rounded text-xs ${className || ""}`}>
<div
className={`my-2 p-2 bg-gray-100 rounded text-xs ${className || ""}`}
>
<div>Unsupported content type: {content.type}</div>
<pre className="mt-1 text-xs whitespace-pre-wrap">
{JSON.stringify(content, null, 2)}
@@ -265,4 +328,4 @@ export function ContentRenderer({ content, isStreaming, className }: RenderProps
</div>
);
}
}
}
@@ -21,12 +21,13 @@ export function AboutModal({ open, onOpenChange }: AboutModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogHeader className="p-6 pb-4">
<DialogTitle>About DevUI</DialogTitle>
<DialogClose onClose={() => onOpenChange(false)} />
</DialogHeader>
<div className="p-4 space-y-4">
<DialogClose onClose={() => onOpenChange(false)} />
<div className="px-6 pb-6 space-y-4">
<p className="text-sm text-muted-foreground">
DevUI is a sample app for getting started with Agent Framework.
</p>
@@ -14,6 +14,8 @@ interface AppHeaderProps {
workflows: WorkflowInfo[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onRemove?: (entityId: string) => void;
onBrowseGallery?: () => void;
isLoading?: boolean;
onSettingsClick?: () => void;
}
@@ -23,6 +25,8 @@ export function AppHeader({
workflows,
selectedItem,
onSelect,
onRemove,
onBrowseGallery,
isLoading = false,
onSettingsClick,
}: AppHeaderProps) {
@@ -34,6 +38,8 @@ export function AppHeader({
workflows={workflows}
selectedItem={selectedItem}
onSelect={onSelect}
onRemove={onRemove}
onBrowseGallery={onBrowseGallery}
isLoading={isLoading}
/>
@@ -973,6 +973,10 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
<span className="font-mono bg-accent/10 px-1 rounded">
ENABLE_OTEL=true
</span>{" "}
or restart devui with the tracing flag{" "}
<div className="font-mono bg-accent/10 px-1 rounded">
devui --enable-tracing
</div>
to enable tracing.
</div>
)}
@@ -15,7 +15,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ChevronDown, Bot, Workflow, FolderOpen, Database } from "lucide-react";
import { ChevronDown, Bot, Workflow, FolderOpen, Database, Globe, X, Plus } from "lucide-react";
import type { AgentInfo, WorkflowInfo } from "@/types";
interface EntitySelectorProps {
@@ -23,6 +23,8 @@ interface EntitySelectorProps {
workflows: WorkflowInfo[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onRemove?: (entityId: string) => void;
onBrowseGallery?: () => void;
isLoading?: boolean;
}
@@ -30,8 +32,22 @@ const getTypeIcon = (type: "agent" | "workflow") => {
return type === "workflow" ? Workflow : Bot;
};
const getSourceIcon = (source: "directory" | "in_memory") => {
return source === "directory" ? FolderOpen : Database;
const getSourceIcon = (source: "directory" | "in_memory" | "remote_gallery") => {
switch (source) {
case "directory": return FolderOpen;
case "in_memory": return Database;
case "remote_gallery": return Globe;
default: return Database;
}
};
const getSourceLabel = (source: "directory" | "in_memory" | "remote_gallery") => {
switch (source) {
case "directory": return "Local";
case "in_memory": return "Memory";
case "remote_gallery": return "Gallery";
default: return "Unknown";
}
};
export function EntitySelector({
@@ -39,6 +55,8 @@ export function EntitySelector({
workflows,
selectedItem,
onSelect,
onRemove,
onBrowseGallery,
isLoading = false,
}: EntitySelectorProps) {
const [open, setOpen] = useState(false);
@@ -53,7 +71,7 @@ export function EntitySelector({
};
const TypeIcon = selectedItem ? getTypeIcon(selectedItem.type) : Bot;
const displayName = selectedItem?.name || selectedItem?.id || "Select Entity";
const displayName = selectedItem?.name || selectedItem?.id || "Select Agent or Workflow";
const itemCount =
selectedItem?.type === "workflow"
? (selectedItem as WorkflowInfo).executors?.length || 0
@@ -102,11 +120,13 @@ export function EntitySelector({
return (
<DropdownMenuItem
key={agent.id}
onClick={() => handleSelect(agent)}
className="cursor-pointer"
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 min-w-0">
<div
className="flex items-center gap-2 min-w-0 flex-1"
onClick={() => handleSelect(agent)}
>
<Bot className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium">
@@ -122,8 +142,26 @@ export function EntitySelector({
<div className="flex items-center gap-1 flex-shrink-0">
<SourceIcon className="h-3 w-3 opacity-60" />
<Badge variant="outline" className="text-xs">
{getSourceLabel(agent.source)}
</Badge>
<Badge variant="outline" className="text-xs ml-1">
{agent.tools.length}
</Badge>
{/* Remove button for gallery entities */}
{agent.source === 'remote_gallery' && onRemove && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 ml-1"
onClick={(e) => {
e.stopPropagation();
onRemove(agent.id);
}}
>
<X className="h-3 w-3 text-destructive" />
</Button>
)}
</div>
</div>
</DropdownMenuItem>
@@ -144,11 +182,13 @@ export function EntitySelector({
return (
<DropdownMenuItem
key={workflow.id}
onClick={() => handleSelect(workflow)}
className="cursor-pointer"
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 min-w-0">
<div
className="flex items-center gap-2 min-w-0 flex-1"
onClick={() => handleSelect(workflow)}
>
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium">
@@ -164,8 +204,26 @@ export function EntitySelector({
<div className="flex items-center gap-1 flex-shrink-0">
<SourceIcon className="h-3 w-3 opacity-60" />
<Badge variant="outline" className="text-xs">
{getSourceLabel(workflow.source)}
</Badge>
<Badge variant="outline" className="text-xs ml-1">
{workflow.executors.length}
</Badge>
{/* Remove button for gallery entities */}
{workflow.source === 'remote_gallery' && onRemove && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 ml-1"
onClick={(e) => {
e.stopPropagation();
onRemove(workflow.id);
}}
>
<X className="h-3 w-3 text-destructive" />
</Button>
)}
</div>
</div>
</DropdownMenuItem>
@@ -177,10 +235,23 @@ export function EntitySelector({
{allItems.length === 0 && (
<DropdownMenuItem disabled>
<div className="text-center text-muted-foreground py-2">
{isLoading ? "Loading entities..." : "No entities found"}
{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,195 @@
/**
* 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 { ExternalLink, RotateCcw } from "lucide-react";
interface SettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onBackendUrlChange?: (url: string) => void;
}
type Tab = "about" | "settings";
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("about");
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";
const [backendUrl, setBackendUrl] = useState(() => {
return localStorage.getItem("devui_backend_url") || defaultUrl;
});
const [tempUrl, setTempUrl] = useState(backendUrl);
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 isModified = tempUrl !== backendUrl;
const isDefault = !localStorage.getItem("devui_backend_url");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[600px] max-w-[90vw]">
<DialogHeader className="p-6 pb-2">
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<DialogClose onClose={() => onOpenChange(false)} />
{/* Tabs */}
<div className="flex border-b px-6">
<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>
<button
onClick={() => setActiveTab("settings")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "settings"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Settings
{activeTab === "settings" && (
<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-[240px]">
{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="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>
)}
{activeTab === "settings" && (
<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>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -3,13 +3,13 @@
*/
import { useState } from "react";
import { FileText, Image, Trash2 } from "lucide-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" | "other";
type: "image" | "pdf" | "audio" | "other";
}
interface AttachmentGalleryProps {
@@ -69,6 +69,14 @@ function AttachmentPreview({ attachment, onRemove }: AttachmentPreviewProps) {
</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">
@@ -15,10 +15,17 @@ interface DialogContentProps {
interface DialogHeaderProps {
children: React.ReactNode;
className?: string;
}
interface DialogTitleProps {
children: React.ReactNode;
className?: string;
}
interface DialogDescriptionProps {
children: React.ReactNode;
className?: string;
}
interface DialogFooterProps {
@@ -46,30 +53,43 @@ 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-w-lg w-full max-h-[90vh] overflow-hidden ${className}`}
className={`relative bg-background border rounded-lg shadow-lg max-h-[90vh] overflow-hidden ${defaultWidthClasses} ${className}`}
>
{children}
</div>
);
}
export function DialogHeader({ children }: DialogHeaderProps) {
export function DialogHeader({ children, className = "" }: DialogHeaderProps) {
return (
<div className="flex items-center justify-between p-4 border-b">
<div className={`space-y-2 ${className}`}>
{children}
</div>
);
}
export function DialogTitle({ children }: DialogTitleProps) {
return <h2 className="text-lg font-semibold">{children}</h2>;
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="h-6 w-6 p-0">
<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>
);
@@ -17,7 +17,7 @@ interface FileUploadProps {
export function FileUpload({
onFilesSelected,
accept = "image/*,.pdf",
accept = "image/*,.pdf,audio/*,.wav,.mp3,.m4a,.ogg",
multiple = true,
maxSize = 50 * 1024 * 1024, // 50MB default for local dev tool
disabled = false,
@@ -105,7 +105,7 @@ export function FileUpload({
onDrop={handleDrop}
onDragOver={handleDragOver}
className="shrink-0 transition-colors hover:bg-muted"
title="Upload files (images, PDFs)"
title="Upload files (images, PDFs, audio)"
>
<Upload className="h-4 w-4" />
</Button>
@@ -1,13 +1,8 @@
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
CheckCircle,
XCircle,
Clock,
Loader2,
AlertCircle,
Play,
Flag,
Workflow,
Home,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -29,6 +24,7 @@ export interface ExecutorNodeData extends Record<string, unknown> {
isSelected?: boolean;
isStartNode?: boolean;
isEndNode?: boolean;
layoutDirection?: "LR" | "TB";
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
}
@@ -36,54 +32,34 @@ const getExecutorStateConfig = (state: ExecutorState) => {
switch (state) {
case "running":
return {
icon: Loader2,
text: "Running",
borderColor: "border-blue-500 dark:border-blue-400",
iconColor: "text-blue-600 dark:text-blue-400",
statusColor: "bg-blue-500 dark:bg-blue-400",
animate: "animate-spin",
glow: "shadow-lg shadow-blue-500/20",
borderColor: "border-[#643FB2] dark:border-[#8B5CF6]",
glow: "shadow-lg shadow-[#643FB2]/20",
badgeColor: "bg-[#643FB2] dark:bg-[#8B5CF6]",
};
case "completed":
return {
icon: CheckCircle,
text: "Completed",
borderColor: "border-green-500 dark:border-green-400",
iconColor: "text-green-600 dark:text-green-400",
statusColor: "bg-green-500 dark:bg-green-400",
animate: "",
glow: "shadow-lg shadow-green-500/20",
badgeColor: "bg-green-500 dark:bg-green-400",
};
case "failed":
return {
icon: XCircle,
text: "Failed",
borderColor: "border-red-500 dark:border-red-400",
iconColor: "text-red-600 dark:text-red-400",
statusColor: "bg-red-500 dark:bg-red-400",
animate: "",
glow: "shadow-lg shadow-red-500/20",
badgeColor: "bg-red-500 dark:bg-red-400",
};
case "cancelled":
return {
icon: AlertCircle,
text: "Cancelled",
borderColor: "border-orange-500 dark:border-orange-400",
iconColor: "text-orange-600 dark:text-orange-400",
statusColor: "bg-orange-500 dark:bg-orange-400",
animate: "",
glow: "shadow-lg shadow-orange-500/20",
badgeColor: "bg-orange-500 dark:bg-orange-400",
};
case "pending":
default:
return {
icon: Clock,
text: "Pending",
borderColor: "border-gray-300 dark:border-gray-600",
iconColor: "text-gray-500 dark:text-gray-400",
statusColor: "bg-gray-400 dark:bg-gray-500",
animate: "",
glow: "",
badgeColor: "bg-gray-400 dark:bg-gray-500",
};
}
};
@@ -91,11 +67,15 @@ const getExecutorStateConfig = (state: ExecutorState) => {
export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const nodeData = data as ExecutorNodeData;
const config = getExecutorStateConfig(nodeData.state);
const IconComponent = config.icon;
const hasData = nodeData.inputData || nodeData.outputData || nodeData.error;
const isRunning = nodeData.state === "running";
// 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 safely render data with full details
const renderDataDetails = () => {
const details = [];
@@ -175,89 +155,67 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
isRunning ? config.glow : "shadow-sm",
)}
>
{/* Start/End Badge */}
{(nodeData.isStartNode || nodeData.isEndNode) && (
<div className={cn(
"absolute -top-6 left-2 px-2 py-1 rounded-t text-xs font-medium text-white flex items-center gap-1 z-10 shadow-sm",
nodeData.isStartNode ? "bg-green-600" : "bg-red-600"
)}>
{nodeData.isStartNode ? (
<>
<Play className="w-3 h-3" />
START
</>
) : (
<>
<Flag className="w-3 h-3" />
END
</>
)}
</div>
)}
{/* Only show target handle if not a start node */}
{/* Small circular handles */}
{!nodeData.isStartNode && (
<Handle
type="target"
position={Position.Left}
className="!w-2 !h-5 !rounded-r-sm !-ml-1 !border-0 transition-colors"
position={targetPosition}
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#3b82f6" :
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#9ca3af"
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
{/* Only show source handle if not an end node */}
{!nodeData.isEndNode && (
<Handle
type="source"
position={Position.Right}
className="!w-2 !h-5 !rounded-l-sm !-mr-1 !border-0 transition-colors"
position={sourcePosition}
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#3b82f6" :
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#9ca3af"
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
<div className="p-4">
<div className="p-3">
{/* Header with icon and title */}
<div className="flex items-start gap-3 mb-3">
<div className="flex-shrink-0 mt-0.5">
<IconComponent
className={cn("w-5 h-5", config.iconColor, config.animate)}
/>
<div 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>
{/* Small status badge for running state */}
{isRunning && (
<div className={cn(
"absolute -top-1 -right-1 w-3 h-3 rounded-full animate-pulse",
config.badgeColor
)} />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{nodeData.name || nodeData.executorId}
</h3>
{nodeData.executorType && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{nodeData.executorType}
</p>
)}
</div>
</div>
{/* State indicator */}
<div className="flex items-center gap-2 mb-2">
<div
className={cn(
"w-2 h-2 rounded-full",
config.statusColor,
config.animate
)}
/>
<span className={cn("text-xs font-medium", config.iconColor)}>
{config.text}
</span>
</div>
{/* Data details */}
{hasData && (
<div className="mt-3">
@@ -267,7 +225,7 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
{/* Running animation overlay */}
{isRunning && (
<div className="absolute inset-0 rounded border-2 border-blue-500/30 dark:border-blue-400/30 animate-pulse pointer-events-none" />
<div className="absolute inset-0 rounded border-2 border-[#643FB2]/30 dark:border-[#8B5CF6]/30 animate-pulse pointer-events-none" />
)}
</div>
</div>
@@ -7,6 +7,7 @@ import {
Maximize,
Shuffle,
Zap,
ArrowDown,
} from "lucide-react";
import {
DropdownMenu,
@@ -53,11 +54,15 @@ function ViewOptionsPanel({
onNodeSelect,
viewOptions,
onToggleViewOption,
layoutDirection,
onLayoutDirectionChange,
}: {
workflowDump?: Workflow;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
layoutDirection: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
}) {
const { fitView, setViewport, setNodes } = useReactFlow();
@@ -71,9 +76,17 @@ function ViewOptionsPanel({
const handleAutoArrange = () => {
if (!workflowDump) return;
const currentNodes = convertWorkflowDumpToNodes(workflowDump, onNodeSelect);
const currentNodes = convertWorkflowDumpToNodes(
workflowDump,
onNodeSelect,
layoutDirection
);
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
const layoutedNodes = applyDagreLayout(currentNodes, currentEdges, "LR");
const layoutedNodes = applyDagreLayout(
currentNodes,
currentEdges,
layoutDirection
);
setNodes(layoutedNodes);
};
@@ -122,6 +135,35 @@ function ViewOptionsPanel({
<Checkbox checked={viewOptions.animateRun} 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
@@ -154,6 +196,8 @@ interface WorkflowFlowProps {
onToggleViewOption?: (
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
) => void;
layoutDirection?: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
}
// Animation handler component that runs inside ReactFlow context
@@ -212,6 +256,8 @@ export function WorkflowFlow({
className = "",
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
onToggleViewOption,
layoutDirection = "LR",
onLayoutDirectionChange,
}: WorkflowFlowProps) {
// Create initial nodes and edges from workflow dump
const { initialNodes, initialEdges } = useMemo(() => {
@@ -219,18 +265,24 @@ export function WorkflowFlow({
return { initialNodes: [], initialEdges: [] };
}
const nodes = convertWorkflowDumpToNodes(workflowDump, onNodeSelect);
const nodes = convertWorkflowDumpToNodes(
workflowDump,
onNodeSelect,
layoutDirection
);
const edges = convertWorkflowDumpToEdges(workflowDump);
// Apply auto-layout if we have nodes and edges
const layoutedNodes =
nodes.length > 0 ? applyDagreLayout(nodes, edges, "LR") : nodes;
nodes.length > 0
? applyDagreLayout(nodes, edges, layoutDirection)
: nodes;
return {
initialNodes: layoutedNodes,
initialEdges: edges,
};
}, [workflowDump, onNodeSelect]);
}, [workflowDump, onNodeSelect, layoutDirection]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<ExecutorNodeData>>(initialNodes);
@@ -388,7 +440,7 @@ export function WorkflowFlow({
const state = data?.state;
switch (state) {
case "running":
return "#3b82f6";
return "#643FB2";
case "completed":
return "#10b981";
case "failed":
@@ -420,6 +472,8 @@ export function WorkflowFlow({
onNodeSelect={onNodeSelect}
viewOptions={viewOptions}
onToggleViewOption={onToggleViewOption}
layoutDirection={layoutDirection}
onLayoutDirectionChange={onLayoutDirectionChange}
/>
</ReactFlow>
@@ -12,6 +12,12 @@ import {
Settings,
RotateCcw,
ChevronDown,
Package,
FolderOpen,
Database,
Globe,
XCircle,
Workflow as WorkflowIcon,
} from "lucide-react";
import { LoadingState } from "@/components/ui/loading-state";
import { WorkflowInputForm } from "@/components/workflow/workflow-input-form";
@@ -197,7 +203,7 @@ function RunWorkflowButton({
{/* Modal with proper Dialog component - matching WorkflowInputForm structure */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="w-full max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogHeader className="px-8 pt-6">
<DialogTitle>Configure Workflow Inputs</DialogTitle>
<DialogClose onClose={() => setShowModal(false)} />
</DialogHeader>
@@ -267,6 +273,7 @@ export function WorkflowView({
const [workflowResult, setWorkflowResult] = useState<string>("");
const [workflowError, setWorkflowError] = useState<string>("");
const accumulatedText = useRef<string>("");
const [detailsExpanded, setDetailsExpanded] = useState(false);
// Panel resize state
const [bottomPanelHeight, setBottomPanelHeight] = useState(() => {
@@ -287,6 +294,12 @@ export function WorkflowView({
};
});
// Layout direction state
const [layoutDirection, setLayoutDirection] = useState<"LR" | "TB">(() => {
const saved = localStorage.getItem("workflowLayoutDirection");
return (saved as "LR" | "TB") || "LR";
});
const { selectExecutor, getExecutorData } = useWorkflowEventCorrelation(
openAIEvents,
isStreaming
@@ -297,6 +310,11 @@ export function WorkflowView({
localStorage.setItem("workflowViewOptions", JSON.stringify(viewOptions));
}, [viewOptions]);
// Save layout direction to localStorage
useEffect(() => {
localStorage.setItem("workflowLayoutDirection", layoutDirection);
}, [layoutDirection]);
// View option handlers
const toggleViewOption = (key: keyof typeof viewOptions) => {
setViewOptions((prev: typeof viewOptions) => ({
@@ -467,7 +485,11 @@ export function WorkflowView({
event_type?: string;
data?: unknown;
};
if (data.event_type === "WorkflowCompletedEvent" && data.data) {
if (
(data.event_type === "WorkflowCompletedEvent" ||
data.event_type === "WorkflowOutputEvent") &&
data.data
) {
setWorkflowResult(String(data.data));
}
}
@@ -517,55 +539,149 @@ export function WorkflowView({
return (
<div className="workflow-view flex flex-col h-full">
{/* Top Panel - Workflow Visualization */}
<div className="flex-1 min-h-0 p-4">
{/* Workflow Diagram Section */}
{workflowInfo?.workflow_dump && (
<div className="border border-border rounded bg-card shadow-sm h-full flex flex-col">
<div className="border-b border-border px-4 py-3 bg-muted rounded-t flex-shrink-0">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">
Workflow Visualization
</h3>
{/* Smart Run Workflow CTA - Show for all states */}
{workflowInfo && (
<div className="flex items-center gap-3">
<RunWorkflowButton
inputSchema={workflowInfo.input_schema}
onRun={handleSendWorkflowData}
isSubmitting={isStreaming}
workflowState={
isStreaming
? "running"
: workflowError
? "error"
: executorHistory.length > 0
? "completed"
: "ready"
}
executorHistory={executorHistory}
workflowError={workflowError}
/>
</div>
)}
{/* Status is now handled by the RunWorkflowButton component */}
{/* Header */}
<div className="border-b pb-2 p-4 flex-shrink-0">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3">
<div className="flex items-center gap-2 min-w-0">
<h2 className="font-semibold text-sm truncate">
<div className="flex items-center gap-2">
<WorkflowIcon className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{selectedWorkflow.name || selectedWorkflow.id}
</span>
</div>
</div>
<div className="flex-1 min-h-0">
<WorkflowFlow
workflowDump={workflowInfo.workflow_dump}
events={openAIEvents}
isStreaming={isStreaming}
onNodeSelect={handleNodeSelect}
className="h-full"
viewOptions={viewOptions}
onToggleViewOption={toggleViewOption}
</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsExpanded(!detailsExpanded)}
className="h-6 w-6 p-0 flex-shrink-0"
>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
detailsExpanded ? "rotate-180" : ""
}`}
/>
</Button>
</div>
{/* Run Workflow Controls */}
{workflowInfo && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-shrink-0">
<RunWorkflowButton
inputSchema={workflowInfo.input_schema}
onRun={handleSendWorkflowData}
isSubmitting={isStreaming}
workflowState={
isStreaming
? "running"
: workflowError
? "error"
: executorHistory.length > 0
? "completed"
: "ready"
}
executorHistory={executorHistory}
workflowError={workflowError}
/>
</div>
)}
</div>
{selectedWorkflow.description && (
<p className="text-sm text-muted-foreground">
{selectedWorkflow.description}
</p>
)}
{/* Executors - Always visible */}
{selectedWorkflow.executors.length > 0 && (
<div className="flex items-center gap-2 text-xs mt-2 mb-2">
<Package className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Executors:</span>
<span className="font-mono">
{selectedWorkflow.executors.slice(0, 3).join(", ")}
{selectedWorkflow.executors.length > 3 && "..."}
</span>
<span className="text-muted-foreground">
({selectedWorkflow.executors.length})
</span>
</div>
)}
{/* Collapsible Details Section */}
<div
className={`overflow-hidden transition-all duration-200 ease-in-out ${
detailsExpanded ? "max-h-40 mt-3" : "max-h-0"
}`}
>
<div className="space-y-2 text-xs">
{/* Start Executor */}
<div className="flex items-center gap-2">
<WorkflowIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Start:</span>
<span className="font-mono">
{selectedWorkflow.start_executor_id}
</span>
</div>
{/* Source */}
<div className="flex items-center gap-2">
{selectedWorkflow.source === "directory" ? (
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
) : selectedWorkflow.source === "in_memory" ? (
<Database className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">Source:</span>
<span>
{selectedWorkflow.source === "directory"
? "Local"
: selectedWorkflow.source === "in_memory"
? "In-Memory"
: "Gallery"}
</span>
{selectedWorkflow.module_path && (
<span className="text-muted-foreground font-mono text-[11px]">
({selectedWorkflow.module_path})
</span>
)}
</div>
{/* Environment */}
<div className="flex items-center gap-2">
{selectedWorkflow.has_env ? (
<XCircle className="h-3.5 w-3.5 text-orange-500" />
) : (
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
)}
<span className="text-muted-foreground">Environment:</span>
<span>
{selectedWorkflow.has_env
? "Requires environment variables"
: "No environment variables required"}
</span>
</div>
</div>
</div>
</div>
{/* Workflow Visualization */}
<div className="flex-1 min-h-0">
{workflowInfo?.workflow_dump && (
<WorkflowFlow
workflowDump={workflowInfo.workflow_dump}
events={openAIEvents}
isStreaming={isStreaming}
onNodeSelect={handleNodeSelect}
className="h-full"
viewOptions={viewOptions}
onToggleViewOption={toggleViewOption}
layoutDirection={layoutDirection}
onLayoutDirectionChange={setLayoutDirection}
/>
)}
</div>
{/* Resize Handle */}
@@ -598,13 +714,17 @@ export function WorkflowView({
executorHistory.length > 0 ||
workflowResult ||
workflowError ? (
<div className="h-full space-y-4">
<div className="h-full flex gap-4">
{/* Current/Last Executor Panel */}
{(selectedExecutor ||
activeExecutors.length > 0 ||
executorHistory.length > 0) && (
<div className="border border-border rounded bg-card shadow-sm">
<div className="border-b border-border px-4 py-3 bg-muted rounded-t">
<div
className={`border border-border rounded bg-card shadow-sm flex flex-col ${
workflowResult || workflowError ? "flex-1" : "w-full"
}`}
>
<div className="border-b border-border px-4 py-3 bg-muted rounded-t flex-shrink-0">
<h4 className="text-sm font-medium text-foreground">
{selectedExecutor
? `Executor: ${
@@ -615,14 +735,14 @@ export function WorkflowView({
: "Last Executor"}
</h4>
</div>
<div className="p-4">
<div className="p-4 overflow-auto flex-1">
{selectedExecutor ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${
selectedExecutor.state === "running"
? "bg-blue-500 dark:bg-blue-400 animate-pulse"
? "bg-[#643FB2] dark:bg-[#8B5CF6] animate-pulse"
: selectedExecutor.state === "completed"
? "bg-green-500 dark:bg-green-400"
: selectedExecutor.state === "failed"
@@ -754,7 +874,7 @@ export function WorkflowView({
<div
className={`w-3 h-3 rounded-full ${
isStreaming
? "bg-blue-500 dark:bg-blue-400 animate-pulse"
? "bg-[#643FB2] dark:bg-[#8B5CF6] animate-pulse"
: historyItem?.status === "completed"
? "bg-green-500 dark:bg-green-400"
: historyItem?.status === "error"
@@ -791,8 +911,8 @@ export function WorkflowView({
{/* Enhanced Result Display */}
{workflowResult && (
<div className="border-2 border-emerald-300 dark:border-emerald-600 rounded bg-emerald-50 dark:bg-emerald-950/50 shadow">
<div className="border-b border-emerald-300 dark:border-emerald-600 px-4 py-3 bg-emerald-100 dark:bg-emerald-900/50 rounded-t">
<div className="border-2 border-emerald-300 dark:border-emerald-600 rounded bg-emerald-50 dark:bg-emerald-950/50 shadow flex-1 flex flex-col">
<div className="border-b border-emerald-300 dark:border-emerald-600 px-4 py-3 bg-emerald-100 dark:bg-emerald-900/50 rounded-t flex-shrink-0">
<div className="flex items-center gap-3">
<CheckCircle className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
<h4 className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
@@ -800,7 +920,7 @@ export function WorkflowView({
</h4>
</div>
</div>
<div className="p-4">
<div className="p-4 overflow-auto flex-1">
<div className="text-emerald-700 dark:text-emerald-300 whitespace-pre-wrap break-words text-sm">
{workflowResult}
</div>
@@ -810,8 +930,8 @@ export function WorkflowView({
{/* Enhanced Error Display */}
{workflowError && (
<div className="border-2 border-destructive/70 rounded bg-destructive/5 shadow">
<div className="border-b border-destructive/70 px-4 py-3 bg-destructive/10 rounded-t">
<div className="border-2 border-destructive/70 rounded bg-destructive/5 shadow flex-1 flex flex-col">
<div className="border-b border-destructive/70 px-4 py-3 bg-destructive/10 rounded-t flex-shrink-0">
<div className="flex items-center gap-3">
<AlertCircle className="w-4 h-4 text-destructive" />
<h4 className="text-sm font-semibold text-destructive">
@@ -819,7 +939,7 @@ export function WorkflowView({
</h4>
</div>
</div>
<div className="p-4">
<div className="p-4 overflow-auto flex-1">
<div className="text-destructive whitespace-pre-wrap break-words text-sm">
{workflowError}
</div>
@@ -0,0 +1,5 @@
/**
* Gallery data exports
*/
export * from './sample-entities';
@@ -0,0 +1,185 @@
/**
* 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: "weather-agent",
name: "Weather Agent",
description:
"Simple weather agent with mock data demonstrating basic tool usage",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/weather_agent/agent.py",
tags: ["openai", "tools", "basic"],
author: "Microsoft",
difficulty: "beginner",
features: [
"Function calling",
"Mock weather data",
"Simple tool integration",
],
requiredEnvVars: [
{
name: "OPENAI_API_KEY",
description: "OpenAI API key for chat completions",
required: true,
},
{
name: "OPENAI_CHAT_MODEL_ID",
description: "OpenAI model ID (e.g., gpt-4o)",
required: false,
example: "gpt-4o",
},
],
},
{
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/packages/devui/samples/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: "AZURE_AI_PROJECT_ENDPOINT",
description: "Azure AI Foundry project endpoint URL",
required: true,
example: "https://your-project.api.azureml.ms",
},
{
name: "FOUNDRY_MODEL_DEPLOYMENT_NAME",
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/packages/devui/samples/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_CHAT_DEPLOYMENT_NAME",
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/packages/devui/samples/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/packages/devui/samples/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";
}
};
+3 -3
View File
@@ -49,7 +49,7 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary: 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);
@@ -83,8 +83,8 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--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);
@@ -5,6 +5,7 @@
import type {
AgentInfo,
AgentSource,
HealthResponse,
RunAgentRequest,
RunWorkflowRequest,
@@ -22,6 +23,8 @@ interface EntityInfo {
framework: string;
tools?: (string | Record<string, unknown>)[];
metadata: Record<string, unknown>;
source?: string;
original_url?: string;
executors?: string[];
workflow_dump?: Record<string, unknown>;
input_schema?: Record<string, unknown>;
@@ -52,16 +55,30 @@ interface ThreadApiObject {
created_at?: string;
}
const API_BASE_URL =
const DEFAULT_API_BASE_URL =
import.meta.env.VITE_API_BASE_URL !== undefined
? import.meta.env.VITE_API_BASE_URL
: "http://localhost:8080";
// Get backend URL from localStorage or default
function getBackendUrl(): string {
return localStorage.getItem("devui_backend_url") || DEFAULT_API_BASE_URL;
}
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string = API_BASE_URL) {
this.baseUrl = baseUrl;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl || getBackendUrl();
}
// Allow updating the base URL at runtime
setBaseUrl(url: string) {
this.baseUrl = url;
}
getBaseUrl(): string {
return this.baseUrl;
}
private async request<T>(
@@ -79,9 +96,17 @@ class ApiClient {
});
if (!response.ok) {
throw new Error(
`API request failed: ${response.status} ${response.statusText}`
);
// Try to extract error message from response body
let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// If parsing fails, use default message
}
throw new Error(errorMessage);
}
return response.json();
@@ -111,7 +136,7 @@ class ApiClient {
name: entity.name,
description: entity.description,
type: "agent",
source: "directory", // Default source
source: (entity.source as AgentSource) || "directory",
tools: (entity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
),
@@ -130,7 +155,7 @@ class ApiClient {
name: entity.name,
description: entity.description,
type: "workflow",
source: "directory",
source: (entity.source as AgentSource) || "directory",
executors: (entity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
),
@@ -440,6 +465,31 @@ class ApiClient {
body: JSON.stringify(request),
});
}
// Add entity from URL
async addEntity(url: string, metadata?: Record<string, unknown>): Promise<EntityInfo> {
const response = await this.request<{ success: boolean; entity: EntityInfo }>("/v1/entities/add", {
method: "POST",
body: JSON.stringify({ url, metadata }),
});
if (!response.success || !response.entity) {
throw new Error("Failed to add entity");
}
return response.entity;
}
// Remove entity by ID
async removeEntity(entityId: string): Promise<void> {
const response = await this.request<{ success: boolean }>(`/v1/entities/${entityId}`, {
method: "DELETE",
});
if (!response.success) {
throw new Error("Failed to remove entity");
}
}
}
// Export singleton instance
@@ -238,9 +238,10 @@ export interface AgentThread {
// Workflow events
export interface WorkflowEvent {
type?: string; // Event class name like "WorkflowCompletedEvent", "ExecutorInvokedEvent", etc.
type?: string; // Event class name like "WorkflowOutputEvent", "WorkflowCompletedEvent", "ExecutorInvokedEvent", etc.
data?: unknown;
executor_id?: string; // Present for executor-related events
source_executor_id?: string; // Present for WorkflowOutputEvent
}
export interface WorkflowStartedEvent extends WorkflowEvent {
@@ -249,10 +250,16 @@ export interface WorkflowStartedEvent extends WorkflowEvent {
}
export interface WorkflowCompletedEvent extends WorkflowEvent {
// Event-specific data for workflow completion
// Event-specific data for workflow completion (legacy)
readonly event_type: "workflow_completed";
}
export interface WorkflowOutputEvent extends WorkflowEvent {
// Event-specific data for workflow output (new)
readonly event_type: "workflow_output";
source_executor_id: string; // ID of executor that yielded the output
}
export interface WorkflowWarningEvent extends WorkflowEvent {
data: string; // Warning message
}
@@ -4,7 +4,7 @@
*/
export type AgentType = "agent" | "workflow";
export type AgentSource = "directory" | "in_memory";
export type AgentSource = "directory" | "in_memory" | "remote_gallery";
export type StreamEventType =
| "agent_run_update"
| "workflow_event"
@@ -14,6 +14,13 @@ export type StreamEventType =
| "debug_trace"
| "trace_span";
export interface EnvVarRequirement {
name: string;
description: string;
required: boolean;
example?: string;
}
export interface AgentInfo {
id: string;
name?: string;
@@ -23,6 +30,7 @@ export interface AgentInfo {
tools: string[];
has_env: boolean;
module_path?: string;
required_env_vars?: EnvVarRequirement[];
}
// JSON Schema types for workflow input
@@ -45,7 +45,7 @@ export function applySimpleLayout(
// Constants for spacing
const NODE_WIDTH = 220;
const NODE_HEIGHT = 120;
const HORIZONTAL_SPACING = direction === "LR" ? 350 : 200;
const HORIZONTAL_SPACING = direction === "LR" ? 350 : 280;
const VERTICAL_SPACING = direction === "TB" ? 250 : 180;
// Track positioned nodes and level information
@@ -54,7 +54,8 @@ export interface NodeUpdate {
*/
export function convertWorkflowDumpToNodes(
workflowDump: Workflow | Record<string, unknown> | undefined,
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void,
layoutDirection?: "LR" | "TB"
): Node<ExecutorNodeData>[] {
if (!workflowDump) {
console.warn("convertWorkflowDumpToNodes: workflowDump is undefined");
@@ -108,6 +109,7 @@ export function convertWorkflowDumpToNodes(
name: executor.name || executor.id,
state: "pending" as ExecutorState,
isStartNode: executor.id === startExecutorId,
layoutDirection: layoutDirection || "LR",
onNodeClick,
},
}));
@@ -339,7 +341,7 @@ export function processWorkflowEvents(
error = typeof eventData === "string" ? eventData : "Execution failed";
} else if (eventType?.includes("Cancel")) {
state = "cancelled";
} else if (eventType === "WorkflowCompletedEvent") {
} else if (eventType === "WorkflowCompletedEvent" || eventType === "WorkflowOutputEvent") {
state = "completed";
}
@@ -376,6 +378,8 @@ export function updateNodesWithEvents(
state: update.state,
outputData: update.data,
error: update.error,
// Preserve layoutDirection
layoutDirection: node.data.layoutDirection,
},
};
}
@@ -478,7 +482,7 @@ export function updateEdgesWithSequenceAnalysis(
// Active edge: source completed and target is currently executing
if (sourceState?.completed && targetIsExecuting) {
style = {
stroke: "#3b82f6", // Blue
stroke: "#643FB2", // Purple accent
strokeWidth: 3,
strokeDasharray: "5,5",
};
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
"""Weather agent sample for DevUI testing."""
from .agent import agent
__all__ = ["agent"]
@@ -0,0 +1,81 @@
# Copyright (c) Microsoft. All rights reserved.
"""Foundry-based weather agent for Agent Framework Debug UI.
This agent uses Azure AI Foundry with Azure CLI authentication.
Make sure to run 'az login' before starting devui.
"""
import os
from typing import Annotated
from agent_framework.azure import AzureAIAgentClient
from azure.identity.aio import AzureCliCredential
def get_weather(
location: Annotated[str, "The location to get the weather for."],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
temperature = 22
return f"The weather in {location} is {conditions[0]} with a high of {temperature}°C."
def get_forecast(
location: Annotated[str, "The location to get the forecast for."],
days: Annotated[int, "Number of days for forecast"] = 3,
) -> str:
"""Get weather forecast for multiple days."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
forecast = []
for day in range(1, days + 1):
condition = conditions[day % len(conditions)]
temp = 18 + day
forecast.append(f"Day {day}: {condition}, {temp}°C")
return f"Weather forecast for {location}:\n" + "\n".join(forecast)
credential = AzureCliCredential()
# Cleanup will happen when Python process exits
client = AzureAIAgentClient(
async_credential=credential,
project_endpoint=os.environ.get("AZURE_AI_PROJECT_ENDPOINT"),
model_deployment_name=os.environ.get("FOUNDRY_MODEL_DEPLOYMENT_NAME"),
)
# Agent instance following Agent Framework conventions
agent = client.create_agent(
name="FoundryWeatherAgent",
description="A helpful agent using Azure AI Foundry that provides weather information",
instructions="""
You are a weather assistant using Azure AI Foundry models. You can provide
current weather information and forecasts for any location. Always be helpful
and provide detailed weather information when asked.
""",
tools=[get_weather, get_forecast],
)
def main():
"""Launch the Foundry weather agent in DevUI."""
import logging
from agent_framework.devui import serve
# Setup logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
logger.info("Starting Foundry Weather Agent")
logger.info("Available at: http://localhost:8090")
logger.info("Entity ID: agent_FoundryWeatherAgent")
logger.info("Note: Make sure 'az login' has been run for authentication")
# Launch server with the agent
serve(entities=[agent], port=8090, auto_open=True)
if __name__ == "__main__":
main()