Python: Add Function Approval UI to DevUI (#1401)

* ensure function aproval is parsed correctly

* udpate ui, add deployment guide button, other debug panel fixes

* feat(devui): Implement lazy loading architecture with enhanced security and state management

Major architectural improvements to DevUI for better performance, security, and developer experience:

Performance & Architecture:
- Implement lazy loading for entity discovery - entities loaded on-demand instead of at startup
- Add hot reload capability for development workflow via new reload endpoint
- Reduce startup time and memory footprint by deferring module imports

Security Enhancements:
- Remove remote entity loading capabilities (POST /v1/entities/add, DELETE endpoints)
- DevUI now strictly local development tool - no remote code execution
- Add explicit security documentation and best practices in README

Frontend Improvements:
- Migrate to Zustand for centralized state management (replacing prop drilling)
- Add lightweight zero-dependency markdown renderer with code block copy support
- Improve gallery UX with setup instructions modal instead of direct URL loading
- Enhanced message UI with copy functionality and better token usage display

Testing & Quality:
- Expand test coverage for lazy loading, type detection, and cache invalidation
- Add comprehensive tests for new behaviors (+231 lines of test code)
- Improve type safety and documentation throughout

Breaking Changes:
- Remote entity loading via URLs is no longer supported
- Entities must be loaded from local filesystem only

* update ui issues, uupdate test descripion
This commit is contained in:
Victor Dibia
2025-10-15 14:36:29 -07:00
committed by GitHub
parent 331c750515
commit b64358df7e
38 changed files with 3726 additions and 1814 deletions
+31 -10
View File
@@ -47,7 +47,7 @@ devui ./agents --port 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.
When DevUI starts with no discovered entities, it displays a **sample entity gallery** with curated examples from the Agent Framework repository. You can download these samples, review them, and run them locally to get started quickly.
## Directory Structure
@@ -160,31 +160,37 @@ Given that DevUI offers an OpenAI Responses API, it internally maps messages and
| Agent Framework Content | OpenAI Event/Type | Status |
| ------------------------------- | ---------------------------------------- | -------- |
| `TextContent` | `response.output_text.delta` | Standard |
| `TextReasoningContent` | `response.reasoning.delta` | Standard |
| `TextReasoningContent` | `response.reasoning_text.delta` | Standard |
| `FunctionCallContent` (initial) | `response.output_item.added` | Standard |
| `FunctionCallContent` (args) | `response.function_call_arguments.delta` | Standard |
| `FunctionResultContent` | `response.function_result.complete` | DevUI |
| `ErrorContent` | `response.error` | Standard |
| `FunctionApprovalRequestContent`| `response.function_approval.requested` | DevUI |
| `FunctionApprovalResponseContent`| `response.function_approval.responded` | DevUI |
| `ErrorContent` | `error` | Standard |
| `UsageContent` | Final `Response.usage` field (not streamed) | Standard |
| `WorkflowEvent` | `response.workflow_event.complete` | DevUI |
| `DataContent`, `UriContent` | `response.trace.complete` | DevUI |
| `DataContent` | `response.trace.complete` | DevUI |
| `UriContent` | `response.trace.complete` | DevUI |
| `HostedFileContent` | `response.trace.complete` | DevUI |
| `HostedVectorStoreContent` | `response.trace.complete` | DevUI |
- **Standard** = OpenAI Responses API spec
- **DevUI** = Custom extensions for Agent Framework features (workflows, traces, function results)
- **DevUI** = Custom extensions for Agent Framework features (workflows, traces, function approvals)
### OpenAI Responses API Compliance
DevUI follows the OpenAI Responses API specification for maximum compatibility:
**Standard OpenAI Types Used:**
- `ResponseOutputItemAddedEvent` - Output item notifications (function calls)
- `ResponseOutputItemAddedEvent` - Output item notifications (function calls and results)
- `Response.usage` - Token usage (in final response, not streamed)
- All standard text, reasoning, and function call events
**Custom DevUI Extensions:**
- `response.function_result.complete` - Function execution results (DevUI executes functions, OpenAI doesn't)
- `response.function_approval.requested` - Function approval requests (for interactive approval workflows)
- `response.function_approval.responded` - Function approval responses (user approval/rejection)
- `response.workflow_event.complete` - Agent Framework workflow events
- `response.trace.complete` - Execution traces for debugging
- `response.trace.complete` - Execution traces and internal content (DataContent, UriContent, hosted files/stores)
These custom extensions are clearly namespaced and can be safely ignored by standard OpenAI clients.
@@ -192,8 +198,7 @@ These custom extensions are clearly namespaced and can be safely ignored by stan
- `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/entities/{entity_id}/reload` - Hot reload entity (for development)
### Execution (OpenAI Responses API)
@@ -214,6 +219,22 @@ These custom extensions are clearly namespaced and can be safely ignored by stan
- `GET /health` - Health check
## Security
DevUI is designed as a **sample application for local development** and should not be exposed to untrusted networks or used in production environments.
**Security features:**
- Only loads entities from local directories or in-memory registration
- No remote code execution capabilities
- Binds to localhost (127.0.0.1) by default
- All samples must be manually downloaded and reviewed before running
**Best practices:**
- Never expose DevUI to the internet
- Review all agent/workflow code before running
- Only load entities from trusted sources
- Use `.env` files for sensitive credentials (never commit them)
## Implementation
- **Discovery**: `agent_framework_devui/_discovery.py`
@@ -4,7 +4,6 @@
from __future__ import annotations
import hashlib
import importlib
import importlib.util
import logging
@@ -13,17 +12,12 @@ import uuid
from pathlib import Path
from typing import Any
import httpx
from dotenv import load_dotenv
from .models._discovery_models import EntityInfo
logger = logging.getLogger(__name__)
# Constants for remote entity fetching
REMOTE_FETCH_TIMEOUT_SECONDS = 30.0
REMOTE_FETCH_MAX_SIZE_MB = 10
class EntityDiscovery:
"""Discovery for Agent Framework entities - agents and workflows."""
@@ -37,7 +31,6 @@ 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.
@@ -77,6 +70,115 @@ class EntityDiscovery:
"""
return self._loaded_objects.get(entity_id)
async def load_entity(self, entity_id: str) -> Any:
"""Load entity on-demand (lazy loading).
This method implements lazy loading by importing the entity module only when needed.
In-memory entities are returned from cache immediately.
Args:
entity_id: Entity identifier
Returns:
Loaded entity object
Raises:
ValueError: If entity not found or cannot be loaded
"""
# Check if already loaded (includes in-memory entities)
if entity_id in self._loaded_objects:
logger.debug(f"Entity {entity_id} already loaded (cache hit)")
return self._loaded_objects[entity_id]
# Get entity metadata
entity_info = self._entities.get(entity_id)
if not entity_info:
raise ValueError(f"Entity {entity_id} not found in registry")
# In-memory entities should never reach here (they're pre-loaded)
if entity_info.source == "in_memory":
raise ValueError(f"In-memory entity {entity_id} missing from loaded objects cache")
logger.info(f"Lazy loading entity: {entity_id} (source: {entity_info.source})")
# Load based on source - only directory and in-memory are supported
if entity_info.source == "directory":
entity_obj = await self._load_directory_entity(entity_id, entity_info)
else:
raise ValueError(
f"Unsupported entity source: {entity_info.source}. "
f"Only 'directory' and 'in_memory' sources are supported."
)
# Enrich metadata with actual entity data
# Don't pass entity_type if it's "unknown" - let inference determine the real type
enriched_info = await self.create_entity_info_from_object(
entity_obj,
entity_type=entity_info.type if entity_info.type != "unknown" else None,
source=entity_info.source,
)
# IMPORTANT: Preserve the original entity_id (enrichment generates a new one)
enriched_info.id = entity_id
# Preserve the original path from sparse metadata
if "path" in entity_info.metadata:
enriched_info.metadata["path"] = entity_info.metadata["path"]
enriched_info.metadata["lazy_loaded"] = True
self._entities[entity_id] = enriched_info
# Cache the loaded object
self._loaded_objects[entity_id] = entity_obj
logger.info(f"✅ Successfully loaded entity: {entity_id} (type: {enriched_info.type})")
return entity_obj
async def _load_directory_entity(self, entity_id: str, entity_info: EntityInfo) -> Any:
"""Load entity from directory (imports module).
Args:
entity_id: Entity identifier
entity_info: Entity metadata
Returns:
Loaded entity object
"""
# Get directory path from metadata
dir_path = Path(entity_info.metadata.get("path", ""))
if not dir_path.exists(): # noqa: ASYNC240
raise ValueError(f"Entity directory not found: {dir_path}")
# Load .env if it exists
if dir_path.is_dir(): # noqa: ASYNC240
self._load_env_for_entity(dir_path)
else:
self._load_env_for_entity(dir_path.parent)
# Import the module
if dir_path.is_dir(): # noqa: ASYNC240
# Directory-based entity - try different import patterns
import_patterns = [
entity_id,
f"{entity_id}.agent",
f"{entity_id}.workflow",
]
for pattern in import_patterns:
module = self._load_module_from_pattern(pattern)
if module:
# Find entity in module - pass entity_id so registration uses correct ID
entity_obj = await self._find_entity_in_module(module, entity_id, str(dir_path))
if entity_obj:
return entity_obj
raise ValueError(f"No valid entity found in {dir_path}")
# File-based entity
module = self._load_module_from_file(dir_path, entity_id)
if module:
entity_obj = await self._find_entity_in_module(module, entity_id, str(dir_path))
if entity_obj:
return entity_obj
raise ValueError(f"No valid entity found in {dir_path}")
def list_entities(self) -> list[EntityInfo]:
"""List all discovered entities.
@@ -85,6 +187,48 @@ class EntityDiscovery:
"""
return list(self._entities.values())
def invalidate_entity(self, entity_id: str) -> None:
"""Invalidate (clear cache for) an entity to enable hot reload.
This removes the entity from the loaded objects cache and clears its module
from Python's sys.modules cache. The entity metadata remains, so it will be
reimported on next access.
Args:
entity_id: Entity identifier to invalidate
"""
# Remove from loaded objects cache
if entity_id in self._loaded_objects:
del self._loaded_objects[entity_id]
logger.info(f"Cleared loaded object cache for: {entity_id}")
# Clear from Python's module cache (including submodules)
keys_to_delete = [
module_name
for module_name in sys.modules
if module_name == entity_id or module_name.startswith(f"{entity_id}.")
]
for key in keys_to_delete:
del sys.modules[key]
logger.debug(f"Cleared module cache: {key}")
# Reset lazy_loaded flag in metadata
entity_info = self._entities.get(entity_id)
if entity_info and "lazy_loaded" in entity_info.metadata:
entity_info.metadata["lazy_loaded"] = False
logger.info(f"♻️ Entity invalidated: {entity_id} (will reload on next access)")
def invalidate_all(self) -> None:
"""Invalidate all cached entities.
Useful for forcing a complete reload of all entities.
"""
entity_ids = list(self._loaded_objects.keys())
for entity_id in entity_ids:
self.invalidate_entity(entity_id)
logger.info(f"Invalidated {len(entity_ids)} entities")
def register_entity(self, entity_id: str, entity_info: EntityInfo, entity_object: Any) -> None:
"""Register an entity with both metadata and object.
@@ -187,7 +331,10 @@ class EntityDiscovery:
)
async def _scan_entities_directory(self, entities_dir: Path) -> None:
"""Scan the entities directory for Agent Framework entities.
"""Scan the entities directory for Agent Framework entities (lazy loading).
This method scans the filesystem WITHOUT importing modules, creating sparse
metadata that will be enriched on-demand when entities are accessed.
Args:
entities_dir: Directory to scan for entities
@@ -196,78 +343,120 @@ class EntityDiscovery:
logger.warning(f"Entities directory not found: {entities_dir}")
return
logger.info(f"Scanning {entities_dir} for Agent Framework entities...")
logger.info(f"Scanning {entities_dir} for Agent Framework entities (lazy mode)...")
# Add entities directory to Python path if not already there
entities_dir_str = str(entities_dir)
if entities_dir_str not in sys.path:
sys.path.insert(0, entities_dir_str)
# Scan for directories and Python files
# Scan for directories and Python files WITHOUT importing
for item in entities_dir.iterdir(): # noqa: ASYNC240
if item.name.startswith(".") or item.name == "__pycache__":
continue
if item.is_dir():
# Directory-based entity
await self._discover_entities_in_directory(item)
if item.is_dir() and self._looks_like_entity(item):
# Directory-based entity - create sparse metadata
self._register_sparse_entity(item)
elif item.is_file() and item.suffix == ".py" and not item.name.startswith("_"):
# Single file entity
await self._discover_entities_in_file(item)
# Single file entity - create sparse metadata
self._register_sparse_file_entity(item)
async def _discover_entities_in_directory(self, dir_path: Path) -> None:
"""Discover entities in a directory using module import.
def _looks_like_entity(self, dir_path: Path) -> bool:
"""Check if directory contains an entity (without importing).
Args:
dir_path: Directory containing entity
dir_path: Directory to check
Returns:
True if directory appears to contain an entity
"""
return (
(dir_path / "agent.py").exists()
or (dir_path / "workflow.py").exists()
or (dir_path / "__init__.py").exists()
)
def _detect_entity_type(self, dir_path: Path) -> str:
"""Detect entity type from directory structure (without importing).
Uses filename conventions to determine entity type:
- workflow.py → "workflow"
- agent.py → "agent"
- both or neither → "unknown"
Args:
dir_path: Directory to analyze
Returns:
Entity type: "workflow", "agent", or "unknown"
"""
has_agent = (dir_path / "agent.py").exists()
has_workflow = (dir_path / "workflow.py").exists()
if has_agent and has_workflow:
# Both files exist - ambiguous, mark as unknown
return "unknown"
if has_workflow:
return "workflow"
if has_agent:
return "agent"
# Has __init__.py but no specific file
return "unknown"
def _register_sparse_entity(self, dir_path: Path) -> None:
"""Register entity with sparse metadata (no import).
Args:
dir_path: Entity directory
"""
entity_id = dir_path.name
logger.debug(f"Scanning directory: {entity_id}")
entity_type = self._detect_entity_type(dir_path)
try:
# Load environment variables for this entity first
self._load_env_for_entity(dir_path)
entity_info = EntityInfo(
id=entity_id,
name=entity_id.replace("_", " ").title(),
type=entity_type,
framework="agent_framework",
tools=[], # Sparse - will be populated on load
description="", # Sparse - will be populated on load
source="directory",
metadata={
"path": str(dir_path),
"discovered": True,
"lazy_loaded": False,
},
)
# Try different import patterns
import_patterns = [
entity_id, # Direct module import
f"{entity_id}.agent", # agent.py submodule
f"{entity_id}.workflow", # workflow.py submodule
]
self._entities[entity_id] = entity_info
logger.debug(f"Registered sparse entity: {entity_id} (type: {entity_type})")
for pattern in import_patterns:
module = self._load_module_from_pattern(pattern)
if module:
entities_found = await self._find_entities_in_module(module, entity_id, str(dir_path))
if entities_found:
logger.debug(f"Found {len(entities_found)} entities in {pattern}")
break
except Exception as e:
logger.warning(f"Error scanning directory {entity_id}: {e}")
async def _discover_entities_in_file(self, file_path: Path) -> None:
"""Discover entities in a single Python file.
def _register_sparse_file_entity(self, file_path: Path) -> None:
"""Register file-based entity with sparse metadata (no import).
Args:
file_path: Python file to scan
file_path: Entity Python file
"""
try:
# Load environment variables for this entity's directory first
self._load_env_for_entity(file_path.parent)
entity_id = file_path.stem
# Create module name from file path
base_name = file_path.stem
# File-based entities are typically agents, but we can't know for sure without importing
entity_info = EntityInfo(
id=entity_id,
name=entity_id.replace("_", " ").title(),
type="unknown", # Will be determined on load
framework="agent_framework",
tools=[],
description="",
source="directory",
metadata={
"path": str(file_path),
"discovered": True,
"lazy_loaded": False,
},
)
# Load the module directly from file
module = self._load_module_from_file(file_path, base_name)
if module:
entities_found = await self._find_entities_in_module(module, base_name, str(file_path))
if entities_found:
logger.debug(f"Found {len(entities_found)} entities in {file_path.name}")
except Exception as e:
logger.warning(f"Error scanning file {file_path}: {e}")
self._entities[entity_id] = entity_info
logger.debug(f"Registered sparse file entity: {entity_id}")
def _load_env_for_entity(self, entity_path: Path) -> bool:
"""Load .env file for an entity.
@@ -359,19 +548,17 @@ class EntityDiscovery:
logger.warning(f"Error loading module from {file_path}: {e}")
return None
async def _find_entities_in_module(self, module: Any, base_id: str, module_path: str) -> list[str]:
"""Find agent and workflow entities in a loaded module.
async def _find_entity_in_module(self, module: Any, entity_id: str, module_path: str) -> Any:
"""Find agent or workflow entity in a loaded module.
Args:
module: Loaded Python module
base_id: Base identifier for entities
entity_id: Expected entity identifier to register with
module_path: Path to module for metadata
Returns:
List of entity IDs that were found and registered
Loaded entity object, or None if not found
"""
entities_found = []
# Look for explicit variable names first
candidates = [
("agent", getattr(module, "agent", None)),
@@ -383,11 +570,12 @@ class EntityDiscovery:
continue
if self._is_valid_entity(obj, obj_type):
# 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)
# Register with the correct entity_id (from directory name)
# Store the object directly in _loaded_objects so we can return it
self._loaded_objects[entity_id] = obj
return obj
return entities_found
return None
def _is_valid_entity(self, obj: Any, expected_type: str) -> bool:
"""Check if object is a valid agent or workflow using duck typing.
@@ -602,173 +790,3 @@ class EntityDiscovery:
full_uuid = uuid.uuid4().hex
return f"{entity_type}_{source}_{base_name}_{full_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 = REMOTE_FETCH_MAX_SIZE_MB) -> str | None:
"""Fetch content from URL with size and timeout limits."""
try:
async with httpx.AsyncClient(timeout=REMOTE_FETCH_TIMEOUT_SECONDS) 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
@@ -169,9 +169,11 @@ class AgentFrameworkExecutor:
Raw Agent Framework events and trace events
"""
try:
# Get entity info and object
# Get entity info
entity_info = self.get_entity_info(entity_id)
entity_obj = self.entity_discovery.get_entity_object(entity_id)
# Trigger lazy loading (will return from cache if already loaded)
entity_obj = await self.entity_discovery.load_entity(entity_id)
if not entity_obj:
raise EntityNotFoundError(f"Entity object for '{entity_id}' not found")
@@ -524,21 +524,15 @@ class MessageMapper:
async def _map_function_result_content(
self, content: Any, context: dict[str, Any]
) -> ResponseFunctionResultComplete:
"""Map FunctionResultContent to custom DevUI event.
"""Map FunctionResultContent to DevUI custom event.
This is a DevUI extension - OpenAI doesn't stream function execution results
because in their model, applications execute functions, not the API.
Agent Framework executes functions, so we emit this event for debugging visibility.
IMPORTANT: Always use Agent Framework's call_id from the content.
Do NOT generate a new call_id - it must match the one from the function call event.
DevUI extension: The OpenAI Responses API doesn't stream function execution results
(in OpenAI's model, the application executes functions, not the API).
"""
# Get call_id from content - this MUST match the call_id from the function call
# Get call_id from content
call_id = getattr(content, "call_id", None)
if not call_id:
logger.warning("FunctionResultContent missing call_id - this will break call/result pairing")
call_id = f"call_{uuid.uuid4().hex[:8]}" # Fallback only if truly missing
call_id = f"call_{uuid.uuid4().hex[:8]}"
# Extract result
result = getattr(content, "result", None)
@@ -547,16 +541,19 @@ class MessageMapper:
# Convert result to string
output = result if isinstance(result, str) else json.dumps(result) if result is not None else ""
# Determine status
# Determine status based on exception
status = "incomplete" if exception else "completed"
# Return custom DevUI event
# Generate item_id
item_id = f"item_{uuid.uuid4().hex[:8]}"
# Return DevUI custom event
return ResponseFunctionResultComplete(
type="response.function_result.complete",
call_id=call_id,
output=output,
status=status,
item_id=context["item_id"],
item_id=item_id,
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
@@ -663,15 +660,24 @@ class MessageMapper:
async def _map_approval_request_content(self, content: Any, context: dict[str, Any]) -> dict[str, Any]:
"""Map FunctionApprovalRequestContent to custom event."""
# Parse arguments to ensure they're always a dict, not a JSON string
# This prevents double-escaping when the frontend calls JSON.stringify()
arguments: dict[str, Any] = {}
if hasattr(content, "function_call"):
if hasattr(content.function_call, "parse_arguments"):
# Use parse_arguments() to convert string arguments to dict
arguments = content.function_call.parse_arguments() or {}
else:
# Fallback to direct access if parse_arguments doesn't exist
arguments = getattr(content.function_call, "arguments", {})
return {
"type": "response.function_approval.requested",
"request_id": getattr(content, "id", "unknown"),
"function_call": {
"id": getattr(content.function_call, "call_id", "") if hasattr(content, "function_call") else "",
"name": getattr(content.function_call, "name", "") if hasattr(content, "function_call") else "",
"arguments": getattr(content.function_call, "arguments", {})
if hasattr(content, "function_call")
else {},
"arguments": arguments,
},
"item_id": context["item_id"],
"output_index": context["output_index"],
@@ -174,7 +174,7 @@ class DevServer:
@app.get("/v1/entities/{entity_id}/info", response_model=EntityInfo)
async def get_entity_info(entity_id: str) -> EntityInfo:
"""Get detailed information about a specific entity."""
"""Get detailed information about a specific entity (triggers lazy loading)."""
try:
executor = await self._ensure_executor()
entity_info = executor.get_entity_info(entity_id)
@@ -182,90 +182,96 @@ class DevServer:
if not entity_info:
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
# Trigger lazy loading if entity not yet loaded
# This will import the module and enrich metadata
entity_obj = await executor.entity_discovery.load_entity(entity_id)
# Get updated entity info (may have been enriched during load)
entity_info = executor.get_entity_info(entity_id) or entity_info
# For workflows, populate additional detailed information
if entity_info.type == "workflow":
entity_obj = executor.entity_discovery.get_entity_object(entity_id)
if entity_obj:
# Get workflow structure
workflow_dump = None
if hasattr(entity_obj, "to_dict") and callable(getattr(entity_obj, "to_dict", None)):
try:
workflow_dump = entity_obj.to_dict() # type: ignore[attr-defined]
except Exception:
workflow_dump = None
elif hasattr(entity_obj, "to_json") and callable(getattr(entity_obj, "to_json", None)):
try:
raw_dump = entity_obj.to_json() # type: ignore[attr-defined]
except Exception:
workflow_dump = None
else:
if isinstance(raw_dump, (bytes, bytearray)):
try:
raw_dump = raw_dump.decode()
except Exception:
raw_dump = raw_dump.decode(errors="replace")
if isinstance(raw_dump, str):
try:
parsed_dump = json.loads(raw_dump)
except Exception:
workflow_dump = raw_dump
else:
workflow_dump = parsed_dump if isinstance(parsed_dump, dict) else raw_dump
else:
workflow_dump = raw_dump
elif hasattr(entity_obj, "__dict__"):
workflow_dump = {k: v for k, v in entity_obj.__dict__.items() if not k.startswith("_")}
# Get input schema information
input_schema = {}
input_type_name = "Unknown"
start_executor_id = ""
if entity_info.type == "workflow" and entity_obj:
# Entity object already loaded by load_entity() above
# Get workflow structure
workflow_dump = None
if hasattr(entity_obj, "to_dict") and callable(getattr(entity_obj, "to_dict", None)):
try:
from ._utils import (
extract_executor_message_types,
generate_input_schema,
select_primary_input_type,
workflow_dump = entity_obj.to_dict() # type: ignore[attr-defined]
except Exception:
workflow_dump = None
elif hasattr(entity_obj, "to_json") and callable(getattr(entity_obj, "to_json", None)):
try:
raw_dump = entity_obj.to_json() # type: ignore[attr-defined]
except Exception:
workflow_dump = None
else:
if isinstance(raw_dump, (bytes, bytearray)):
try:
raw_dump = raw_dump.decode()
except Exception:
raw_dump = raw_dump.decode(errors="replace")
if isinstance(raw_dump, str):
try:
parsed_dump = json.loads(raw_dump)
except Exception:
workflow_dump = raw_dump
else:
workflow_dump = parsed_dump if isinstance(parsed_dump, dict) else raw_dump
else:
workflow_dump = raw_dump
elif hasattr(entity_obj, "__dict__"):
workflow_dump = {k: v for k, v in entity_obj.__dict__.items() if not k.startswith("_")}
# Get input schema information
input_schema = {}
input_type_name = "Unknown"
start_executor_id = ""
try:
from ._utils import (
extract_executor_message_types,
generate_input_schema,
select_primary_input_type,
)
start_executor = entity_obj.get_start_executor()
except Exception as e:
logger.debug(f"Could not extract input info for workflow {entity_id}: {e}")
else:
if start_executor:
start_executor_id = getattr(start_executor, "executor_id", "") or getattr(
start_executor, "id", ""
)
start_executor = entity_obj.get_start_executor()
except Exception as e:
logger.debug(f"Could not extract input info for workflow {entity_id}: {e}")
else:
if start_executor:
start_executor_id = getattr(start_executor, "executor_id", "") or getattr(
start_executor, "id", ""
)
message_types = extract_executor_message_types(start_executor)
input_type = select_primary_input_type(message_types)
message_types = extract_executor_message_types(start_executor)
input_type = select_primary_input_type(message_types)
if input_type:
input_type_name = getattr(input_type, "__name__", str(input_type))
if input_type:
input_type_name = getattr(input_type, "__name__", str(input_type))
# Generate schema using comprehensive schema generation
input_schema = generate_input_schema(input_type)
# Generate schema using comprehensive schema generation
input_schema = generate_input_schema(input_type)
if not input_schema:
input_schema = {"type": "string"}
if input_type_name == "Unknown":
input_type_name = "string"
if not input_schema:
input_schema = {"type": "string"}
if input_type_name == "Unknown":
input_type_name = "string"
# Get executor list
executor_list = []
if hasattr(entity_obj, "executors") and entity_obj.executors:
executor_list = [getattr(ex, "executor_id", str(ex)) for ex in entity_obj.executors]
# Get executor list
executor_list = []
if hasattr(entity_obj, "executors") and entity_obj.executors:
executor_list = [getattr(ex, "executor_id", str(ex)) for ex in entity_obj.executors]
# Create copy of entity info and populate workflow-specific fields
update_payload: dict[str, Any] = {
"workflow_dump": workflow_dump,
"input_schema": input_schema,
"input_type_name": input_type_name,
"start_executor_id": start_executor_id,
}
if executor_list:
update_payload["executors"] = executor_list
return entity_info.model_copy(update=update_payload)
# Create copy of entity info and populate workflow-specific fields
update_payload: dict[str, Any] = {
"workflow_dump": workflow_dump,
"input_schema": input_schema,
"input_type_name": input_type_name,
"start_executor_id": start_executor_id,
}
if executor_list:
update_payload["executors"] = executor_list
return entity_info.model_copy(update=update_payload)
# For non-workflow entities, return as-is
return entity_info
@@ -276,70 +282,34 @@ 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."""
@app.post("/v1/entities/{entity_id}/reload")
async def reload_entity(entity_id: str) -> dict[str, Any]:
"""Hot reload entity (clears cache, will reimport on next access).
This enables hot reload during development - edit entity code, call this endpoint,
and the next execution will use the updated code without server restart.
"""
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)
# Check if entity exists
entity_info = executor.get_entity_info(entity_id)
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)
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
logger.info(f"Successfully added entity: {entity_info.id}")
return {"success": True, "entity": entity_info.model_dump()}
# Invalidate cache
executor.entity_discovery.invalidate_entity(entity_id)
return {
"success": True,
"message": f"Entity '{entity_id}' cache cleared. Will reload on next access.",
}
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
logger.error(f"Error reloading entity {entity_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to reload entity: {e!s}") from e
@app.post("/v1/responses")
async def create_response(request: AgentFrameworkRequest, raw_request: Request) -> Any:
@@ -31,8 +31,7 @@ class EntityInfo(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
# Source information
source: str = "directory" # "directory", "in_memory", "remote_gallery"
original_url: str | None = None
source: str = "directory" # "directory" or "in_memory"
# Environment variable requirements
required_env_vars: list[EnvVarRequirement] | None = None
@@ -38,11 +38,16 @@ class ResponseTraceEventComplete(BaseModel):
class ResponseFunctionResultComplete(BaseModel):
"""Custom DevUI event for function execution results.
"""DevUI extension: Stream function execution results.
This is a DevUI extension - OpenAI doesn't stream function execution results
because in their model, the application executes functions, not the API.
Agent Framework executes functions, so we emit this event for debugging visibility.
This is a DevUI extension because:
- OpenAI Responses API doesn't stream function results (clients execute functions)
- Agent Framework executes functions server-side, so we stream results for debugging visibility
- ResponseFunctionToolCallOutputItem exists in OpenAI SDK but isn't in ResponseOutputItem union
(it's for Conversations API input, not Responses API streaming output)
This event provides the same structure as OpenAI's function output items but wrapped
in a custom event type since standard events don't support streaming function results.
"""
type: Literal["response.function_result.complete"] = "response.function_result.complete"
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="/agentframework.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Framework Dev UI</title>
<script type="module" crossorigin src="/assets/index-ZIs_B0ln.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BhFnsoso.css">
<script type="module" crossorigin src="/assets/index-DmL7WSFa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CE4pGoXh.css">
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -120,7 +120,7 @@ Agent Framework content types → OpenAI Responses API events (in `_mapper.py`):
| `TextReasoningContent` | `response.reasoning.delta` | Standard |
| `FunctionCallContent` (initial) | `response.output_item.added` | Standard |
| `FunctionCallContent` (args) | `response.function_call_arguments.delta` | Standard |
| `FunctionResultContent` | `response.function_result.complete` | Standard |
| `FunctionResultContent` | `response.function_result.complete` | DevUI |
| `ErrorContent` | `response.error` | Standard |
| `UsageContent` | `response.usage.complete` | Extended |
| `WorkflowEvent` | `response.workflow.event` | DevUI |
+2 -1
View File
@@ -26,7 +26,8 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12"
"tailwindcss": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
+161 -245
View File
@@ -3,90 +3,105 @@
* Features: Entity selection, layout management, debug coordination
*/
import { useState, useEffect, useCallback } from "react";
import { AppHeader, DebugPanel, SettingsModal } from "@/components/layout";
import { useEffect, useCallback } from "react";
import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout";
import { GalleryView } from "@/components/features/gallery";
import { AgentView } from "@/components/features/agent";
import { WorkflowView } from "@/components/features/workflow";
import { LoadingState } from "@/components/ui/loading-state";
import { Toast } from "@/components/ui/toast";
import { apiClient } from "@/services/api";
import { PanelRightOpen, ChevronDown, ServerOff } from "lucide-react";
import type { SampleEntity } from "@/data/gallery";
import { PanelRightOpen, ChevronDown, ServerOff, Rocket } from "lucide-react";
import type {
AgentInfo,
WorkflowInfo,
AppState,
ExtendedResponseStreamEvent,
} from "@/types";
import { Button } from "./components/ui/button";
import { useDevUIStore } from "@/stores";
export default function App() {
const [appState, setAppState] = useState<AppState>({
agents: [],
workflows: [],
isLoading: true,
});
// Entity state from Zustand
const agents = useDevUIStore((state) => state.agents);
const workflows = useDevUIStore((state) => state.workflows);
const selectedAgent = useDevUIStore((state) => state.selectedAgent);
const isLoadingEntities = useDevUIStore((state) => state.isLoadingEntities);
const entityError = useDevUIStore((state) => state.entityError);
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>(
[]
);
const [showDebugPanel, setShowDebugPanel] = useState(() => {
const saved = localStorage.getItem("showDebugPanel");
return saved !== null ? saved === "true" : true;
});
const [debugPanelWidth, setDebugPanelWidth] = useState(() => {
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);
const [showEntityNotFoundToast, setShowEntityNotFoundToast] = useState(false);
// Entity actions
const setAgents = useDevUIStore((state) => state.setAgents);
const setWorkflows = useDevUIStore((state) => state.setWorkflows);
const selectEntity = useDevUIStore((state) => state.selectEntity);
const updateAgent = useDevUIStore((state) => state.updateAgent);
const updateWorkflow = useDevUIStore((state) => state.updateWorkflow);
const setIsLoadingEntities = useDevUIStore((state) => state.setIsLoadingEntities);
const setEntityError = useDevUIStore((state) => state.setEntityError);
// UI state from Zustand
const showDebugPanel = useDevUIStore((state) => state.showDebugPanel);
const debugPanelWidth = useDevUIStore((state) => state.debugPanelWidth);
const debugEvents = useDevUIStore((state) => state.debugEvents);
const isResizing = useDevUIStore((state) => state.isResizing);
// UI actions
const setShowDebugPanel = useDevUIStore((state) => state.setShowDebugPanel);
const setDebugPanelWidth = useDevUIStore((state) => state.setDebugPanelWidth);
const addDebugEvent = useDevUIStore((state) => state.addDebugEvent);
const clearDebugEvents = useDevUIStore((state) => state.clearDebugEvents);
const setIsResizing = useDevUIStore((state) => state.setIsResizing);
// Modal state
const showAboutModal = useDevUIStore((state) => state.showAboutModal);
const showGallery = useDevUIStore((state) => state.showGallery);
const showDeployModal = useDevUIStore((state) => state.showDeployModal);
const showEntityNotFoundToast = useDevUIStore((state) => state.showEntityNotFoundToast);
// Modal actions
const setShowAboutModal = useDevUIStore((state) => state.setShowAboutModal);
const setShowGallery = useDevUIStore((state) => state.setShowGallery);
const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal);
const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast);
// Initialize app - load agents and workflows
useEffect(() => {
const loadData = async () => {
try {
const [agents, workflows] = await Promise.all([
apiClient.getAgents(),
apiClient.getWorkflows(),
]);
// Single API call instead of two parallel calls to same endpoint
const { agents: agentList, workflows: workflowList } = await apiClient.getEntities();
setAgents(agentList);
setWorkflows(workflowList);
// Check if there's an entity_id in the URL
const urlParams = new URLSearchParams(window.location.search);
const entityId = urlParams.get("entity_id");
let selectedAgent: AgentInfo | WorkflowInfo | undefined;
let selectedEntity: AgentInfo | WorkflowInfo | undefined;
// Try to find entity from URL parameter first
if (entityId) {
selectedAgent =
agents.find((a) => a.id === entityId) ||
workflows.find((w) => w.id === entityId);
selectedEntity =
agentList.find((a) => a.id === entityId) ||
workflowList.find((w) => w.id === entityId);
// If entity not found but was requested, show notification
if (!selectedAgent) {
if (!selectedEntity) {
setShowEntityNotFoundToast(true);
}
}
// Fallback to first available entity if URL entity not found
if (!selectedAgent) {
selectedAgent =
agents.length > 0
? agents[0]
: workflows.length > 0
? workflows[0]
if (!selectedEntity) {
selectedEntity =
agentList.length > 0
? agentList[0]
: workflowList.length > 0
? workflowList[0]
: undefined;
// Update URL to match actual selected entity (or clear if none)
if (selectedAgent) {
if (selectedEntity) {
const url = new URL(window.location.href);
url.searchParams.set("entity_id", selectedAgent.id);
url.searchParams.set("entity_id", selectedEntity.id);
window.history.replaceState({}, "", url);
} else {
// Clear entity_id if no entities available
@@ -96,34 +111,44 @@ export default function App() {
}
}
setAppState((prev) => ({
...prev,
agents,
workflows,
selectedAgent,
isLoading: false,
}));
if (selectedEntity) {
selectEntity(selectedEntity);
// Load full info for the first entity immediately
if (selectedEntity.metadata?.lazy_loaded === false) {
try {
if (selectedEntity.type === "agent") {
const fullAgent = await apiClient.getAgentInfo(
selectedEntity.id
);
updateAgent(fullAgent);
} else {
const fullWorkflow = await apiClient.getWorkflowInfo(
selectedEntity.id
);
updateWorkflow(fullWorkflow);
}
} catch (error) {
console.error(
`Failed to load full info for first entity ${selectedEntity.id}:`,
error
);
}
}
}
setIsLoadingEntities(false);
} catch (error) {
console.error("Failed to load agents/workflows:", error);
setAppState((prev) => ({
...prev,
error: error instanceof Error ? error.message : "Failed to load data",
isLoading: false,
}));
setEntityError(
error instanceof Error ? error.message : "Failed to load data"
);
setIsLoadingEntities(false);
}
};
loadData();
}, []);
// Save debug panel state to localStorage
useEffect(() => {
localStorage.setItem("showDebugPanel", showDebugPanel.toString());
}, [showDebugPanel]);
useEffect(() => {
localStorage.setItem("debugPanelWidth", debugPanelWidth.toString());
}, [debugPanelWidth]);
}, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast]);
// Handle resize drag
const handleMouseDown = useCallback(
@@ -155,161 +180,43 @@ export default function App() {
[debugPanelWidth]
);
// Handle entity selection
const handleEntitySelect = useCallback((item: AgentInfo | WorkflowInfo) => {
setAppState((prev) => ({
...prev,
selectedAgent: item,
currentThread: undefined,
}));
// Handle entity selection - uses Zustand's selectEntity which handles ALL side effects
const handleEntitySelect = useCallback(
async (item: AgentInfo | WorkflowInfo) => {
selectEntity(item); // This clears conversation state, debug events, and updates URL!
// Update URL with selected entity ID
const url = new URL(window.location.href);
url.searchParams.set("entity_id", item.id);
window.history.pushState({}, "", url);
// Clear debug events when switching entities
setDebugEvents([]);
}, []);
// If entity is sparse (not fully loaded), load full details
if (item.metadata?.lazy_loaded === false) {
try {
if (item.type === "agent") {
const fullAgent = await apiClient.getAgentInfo(item.id);
updateAgent(fullAgent);
} else {
const fullWorkflow = await apiClient.getWorkflowInfo(item.id);
updateWorkflow(fullWorkflow);
}
} catch (error) {
console.error(`Failed to load full info for ${item.id}:`, error);
}
}
},
[selectEntity, updateAgent, updateWorkflow]
);
// Handle debug events from active view
const handleDebugEvent = useCallback(
(event: ExtendedResponseStreamEvent | "clear") => {
if (event === "clear") {
setDebugEvents([]);
clearDebugEvents();
} else {
setDebugEvents((prev) => [...prev, event]);
addDebugEvent(event);
}
},
[]
);
// 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,
}));
// Update URL with new entity
const url = new URL(window.location.href);
url.searchParams.set("entity_id", agentEntity.id);
window.history.pushState({}, "", url);
} 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,
}));
// Update URL with new entity
const url = new URL(window.location.href);
url.searchParams.set("entity_id", workflowEntity.id);
window.history.pushState({}, "", url);
}
// 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,
}));
// Update URL - clear entity_id if we removed the selected entity
if (appState.selectedAgent?.id === entityId) {
const url = new URL(window.location.href);
url.searchParams.delete("entity_id");
window.history.pushState({}, "", url);
setDebugEvents([]);
}
} catch (error) {
console.error("Failed to remove entity:", error);
}
},
[appState.selectedAgent?.id]
[addDebugEvent, clearDebugEvents]
);
// Show loading state while initializing
if (appState.isLoading) {
if (isLoadingEntities) {
return (
<div className="h-screen flex flex-col bg-background">
{/* Top Bar - Skeleton */}
@@ -322,17 +229,18 @@ export default function App() {
</header>
{/* Loading Content */}
<LoadingState
message="Initializing DevUI..."
description="Loading agents and workflows from your configuration"
fullPage={true}
/>
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="text-lg font-medium">Initializing DevUI...</div>
<div className="text-sm text-muted-foreground mt-2">Loading agents and workflows from your configuration</div>
</div>
</div>
</div>
);
}
// Show error state if loading failed
if (appState.error) {
if (entityError) {
return (
<div className="h-screen flex flex-col bg-background">
<AppHeader
@@ -340,7 +248,6 @@ export default function App() {
workflows={[]}
selectedItem={undefined}
onSelect={() => {}}
onRemove={handleRemoveEntity}
isLoading={false}
onSettingsClick={() => setShowAboutModal(true)}
/>
@@ -388,14 +295,14 @@ export default function App() {
</div>
{/* Error Details (Collapsible) */}
{appState.error && (
{entityError && (
<details className="text-left group">
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
Error details
</summary>
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
{appState.error}
{entityError}
</p>
</details>
)}
@@ -420,13 +327,12 @@ export default function App() {
return (
<div className="h-screen flex flex-col bg-background max-h-screen">
<AppHeader
agents={appState.agents}
workflows={appState.workflows}
selectedItem={appState.selectedAgent}
agents={agents}
workflows={workflows}
selectedItem={selectedAgent}
onSelect={handleEntitySelect}
onRemove={handleRemoveEntity}
onBrowseGallery={() => setShowGallery(true)}
isLoading={appState.isLoading}
isLoading={isLoadingEntities}
onSettingsClick={() => setShowAboutModal(true)}
/>
@@ -437,40 +343,28 @@ export default function App() {
<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
agents.length > 0 || workflows.length > 0
}
/>
</div>
) : appState.agents.length === 0 && appState.workflows.length === 0 ? (
) : agents.length === 0 && 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}
/>
<GalleryView variant="inline" />
) : (
<>
{/* Left Panel - Main View */}
<div className="flex-1 min-w-0">
{appState.selectedAgent ? (
appState.selectedAgent.type === "agent" ? (
{selectedAgent ? (
selectedAgent.type === "agent" ? (
<AgentView
selectedAgent={appState.selectedAgent as AgentInfo}
selectedAgent={selectedAgent as AgentInfo}
onDebugEvent={handleDebugEvent}
/>
) : (
<WorkflowView
selectedWorkflow={appState.selectedAgent as WorkflowInfo}
selectedWorkflow={selectedAgent as WorkflowInfo}
onDebugEvent={handleDebugEvent}
/>
)
@@ -503,7 +397,7 @@ export default function App() {
{/* Right Panel - Debug */}
<div
className="flex-shrink-0"
className="flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]"
style={{ width: `${debugPanelWidth}px` }}
>
<DebugPanel
@@ -511,6 +405,21 @@ export default function App() {
isStreaming={false} // Each view manages its own streaming state
onClose={() => setShowDebugPanel(false)}
/>
{/* Deploy Footer - Pinned to bottom */}
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
<Button
onClick={() => setShowDeployModal(true)}
className="w-full"
variant="outline"
size="sm"
>
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate text-xs">
Deployment Guide for {selectedAgent?.name || "Agent"}
</span>
</Button>
</div>
</div>
</>
) : (
@@ -534,6 +443,13 @@ export default function App() {
{/* Settings Modal */}
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
{/* Deployment Modal */}
<DeploymentModal
open={showDeployModal}
onClose={() => setShowDeployModal(false)}
agentName={selectedAgent?.name}
/>
{/* Toast Notification */}
{showEntityNotFoundToast && (
<Toast
@@ -34,6 +34,8 @@ import {
FileText,
Check,
X,
Copy,
CheckCheck,
} from "lucide-react";
import { apiClient } from "@/services/api";
import type {
@@ -41,13 +43,8 @@ import type {
RunAgentRequest,
Conversation,
ExtendedResponseStreamEvent,
PendingApproval,
} from "@/types";
interface ChatState {
items: import("@/types/openai").ConversationItem[]; // Pure OpenAI types - no legacy ChatMessage
isStreaming: boolean;
}
import { useDevUIStore } from "@/stores";
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
@@ -61,14 +58,46 @@ interface ConversationItemBubbleProps {
}
function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
const [isHovered, setIsHovered] = useState(false);
const [copied, setCopied] = useState(false);
// Extract text content from message for copying
const getMessageText = () => {
if (item.type === "message") {
return item.content
.filter((c) => c.type === "text")
.map((c) => (c as import("@/types/openai").MessageTextContent).text)
.join("\n");
}
return "";
};
const handleCopy = async () => {
const text = getMessageText();
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
// Handle different item types
if (item.type === "message") {
const isUser = item.role === "user";
const isError = item.status === "incomplete";
const Icon = isUser ? User : isError ? AlertCircle : Bot;
const messageText = getMessageText();
return (
<div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
<div
className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
className={`flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border ${
isUser
@@ -86,26 +115,48 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
isUser ? "items-end" : "items-start"
} max-w-[80%]`}
>
<div
className={`rounded px-3 py-2 text-sm break-all ${
isUser
? "bg-primary text-primary-foreground"
: isError
? "bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800"
: "bg-muted"
}`}
>
{isError && (
<div className="flex items-start gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="font-medium text-sm">
Unable to process request
</span>
<div className="relative group">
<div
className={`rounded px-3 py-2 text-sm break-all ${
isUser
? "bg-primary text-primary-foreground"
: isError
? "bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800"
: "bg-muted"
}`}
>
{isError && (
<div className="flex items-start gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="font-medium text-sm">
Unable to process request
</span>
</div>
)}
<div className={isError ? "text-xs leading-relaxed break-all" : ""}>
<OpenAIMessageRenderer item={item} />
</div>
)}
<div className={isError ? "text-xs leading-relaxed break-all" : ""}>
<OpenAIMessageRenderer item={item} />
</div>
{/* Copy button - appears on hover, always top-right inside */}
{messageText && isHovered && (
<button
onClick={handleCopy}
className="absolute top-1 right-1
p-1.5 rounded-md border shadow-sm
bg-background hover:bg-accent
text-muted-foreground hover:text-foreground
transition-all duration-200 ease-in-out
opacity-0 group-hover:opacity-100"
title={copied ? "Copied!" : "Copy message"}
>
{copied ? (
<CheckCheck className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
@@ -115,10 +166,10 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
<span></span>
<span className="flex items-center gap-1">
<span className="text-blue-600 dark:text-blue-400">
{item.usage.input_tokens}
{item.usage.input_tokens}
</span>
<span className="text-green-600 dark:text-green-400">
{item.usage.output_tokens}
{item.usage.output_tokens}
</span>
<span>({item.usage.total_tokens} tokens)</span>
</span>
@@ -146,33 +197,35 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
}
export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const [chatState, setChatState] = useState<ChatState>({
items: [],
isStreaming: false,
});
const [currentConversation, setCurrentConversation] = useState<
Conversation | undefined
>(undefined);
const [availableConversations, setAvailableConversations] = useState<
Conversation[]
>([]);
const [inputValue, setInputValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [loadingConversations, setLoadingConversations] = useState(false);
// Get conversation state from Zustand
const currentConversation = useDevUIStore((state) => state.currentConversation);
const availableConversations = useDevUIStore((state) => state.availableConversations);
const chatItems = useDevUIStore((state) => state.chatItems);
const isStreaming = useDevUIStore((state) => state.isStreaming);
const isSubmitting = useDevUIStore((state) => state.isSubmitting);
const loadingConversations = useDevUIStore((state) => state.loadingConversations);
const inputValue = useDevUIStore((state) => state.inputValue);
const attachments = useDevUIStore((state) => state.attachments);
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
// Get conversation actions from Zustand (only the ones we actually use)
const setCurrentConversation = useDevUIStore((state) => state.setCurrentConversation);
const setAvailableConversations = useDevUIStore((state) => state.setAvailableConversations);
const setChatItems = useDevUIStore((state) => state.setChatItems);
const setIsStreaming = useDevUIStore((state) => state.setIsStreaming);
const setIsSubmitting = useDevUIStore((state) => state.setIsSubmitting);
const setLoadingConversations = useDevUIStore((state) => state.setLoadingConversations);
const setInputValue = useDevUIStore((state) => state.setInputValue);
const setAttachments = useDevUIStore((state) => state.setAttachments);
const updateConversationUsage = useDevUIStore((state) => state.updateConversationUsage);
const setPendingApprovals = useDevUIStore((state) => state.setPendingApprovals);
// Local UI state (not in Zustand - component-specific)
const [isDragOver, setIsDragOver] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const [pasteNotification, setPasteNotification] = useState<string | null>(
null
);
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [conversationUsage, setConversationUsage] = useState<{
total_tokens: number;
message_count: number;
}>({ total_tokens: 0, message_count: 0 });
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>(
[]
);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -183,18 +236,48 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
input_tokens: number;
output_tokens: number;
} | null>(null);
const userJustSentMessage = useRef<boolean>(false);
// Auto-scroll to bottom when new items arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chatState.items, chatState.isStreaming]);
if (!messagesEndRef.current) return;
// Check if user is near bottom (within 100px)
const scrollContainer = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]');
let shouldScroll = false;
if (scrollContainer) {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
// Always scroll if user just sent a message, otherwise only if near bottom
shouldScroll = userJustSentMessage.current || isNearBottom;
} else {
// Fallback if scroll container not found - always scroll
shouldScroll = true;
}
if (shouldScroll) {
// Use instant scroll during streaming for smooth chunk additions
// Use smooth scroll when not streaming (new messages)
messagesEndRef.current.scrollIntoView({
behavior: isStreaming ? "instant" : "smooth"
});
}
// Reset the flag after first scroll
if (userJustSentMessage.current && !isStreaming) {
userJustSentMessage.current = false;
}
}, [chatItems, isStreaming]);
// Return focus to input after streaming completes
useEffect(() => {
if (!chatState.isStreaming && !isSubmitting) {
if (!isStreaming && !isSubmitting) {
textareaRef.current?.focus();
}
}, [chatState.isStreaming, isSubmitting]);
}, [isStreaming, isSubmitting]);
// Load conversations when agent changes
useEffect(() => {
@@ -223,12 +306,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
);
// Use OpenAI ConversationItems directly (no conversion!)
setChatState({
items: items as import("@/types/openai").ConversationItem[],
isStreaming: false
});
setChatItems(items as import("@/types/openai").ConversationItem[]);
setIsStreaming(false);
} catch {
setChatState({ items: [], isStreaming: false });
// 404 means conversation exists but has no items yet (newly created)
// This is normal - just start with empty chat
console.debug(`No items found for conversation ${mostRecent.id}, starting fresh`);
setChatItems([]);
setIsStreaming(false);
}
// Cache to localStorage for faster future loads
@@ -252,11 +337,24 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const convs = JSON.parse(cached) as Conversation[];
if (convs.length > 0) {
// Use most recent cached conversation
setAvailableConversations(convs);
setCurrentConversation(convs[0]);
setChatState({ items: [], isStreaming: false });
return;
// Validate that cached conversations still exist in backend
// Try to load items for the most recent one to verify it exists
try {
await apiClient.listConversationItems(convs[0].id);
// Success! Conversation exists in backend
setAvailableConversations(convs);
setCurrentConversation(convs[0]);
setChatItems([]);
setIsStreaming(false);
return;
} catch {
// Cached conversation doesn't exist anymore (server restarted)
// Clear stale cache and create new conversation
console.debug(`Cached conversation ${convs[0].id} no longer exists, clearing cache`);
localStorage.removeItem(cachedKey);
// Fall through to Step 3
}
}
} catch {
// Invalid cache - clear it
@@ -271,28 +369,28 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setCurrentConversation(newConversation);
setAvailableConversations([newConversation]);
setChatState({ items: [], isStreaming: false });
setChatItems([]);
setIsStreaming(false);
// Save to localStorage
localStorage.setItem(cachedKey, JSON.stringify([newConversation]));
} catch {
setAvailableConversations([]);
setChatState({ items: [], isStreaming: false });
setChatItems([]);
setIsStreaming(false);
} finally {
setLoadingConversations(false);
}
};
// Clear chat when agent changes
setChatState({
items: [],
isStreaming: false,
});
setChatItems([]);
setIsStreaming(false);
setCurrentConversation(undefined);
accumulatedText.current = "";
loadConversations();
}, [selectedAgent]);
}, [selectedAgent, setLoadingConversations, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]);
// Handle file uploads
const handleFilesSelected = async (files: File[]) => {
@@ -315,11 +413,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
});
}
setAttachments((prev) => [...prev, ...newAttachments]);
setAttachments([...useDevUIStore.getState().attachments, ...newAttachments]);
};
const handleRemoveAttachment = (id: string) => {
setAttachments((prev) => prev.filter((att) => att.id !== id));
setAttachments(useDevUIStore.getState().attachments.filter((att) => att.id !== id));
};
// Drag and drop handlers
@@ -353,7 +451,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setIsDragOver(false);
setDragCounter(0);
if (isSubmitting || chatState.isStreaming) return;
if (isSubmitting || isStreaming) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
@@ -523,12 +621,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
agent_id: selectedAgent.id,
});
setCurrentConversation(newConversation);
setAvailableConversations((prev) => [newConversation, ...prev]);
setChatState({
items: [],
isStreaming: false,
});
setConversationUsage({ total_tokens: 0, message_count: 0 });
setAvailableConversations([newConversation, ...useDevUIStore.getState().availableConversations]);
setChatItems([]);
setIsStreaming(false);
// Reset conversation usage by setting it to initial state
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
accumulatedText.current = "";
// Update localStorage cache with new conversation
@@ -538,7 +635,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
} catch {
// Failed to create conversation
}
}, [selectedAgent, availableConversations]);
}, [selectedAgent, availableConversations, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
// Handle conversation deletion
const handleDeleteConversation = useCallback(
@@ -578,18 +675,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Select the most recent remaining conversation
const nextConversation = updatedConversations[0];
setCurrentConversation(nextConversation);
setChatState({
items: [],
isStreaming: false,
});
setChatItems([]);
setIsStreaming(false);
} else {
// No conversations left, clear everything
setCurrentConversation(undefined);
setChatState({
items: [],
isStreaming: false,
});
setConversationUsage({ total_tokens: 0, message_count: 0 });
setChatItems([]);
setIsStreaming(false);
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
accumulatedText.current = "";
}
}
@@ -601,7 +694,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
alert("Failed to delete conversation. Please try again.");
}
},
[availableConversations, currentConversation, selectedAgent, onDebugEvent]
[availableConversations, currentConversation, selectedAgent, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]
);
// Handle conversation selection
@@ -624,28 +717,28 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Use OpenAI ConversationItems directly (no conversion!)
const items = result.data as import("@/types/openai").ConversationItem[];
setChatState({
items,
isStreaming: false,
});
setChatItems(items);
setIsStreaming(false);
// Calculate usage from loaded items
setConversationUsage({
total_tokens: 0, // We don't have usage info in stored items
message_count: items.length,
useDevUIStore.setState({
conversationUsage: {
total_tokens: 0, // We don't have usage info in stored items
message_count: items.length,
}
});
} catch {
// Fallback to clearing items
setChatState({
items: [],
isStreaming: false,
});
setConversationUsage({ total_tokens: 0, message_count: 0 });
// 404 means conversation doesn't exist or has no items yet
// This can happen if server restarted (in-memory store cleared)
console.debug(`No items found for conversation ${conversationId}, starting with empty chat`);
setChatItems([]);
setIsStreaming(false);
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
}
accumulatedText.current = "";
},
[availableConversations, onDebugEvent]
[availableConversations, onDebugEvent, setCurrentConversation, setChatItems, setIsStreaming]
);
// Handle function approval responses
@@ -677,8 +770,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
};
// Remove from pending immediately (will be confirmed by backend event)
setPendingApprovals((prev) =>
prev.filter((a) => a.request_id !== request_id)
setPendingApprovals(
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== request_id)
);
// Trigger send (we'll call this from the UI button handler)
@@ -729,11 +822,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
status: "completed",
};
setChatState((prev) => ({
...prev,
items: [...prev.items, userMessage],
isStreaming: true,
}));
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
setIsStreaming(true);
// Create assistant message placeholder
const assistantMessage: import("@/types/openai").ConversationMessage = {
@@ -744,10 +834,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
status: "in_progress",
};
setChatState((prev) => ({
...prev,
items: [...prev.items, assistantMessage],
}));
setChatItems([...useDevUIStore.getState().chatItems, assistantMessage]);
try {
// If no conversation selected, create one automatically
@@ -758,7 +845,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
agent_id: selectedAgent.id,
});
setCurrentConversation(conversationToUse);
setAvailableConversations((prev) => [conversationToUse!, ...prev]);
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
} catch {
// Failed to create conversation
}
@@ -772,9 +859,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Clear text accumulator for new response
accumulatedText.current = "";
// Clear debug panel events for new agent run
onDebugEvent("clear");
// Use OpenAI-compatible API streaming - direct event handling
const streamGenerator = apiClient.streamAgentExecutionOpenAI(
selectedAgent.id,
@@ -805,8 +889,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
// Add to pending approvals
setPendingApprovals((prev) => [
...prev,
setPendingApprovals([
...useDevUIStore.getState().pendingApprovals,
{
request_id: approvalEvent.request_id,
function_call: approvalEvent.function_call,
@@ -820,8 +904,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const responseEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRespondedEvent;
// Remove from pending approvals
setPendingApprovals((prev) =>
prev.filter((a) => a.request_id !== responseEvent.request_id)
setPendingApprovals(
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== responseEvent.request_id)
);
continue;
}
@@ -834,24 +918,22 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const errorMessage = errorEvent.message || "An error occurred";
// Update assistant message with error and stop streaming
setChatState((prev) => ({
...prev,
isStreaming: false,
items: prev.items.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: errorMessage,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
),
}));
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: errorMessage,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
));
setIsStreaming(false);
return; // Exit stream processing early on error
}
@@ -864,23 +946,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
accumulatedText.current += openAIEvent.delta;
// Update assistant message with accumulated content
setChatState((prev) => ({
...prev,
items: prev.items.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: accumulatedText.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
}
: item
),
}));
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: accumulatedText.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
}
: item
));
}
// Handle completion/error by detecting when streaming stops
@@ -891,56 +971,49 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Usage is provided via response.completed event (OpenAI standard)
const finalUsage = currentMessageUsage.current;
setChatState((prev) => ({
...prev,
isStreaming: false,
items: prev.items.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
status: "completed" as const,
usage: finalUsage || undefined,
}
: item
),
}));
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
status: "completed" as const,
usage: finalUsage || undefined,
}
: item
));
setIsStreaming(false);
// Update conversation-level usage stats
if (finalUsage) {
setConversationUsage((prev) => ({
total_tokens: prev.total_tokens + finalUsage.total_tokens,
message_count: prev.message_count + 1,
}));
updateConversationUsage(finalUsage.total_tokens);
}
// Reset usage for next message
currentMessageUsage.current = null;
} catch (error) {
setChatState((prev) => ({
...prev,
isStreaming: false,
items: prev.items.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
),
}));
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
));
setIsStreaming(false);
}
},
[selectedAgent, currentConversation, onDebugEvent]
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage]
);
const handleSubmit = async (e: React.FormEvent) => {
@@ -953,6 +1026,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
)
return;
// Set flag to force scroll when user sends message
userJustSentMessage.current = true;
setIsSubmitting(true);
const messageText = inputValue.trim();
setInputValue("");
@@ -1056,7 +1132,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const canSendMessage =
selectedAgent &&
!isSubmitting &&
!chatState.isStreaming &&
!isStreaming &&
(inputValue.trim() || attachments.length > 0);
return (
@@ -1183,7 +1259,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
{/* Messages */}
<ScrollArea className="flex-1 p-4 h-0" ref={scrollAreaRef}>
<div className="space-y-4">
{chatState.items.length === 0 ? (
{chatItems.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
<div className="text-muted-foreground text-sm">
Start a conversation with{" "}
@@ -1194,7 +1270,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
</div>
</div>
) : (
chatState.items.map((item) => (
chatItems.map((item) => (
<ConversationItemBubble key={item.id} item={item} />
))
)}
@@ -1339,13 +1415,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
placeholder={`Message ${
selectedAgent.name || selectedAgent.id
}... (Shift+Enter for new line)`}
disabled={isSubmitting || chatState.isStreaming}
disabled={isSubmitting || isStreaming}
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
style={{ fieldSizing: "content" } as React.CSSProperties}
/>
<FileUpload
onFilesSelected={handleFilesSelected}
disabled={isSubmitting || chatState.isStreaming}
disabled={isSubmitting || isStreaming}
/>
<Button
type="submit"
@@ -9,10 +9,11 @@ import {
FileText,
Code,
ChevronDown,
ChevronUp,
ChevronRight,
Music,
} from "lucide-react";
import type { MessageContent } from "@/types/openai";
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
interface ContentRendererProps {
content: MessageContent;
@@ -22,51 +23,15 @@ interface ContentRendererProps {
// Text content renderer
function TextContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "text") return null;
const text = content.text;
const TRUNCATE_LENGTH = 1600;
const shouldTruncate = text.length > TRUNCATE_LENGTH;
const displayText =
shouldTruncate && !isExpanded
? text.slice(0, TRUNCATE_LENGTH) + "..."
: text;
return (
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
<div
className={
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
}
>
{displayText}
{isStreaming && text.length > 0 && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
{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 className={`break-words ${className || ""}`}>
<MarkdownRenderer content={text} />
{isStreaming && text.length > 0 && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
);
@@ -223,7 +188,7 @@ export function FunctionCallRenderer({ name, arguments: args, className }: Funct
}
return (
<div className={`my-2 p-3 border rounded-lg bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
<div className={`my-2 p-3 border rounded bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -232,7 +197,11 @@ export function FunctionCallRenderer({ name, arguments: args, className }: Funct
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">
Function Call: {name}
</span>
<span className="text-xs text-blue-600 dark:text-blue-400">{isExpanded ? "▼" : "▶"}</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
) : (
<ChevronRight className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
)}
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
@@ -264,7 +233,7 @@ export function FunctionResultRenderer({ output, call_id, className }: FunctionR
}
return (
<div className={`my-2 p-3 border rounded-lg bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
<div className={`my-2 p-3 border rounded bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -273,7 +242,11 @@ export function FunctionResultRenderer({ output, call_id, className }: FunctionR
<span className="text-sm font-medium text-green-800 dark:text-green-300">
Function Result
</span>
<span className="text-xs text-green-600 dark:text-green-400">{isExpanded ? "▼" : "▶"}</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
) : (
<ChevronRight className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
)}
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
@@ -17,15 +17,13 @@ import {
import {
Bot,
Workflow,
Plus,
Loader2,
User,
TriangleAlert,
AlertCircle,
X,
Key,
ChevronDown,
ArrowLeft,
Download,
BookOpen,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
@@ -33,13 +31,9 @@ import {
type SampleEntity,
getDifficultyColor,
} from "@/data/gallery";
import { SetupInstructionsModal } from "./setup-instructions-modal";
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;
@@ -48,91 +42,41 @@ interface GalleryViewProps {
// 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 [showInstructions, setShowInstructions] = useState(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}
<>
<Card className="hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full">
<CardHeader className="pb-3 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<TypeIcon className="h-5 w-5" />
<Badge variant="secondary" className="text-xs">
{sample.type}
</Badge>
</div>
<Badge
variant="outline"
className={cn(
"text-xs border",
getDifficultyColor(sample.difficulty)
)}
>
{sample.difficulty}
</Badge>
</div>
<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>
<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>
)}
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
<div className="space-y-3 min-w-0">
{/* Tags */}
@@ -199,73 +143,57 @@ function SampleEntityCard({
</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>
<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>
</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>
{/* Action Buttons */}
<div className="w-full flex gap-2">
<Button asChild className="flex-1" size="sm">
<a
href={sample.url}
download={`${sample.id}.py`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-4 w-4 mr-2" />
Download
</a>
</Button>
<Button
variant="outline"
className="flex-1"
size="sm"
onClick={() => setShowInstructions(true)}
>
<BookOpen className="h-4 w-4 mr-2" />
Setup Guide
</Button>
</div>
</CardFooter>
</Card>
<SetupInstructionsModal
sample={sample}
open={showInstructions}
onOpenChange={setShowInstructions}
/>
</>
);
}
// Internal: Sample Entity Grid Component
function SampleEntityGrid({
samples,
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;
}) {
function SampleEntityGrid({ samples }: { samples: SampleEntity[] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{samples.map((sample) => (
<div key={sample.id} className="min-w-0">
<SampleEntityCard
sample={sample}
onAdd={onAdd}
isAdding={addingEntityId === sample.id}
hasError={errorEntityId === sample.id}
errorMessage={errorMessage}
onClearError={onClearError}
/>
<SampleEntityCard sample={sample} />
</div>
))}
</div>
@@ -274,11 +202,6 @@ function SampleEntityGrid({
// Main: Gallery View Component
export function GalleryView({
onAdd,
addingEntityId,
errorEntityId,
errorMessage,
onClearError,
onClose,
variant = "inline",
hasExistingEntities = false,
@@ -304,8 +227,8 @@ export function GalleryView({
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.
Browse the sample agents and workflows below. Download them,
review the code, and run them locally to get started quickly.
</p>
</div>
</div>
@@ -314,14 +237,7 @@ export function GalleryView({
{/* 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}
/>
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
</div>
{/* Footer */}
@@ -362,22 +278,15 @@ export function GalleryView({
<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.
Browse sample agents and workflows to learn the Agent
Framework. Download these curated examples and run them locally.
Examples range from beginner to advanced.
</p>
</div>
</div>
{/* Sample Gallery */}
<SampleEntityGrid
samples={SAMPLE_ENTITIES}
onAdd={onAdd}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={onClearError}
/>
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
{/* Footer */}
<div className="text-center mt-12 pt-8 border-t">
@@ -399,14 +308,5 @@ export function GalleryView({
}
// Modal variant - for dropdown trigger (simplified, just the grid)
return (
<SampleEntityGrid
samples={SAMPLE_ENTITIES}
onAdd={onAdd}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={onClearError}
/>
);
return <SampleEntityGrid samples={SAMPLE_ENTITIES} />;
}
@@ -0,0 +1,207 @@
/**
* SetupInstructionsModal - Shows step-by-step instructions for running a sample entity
*/
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Download,
ExternalLink,
Copy,
Check,
Lightbulb,
BookOpen,
} from "lucide-react";
import type { SampleEntity } from "@/data/gallery";
interface SetupInstructionsModalProps {
sample: SampleEntity;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function CodeBlock({ children, copyable = false }: { children: string; copyable?: boolean }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative">
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono">
<code>{children}</code>
</pre>
{copyable && (
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-6 w-6 p-0"
onClick={handleCopy}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
)}
</div>
);
}
function SetupStep({
number,
title,
description,
code,
action,
copyable = false,
}: {
number: number;
title: string;
description?: string;
code?: string;
action?: React.ReactNode;
copyable?: boolean;
}) {
return (
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold">
{number}
</div>
</div>
<div className="flex-1 space-y-2">
<h4 className="font-semibold">{title}</h4>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
{code && <CodeBlock copyable={copyable}>{code}</CodeBlock>}
{action && <div>{action}</div>}
</div>
</div>
);
}
export function SetupInstructionsModal({
sample,
open,
onOpenChange,
}: SetupInstructionsModalProps) {
const hasEnvVars = sample.requiredEnvVars && sample.requiredEnvVars.length > 0;
const stepOffset = hasEnvVars ? 0 : -1;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle>Setup: {sample.name}</DialogTitle>
<DialogDescription>
Follow these steps to run this sample {sample.type} locally
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-6">
<ScrollArea className="h-[500px]">
<div className="space-y-6 pr-4">
{/* Step 1: Download */}
<SetupStep
number={1}
title="Download the sample file"
action={
<Button asChild size="sm">
<a
href={sample.url}
download={`${sample.id}.py`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-4 w-4 mr-2" />
Download {sample.id}.py
</a>
</Button>
}
/>
{/* Step 2: Create folder */}
<SetupStep
number={2}
title="Create a project folder"
description="Create a dedicated folder for this sample and move the downloaded file there:"
code={`mkdir -p ~/my-agents/${sample.id}\nmv ~/Downloads/${sample.id}.py ~/my-agents/${sample.id}/`}
copyable
/>
{/* Step 3: Environment variables (conditional) */}
{hasEnvVars && (
<SetupStep
number={3}
title="Set up environment variables"
description="Create a .env file in the project folder with these required variables:"
code={sample.requiredEnvVars!
.map((v) => `${v.name}=${v.example || "your-value-here"}\n# ${v.description}`)
.join("\n\n")}
copyable
/>
)}
{/* Step 4: Run DevUI */}
<SetupStep
number={4 + stepOffset}
title="Run with DevUI"
description="Navigate to the folder and start DevUI:"
code={`cd ~/my-agents/${sample.id}\ndevui .`}
copyable
/>
{/* Alternative: Direct run */}
<Alert>
<Lightbulb className="h-4 w-4" />
<AlertTitle>Alternative: Run Programmatically</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-2">You can also run the {sample.type} directly in Python:</p>
<CodeBlock copyable>
{`from ${sample.id} import ${sample.type}
import asyncio
async def main():
response = await ${sample.type}.run("Hello!")
print(response)
asyncio.run(main())`}
</CodeBlock>
</AlertDescription>
</Alert>
{/* Help links */}
<div className="flex gap-2 pt-4 border-t">
<Button variant="outline" size="sm" asChild>
<a href={sample.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
View Source
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<a
href="https://github.com/microsoft/agent-framework#readme"
target="_blank"
rel="noopener noreferrer"
>
<BookOpen className="h-4 w-4 mr-2" />
Documentation
</a>
</Button>
</div>
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
@@ -32,22 +32,29 @@ interface FormFieldProps {
isRequired?: boolean;
}
// Helper: Determine if field is likely a short metadata field (not multiline text)
function isShortField(fieldName: string): boolean {
const shortFieldNames = ['name', 'title', 'id', 'key', 'label', 'type', 'status', 'tag', 'category', 'code', 'username', 'password'];
return shortFieldNames.includes(fieldName.toLowerCase());
}
function FormField({ name, schema, value, onChange, isRequired = false }: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
// For text/message/content fields, treat as textarea for better UX
const isTextContentField = ['text', 'message', 'content', 'query', 'prompt'].includes(name.toLowerCase());
// Determine if this should be a textarea based on JSON Schema format field
// or heuristics (long descriptions, specific field types)
const shouldBeTextarea =
schema.format === "textarea" || // Explicit format from backend
(description && description.length > 100) || // Long description suggests multiline
(type === "string" && !enumValues && !isShortField(name)); // Default strings to textarea unless they're short metadata fields
// Determine if this field should span full width
// Only span full if it's a textarea or has very long description
const shouldSpanFullWidth =
schema.format === "textarea" ||
isTextContentField || // text/message fields span full width
shouldBeTextarea ||
(description && description.length > 150);
const shouldSpanTwoColumns =
schema.format === "textarea" ||
isTextContentField ||
shouldBeTextarea ||
(description && description.length > 80) ||
type === "array"; // Arrays might need more space for comma-separated values
@@ -90,8 +97,7 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
</div>
);
} else if (
schema.format === "textarea" ||
isTextContentField ||
shouldBeTextarea ||
(description && description.length > 100)
) {
// Multi-line text (including text/message/content fields)
@@ -110,7 +116,8 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
? defaultValue
: `Enter ${name}`
}
rows={isTextContentField ? 4 : 2}
rows={shouldBeTextarea ? 4 : 2}
className="min-w-[300px] w-full"
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
@@ -201,7 +201,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">
<DialogContent className="w-full min-w-[400px] max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-8 pt-6">
<DialogTitle>Configure Workflow Inputs</DialogTitle>
<DialogClose onClose={() => setShowModal(false)} />
@@ -14,7 +14,6 @@ interface AppHeaderProps {
workflows: WorkflowInfo[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onRemove?: (entityId: string) => void;
onBrowseGallery?: () => void;
isLoading?: boolean;
onSettingsClick?: () => void;
@@ -25,7 +24,6 @@ export function AppHeader({
workflows,
selectedItem,
onSelect,
onRemove,
onBrowseGallery,
isLoading = false,
onSettingsClick,
@@ -66,7 +64,6 @@ export function AppHeader({
workflows={workflows}
selectedItem={selectedItem}
onSelect={onSelect}
onRemove={onRemove}
onBrowseGallery={onBrowseGallery}
isLoading={isLoading}
/>
@@ -24,6 +24,40 @@ import {
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
// Simple visual separator component
function MessageSeparator() {
return (
<div className="flex items-center gap-2 py-3 px-2">
<div className="flex-1 border-t border-border/50" />
</div>
);
}
// Helper to add separators between message rounds
function addSeparatorsToEvents(events: ExtendedResponseStreamEvent[]): (ExtendedResponseStreamEvent | { type: "separator"; id: string })[] {
const result: (ExtendedResponseStreamEvent | { type: "separator"; id: string })[] = [];
let lastWasResponseDone = false;
for (let i = 0; i < events.length; i++) {
const event = events[i];
// Add separator before first event after response.done
if (lastWasResponseDone && event.type !== "response.done") {
result.push({ type: "separator", id: `sep-${i}` });
lastWasResponseDone = false;
}
result.push(event);
// Track when we see response.done
if (event.type === "response.done" || event.type === "response.completed") {
lastWasResponseDone = true;
}
}
return result;
}
// Type definitions for event data structures
interface EventDataBase {
call_id?: string;
@@ -64,7 +98,7 @@ interface DebugPanelProps {
onClose?: () => void;
}
// Helper: Extract function result from DevUI custom format
// Helper: Extract function result from DevUI custom event
function getFunctionResultFromEvent(event: ExtendedResponseStreamEvent): {
call_id: string;
output: string;
@@ -101,9 +135,18 @@ function processEventsForDisplay(
let accumulatedText = "";
for (const event of events) {
// Skip trace events - they belong in the Traces tab only
if (
event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete"
) {
continue;
}
// Handle response.output_item.added - NEW! Extract function call metadata
if (event.type === "response.output_item.added") {
const outputEvent = event as import("@/types").ResponseOutputItemAddedEvent;
const outputEvent =
event as import("@/types").ResponseOutputItemAddedEvent;
const item = outputEvent.item;
// If it's a function call item, extract metadata
@@ -388,15 +431,17 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
}
return "Function arguments...";
case "response.function_result.complete": {
const resultEvent =
event as import("@/types").ResponseFunctionResultComplete;
const truncated = resultEvent.output.slice(0, 40);
return `Function result: ${truncated}${
truncated.length >= 40 ? "..." : ""
}`;
}
case "response.output_item.added": {
const result = getFunctionResultFromEvent(event);
if (result) {
const truncated = result.output.slice(0, 40);
return `Tool result: ${truncated}${
truncated.length >= 40 ? "..." : ""
}`;
}
// Could also be a function call
// Could be a function call
const addedEvent =
event as import("@/types").ResponseOutputItemAddedEvent;
if (addedEvent.item.type === "function_call") {
@@ -422,7 +467,8 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
case "response.completed":
if ("response" in event && event.response && "usage" in event.response) {
const completedEvent = event as import("@/types").ResponseCompletedEvent;
const completedEvent =
event as import("@/types").ResponseCompletedEvent;
const usage = completedEvent.response.usage;
if (usage) {
return `Response complete (${usage.total_tokens} tokens)`;
@@ -453,6 +499,8 @@ function getEventIcon(type: string) {
case "response.function_call.delta":
case "response.function_call_arguments.delta":
return Wrench;
case "response.function_result.complete":
return CheckCircle2;
case "response.output_item.added":
return CheckCircle2;
case "response.workflow_event.complete":
@@ -479,6 +527,8 @@ function getEventColor(type: string) {
case "response.function_call.delta":
case "response.function_call_arguments.delta":
return "text-blue-600 dark:text-blue-400";
case "response.function_result.complete":
return "text-green-600 dark:text-green-400";
case "response.output_item.added":
return "text-green-600 dark:text-green-400";
case "response.workflow_event.complete":
@@ -509,6 +559,7 @@ function EventItem({ event }: EventItemProps) {
(event.type === "response.function_call.complete" &&
"data" in event &&
event.data) ||
event.type === "response.function_result.complete" ||
(event.type === "response.output_item.added" &&
getFunctionResultFromEvent(event) !== null) ||
(event.type === "response.workflow_event.complete" &&
@@ -681,6 +732,53 @@ function EventExpandedContent({
}
break;
case "response.function_result.complete": {
const resultEvent =
event as import("@/types").ResponseFunctionResultComplete;
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span className="font-semibold text-sm">Function Result</span>
</div>
<div className="grid grid-cols-1 gap-2 text-xs">
<div>
<span className="font-medium text-muted-foreground">
Call ID:
</span>
<span className="ml-2 font-mono text-xs">
{resultEvent.call_id}
</span>
</div>
<div>
<span className="font-medium text-muted-foreground">
Status:
</span>
<span
className={`ml-2 px-2 py-1 rounded text-xs font-medium ${
resultEvent.status === "completed"
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
}`}
>
{resultEvent.status}
</span>
</div>
<div>
<span className="font-medium text-muted-foreground">
Output:
</span>
<div className="mt-1 max-h-32 overflow-auto">
<pre className="text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all">
{resultEvent.output}
</pre>
</div>
</div>
</div>
</div>
);
}
case "response.output_item.added": {
const result = getFunctionResultFromEvent(event);
if (result) {
@@ -915,7 +1013,8 @@ function EventExpandedContent({
case "response.completed":
if ("response" in event && event.response) {
const completedEvent = event as import("@/types").ResponseCompletedEvent;
const completedEvent =
event as import("@/types").ResponseCompletedEvent;
const response = completedEvent.response;
return (
<div className="space-y-2">
@@ -1006,11 +1105,14 @@ function EventsTab({
// Process events to accumulate tool calls and reduce noise
const processedEvents = processEventsForDisplay(events);
// Add separators between message rounds
const eventsWithSeparators = addSeparatorsToEvents(processedEvents);
// Reverse events so latest appears at top
const reversedEvents = [...processedEvents].reverse();
const reversedEvents = [...eventsWithSeparators].reverse();
return (
<div className="h-full">
<div className="h-full flex flex-col">
<div className="flex items-center justify-between p-3 border-b">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4" />
@@ -1030,7 +1132,7 @@ function EventsTab({
)}
</div>
<ScrollArea ref={scrollRef}>
<ScrollArea ref={scrollRef} className="flex-1">
<div className="p-3">
{processedEvents.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
@@ -1040,9 +1142,12 @@ function EventsTab({
</div>
) : (
<div className="space-y-2">
{reversedEvents.map((event, index) => (
<EventItem key={`${event.type}-${index}`} event={event} />
))}
{reversedEvents.map((event, index) => {
if ('type' in event && event.type === "separator") {
return <MessageSeparator key={(event as { type: "separator"; id: string }).id} />;
}
return <EventItem key={`${event.type}-${index}`} event={event as ExtendedResponseStreamEvent} />;
})}
</div>
)}
</div>
@@ -1059,18 +1164,21 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
e.type === "response.trace.complete"
);
// Add separators between message rounds
const tracesWithSeparators = addSeparatorsToEvents(traceEvents);
// Reverse to show latest traces at the top
const reversedTraceEvents = [...traceEvents].reverse();
const reversedTraceEvents = [...tracesWithSeparators].reverse();
return (
<div className="h-full">
<div className="h-full flex flex-col">
<div className="flex items-center gap-2 p-3 border-b">
<Search className="h-4 w-4" />
<span className="font-medium">Traces</span>
<Badge variant="outline">{traceEvents.length}</Badge>
</div>
<ScrollArea className="">
<ScrollArea className="flex-1">
<div className="p-3">
{traceEvents.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
@@ -1094,9 +1202,12 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
</div>
) : (
<div className="space-y-3">
{reversedTraceEvents.map((event, index) => (
<TraceEventItem key={index} event={event} />
))}
{reversedTraceEvents.map((event, index) => {
if ('type' in event && event.type === "separator") {
return <MessageSeparator key={(event as { type: "separator"; id: string }).id} />;
}
return <TraceEventItem key={index} event={event as ExtendedResponseStreamEvent} />;
})}
</div>
)}
</div>
@@ -1165,7 +1276,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
<ChevronRight className="h-3 w-3" />
)}
</div>
<div className="text-muted-foreground flex-1">
<div className="text-muted-foreground flex-1 break-all">
<span className="font-medium">{operationName}</span>
{entityId && <span className="ml-2 text-xs">({entityId})</span>}
</div>
@@ -1193,7 +1304,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
<span className="font-medium text-muted-foreground">
Span ID:
</span>
<span className="ml-2 font-mono text-xs">
<span className="ml-2 font-mono text-xs break-all">
{data.span_id}
</span>
</div>
@@ -1203,7 +1314,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
<span className="font-medium text-muted-foreground">
Trace ID:
</span>
<span className="ml-2 font-mono text-xs">
<span className="ml-2 font-mono text-xs break-all">
{data.trace_id}
</span>
</div>
@@ -1213,7 +1324,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
<span className="font-medium text-muted-foreground">
Parent Span:
</span>
<span className="ml-2 font-mono text-xs">
<span className="ml-2 font-mono text-xs break-all">
{data.parent_span_id}
</span>
</div>
@@ -1250,7 +1361,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
<span className="font-medium text-muted-foreground">
Entity:
</span>
<span className="ml-2 font-mono text-xs">
<span className="ml-2 font-mono text-xs break-all">
{data.entity_id}
</span>
</div>
@@ -1338,18 +1449,21 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
toolEvents.push(result);
});
// Add separators between message rounds
const toolsWithSeparators = addSeparatorsToEvents(toolEvents);
// Reverse to show latest tools at the top
const reversedToolEvents = [...toolEvents].reverse();
const reversedToolEvents = [...toolsWithSeparators].reverse();
return (
<div className="h-full">
<div className="h-full flex flex-col">
<div className="flex items-center gap-2 p-3 border-b">
<Wrench className="h-4 w-4" />
<span className="font-medium">Tools</span>
<Badge variant="outline">{toolEvents.length}</Badge>
</div>
<ScrollArea>
<ScrollArea className="flex-1">
<div className="p-3">
{toolEvents.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
@@ -1358,9 +1472,12 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
</div>
) : (
<div className="space-y-3">
{reversedToolEvents.map((event, index) => (
<ToolEventItem key={index} event={event} />
))}
{reversedToolEvents.map((event, index) => {
if ('type' in event && event.type === "separator") {
return <MessageSeparator key={(event as { type: "separator"; id: string }).id} />;
}
return <ToolEventItem key={index} event={event as ExtendedResponseStreamEvent} />;
})}
</div>
)}
</div>
@@ -1370,21 +1487,21 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
}
function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
if (!("data" in event)) {
return null;
}
const data = event.data as EventDataBase;
const timestamp = new Date().toLocaleTimeString();
// Check if this is a function call event
// Check if this is a function call or result event
const isFunctionCall = event.type === "response.function_call.complete";
const isFunctionResult = getFunctionResultFromEvent(event) !== null;
const resultData = getFunctionResultFromEvent(event);
const isFunctionResult = resultData !== null;
if (!isFunctionCall && !isFunctionResult) {
return null;
}
// For function calls: extract data field
const callData =
isFunctionCall && "data" in event ? (event.data as EventDataBase) : null;
return (
<div className="border rounded p-3">
<div className="flex items-center justify-between mb-2">
@@ -1393,9 +1510,9 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
<span className="font-medium text-sm">
{isFunctionCall ? "Tool Call" : "Tool Result"}
</span>
{isFunctionCall && data.name !== undefined && (
{isFunctionCall && callData && callData.name !== undefined && (
<span className="text-xs text-muted-foreground">
({String(data.name)})
({String(callData.name)})
</span>
)}
</div>
@@ -1405,7 +1522,7 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
</div>
{/* Function Calls */}
{isFunctionCall && (
{isFunctionCall && callData && (
<div className="p-2 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded">
<div className="flex items-center gap-2 mb-2">
<Wrench className="h-3 w-3 text-blue-600 dark:text-blue-400" />
@@ -1413,19 +1530,19 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
CALL
</span>
<span className="font-medium text-sm">
{String(data.name || "unknown")}
{String(callData.name || "unknown")}
</span>
</div>
{data.arguments !== undefined && (
{callData.arguments !== undefined && (
<div className="text-xs">
<span className="text-muted-foreground mb-1 block">
Arguments:
</span>
<pre className="p-2 bg-background border rounded text-xs overflow-auto max-h-32 max-w-full break-all whitespace-pre-wrap">
{typeof data.arguments === "string"
? data.arguments
: JSON.stringify(data.arguments, null, 1)}
{typeof callData.arguments === "string"
? callData.arguments
: JSON.stringify(callData.arguments, null, 1)}
</pre>
</div>
)}
@@ -1433,31 +1550,35 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
)}
{/* Function Results */}
{isFunctionResult && (
{isFunctionResult && resultData && (
<div className="p-2 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 rounded">
<div className="flex items-center gap-2 mb-2">
<CheckCircle2 className="h-3 w-3 text-green-600 dark:text-green-400" />
<span className="text-xs font-mono bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded">
RESULT
</span>
{/* Only show status badge for non-completed states (errors/incomplete) */}
{resultData.status !== "completed" && (
<span className="ml-auto px-2 py-1 rounded text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200">
{resultData.status}
</span>
)}
</div>
<div className="text-xs">
<span className="text-muted-foreground mb-1 block">Result:</span>
<pre className="p-2 bg-background border rounded text-xs overflow-auto max-h-32 max-w-full break-all whitespace-pre-wrap">
{typeof data.result === "string"
? data.result
: JSON.stringify(data.result, null, 1)}
</pre>
</div>
{data.exception !== null && data.exception !== undefined && (
<div className="mt-2 p-2 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded">
<span className="text-xs text-red-600 dark:text-red-400">
Error: {String(data.exception)}
<div className="text-xs space-y-1">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Call ID:</span>
<span className="font-mono text-xs break-all">
{resultData.call_id}
</span>
</div>
)}
<div>
<span className="text-muted-foreground block mb-1">Output:</span>
<pre className="p-2 bg-background border rounded text-xs overflow-auto max-h-32 break-all whitespace-pre-wrap">
{resultData.output}
</pre>
</div>
</div>
</div>
)}
</div>
@@ -1470,9 +1591,9 @@ export function DebugPanel({
onClose,
}: DebugPanelProps) {
return (
<div className=" overflow-auto h-[calc(100vh-3.7rem)] border-l">
<Tabs defaultValue="events" className="h-full flex flex-col">
<div className="px-3 pt-3 flex items-center gap-2">
<div className="flex-1 border-l flex flex-col min-h-0">
<Tabs defaultValue="events" className="flex-1 flex flex-col min-h-0">
<div className="px-3 pt-3 flex items-center gap-2 flex-shrink-0">
<TabsList className="flex-1">
<TabsTrigger value="events" className="flex-1">
Events
@@ -1497,15 +1618,15 @@ export function DebugPanel({
)}
</div>
<TabsContent value="events" className="flex-1 mt-0">
<TabsContent value="events" className="flex-1 mt-0 overflow-hidden">
<EventsTab events={events} isStreaming={isStreaming} />
</TabsContent>
<TabsContent value="traces" className="flex-1 mt-0">
<TabsContent value="traces" className="flex-1 mt-0 overflow-hidden">
<TracesTab events={events} />
</TabsContent>
<TabsContent value="tools" className="flex-1 mt-0">
<TabsContent value="tools" className="flex-1 mt-0 overflow-hidden">
<ToolsTab events={events} />
</TabsContent>
</Tabs>
@@ -0,0 +1,519 @@
/**
* DeploymentModal - Shows Azure deployment instructions and Docker templates
* Features: Docker setup files, Azure Container Apps deployment guide
*/
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Rocket,
Container,
Cloud,
Copy,
CheckCircle2,
ExternalLink,
} from "lucide-react";
interface DeploymentModalProps {
open: boolean;
onClose: () => void;
agentName?: string;
}
type Tab = "docker" | "azure";
export function DeploymentModal({
open,
onClose,
agentName = "Agent",
}: DeploymentModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("docker");
const [copiedTemplate, setCopiedTemplate] = useState<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const handleCopy = async (template: string, templateName: string) => {
try {
await navigator.clipboard.writeText(template);
setCopiedTemplate(templateName);
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout with cleanup
timeoutRef.current = setTimeout(() => {
setCopiedTemplate(null);
timeoutRef.current = null;
}, 2000);
} catch (err) {
console.error("Failed to copy template:", err);
// Reset state on error
setCopiedTemplate(null);
}
};
const dockerfileTemplate = `# Dockerfile for ${agentName}
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy agent/workflow directories
COPY . .
# Expose DevUI default port
EXPOSE 8080
# Run DevUI server
CMD ["devui", ".", "--port", "8080", "--host", "0.0.0.0"]
`;
const dockerComposeTemplate = `# docker-compose.yml
version: '3.8'
services:
${agentName.toLowerCase().replace(/\s+/g, "-")}:
build: .
environment:
# OpenAI
- OPENAI_API_KEY=\${OPENAI_API_KEY}
- OPENAI_CHAT_MODEL_ID=\${OPENAI_CHAT_MODEL_ID:-gpt-4o-mini}
# Or Azure OpenAI
- AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY}
- AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT}
- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=\${AZURE_OPENAI_CHAT_DEPLOYMENT_NAME}
# Optional: Enable tracing
- ENABLE_OTEL=\${ENABLE_OTEL:-false}
ports:
- "8080:8080"
restart: unless-stopped
`;
const requirementsTemplate = `# requirements.txt
agent-framework-devui>=0.1.0
agent-framework>=0.1.0
# Chat clients (install what you need)
openai>=1.0.0
# azure-openai
# anthropic
`;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="w-[800px] max-w-[90vw]">
<DialogClose onClose={onClose} />
<DialogHeader className="p-6 pb-2">
<DialogTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
Deploy {agentName}
</DialogTitle>
<p className="text-sm text-muted-foreground pt-1">
Get started with containerizing your agent for deployment.
</p>
</DialogHeader>
{/* Tabs */}
<div className="flex border-b px-6">
<button
onClick={() => setActiveTab("docker")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "docker"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Container className="h-4 w-4 mr-2 inline" />
Docker
{activeTab === "docker" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => setActiveTab("azure")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "azure"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Cloud className="h-4 w-4 mr-2 inline" />
Azure
{activeTab === "azure" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
</div>
{/* Tab Content */}
<div className="px-6 pb-6 min-h-[400px]">
<ScrollArea className="h-[500px]">
<div className="pr-4">
{activeTab === "docker" && (
<div className="space-y-4 pt-4">
<div>
<h3 className="font-semibold mb-2">
Containerize with Docker
</h3>
<p className="text-sm text-muted-foreground">
Package your agent as a Docker container for consistent
deployment anywhere.
</p>
</div>
{/* Dockerfile */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Dockerfile</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
handleCopy(dockerfileTemplate, "dockerfile")
}
>
{copiedTemplate === "dockerfile" ? (
<>
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
{dockerfileTemplate}
</pre>
</div>
{/* docker-compose.yml */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
docker-compose.yml
</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
handleCopy(dockerComposeTemplate, "compose")
}
>
{copiedTemplate === "compose" ? (
<>
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
{dockerComposeTemplate}
</pre>
</div>
{/* requirements.txt */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
requirements.txt
</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
handleCopy(requirementsTemplate, "requirements")
}
>
{copiedTemplate === "requirements" ? (
<>
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
{requirementsTemplate}
</pre>
</div>
{/* Quick Start */}
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3">
<h4 className="text-sm font-semibold mb-2">Quick Start</h4>
<ol className="text-xs space-y-1 list-decimal list-inside text-muted-foreground">
<li>Save the files above to your project directory</li>
<li>
Build:{" "}
<code className="bg-muted px-1 rounded">
docker build -t {agentName.toLowerCase()}-agent .
</code>
</li>
<li>
Run:{" "}
<code className="bg-muted px-1 rounded">
docker-compose up
</code>
</li>
<li>Your agent is now running in a container!</li>
</ol>
</div>
{/* Production Warnings */}
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded-md p-3">
<h4 className="text-sm font-semibold mb-2 text-amber-900 dark:text-amber-100">
Production Considerations
</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-amber-800 dark:text-amber-200">
<li>
<strong>In-memory state:</strong> Conversations are lost
when container restarts
</li>
<li>
<strong>No authentication:</strong> Add reverse proxy
(nginx, Caddy) with auth for production
</li>
<li>
<strong>Security:</strong> Use Azure Key Vault for
secrets management
</li>
<li>
<strong>Scaling:</strong> Single instance only due to
in-memory conversation store
</li>
</ul>
</div>
{/* Deployment Checklist */}
<div className="border-t pt-4">
<h4 className="font-semibold text-sm mb-3">
Pre-Deployment Checklist
</h4>
<div className="space-y-2 text-sm">
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Set environment variables (API keys, secrets)
</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Test agent locally in container
</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Configure logging and monitoring
</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Set up error handling and retries
</span>
</div>
</div>
</div>
</div>
)}
{activeTab === "azure" && (
<div className="space-y-4 pt-4">
<div>
<h3 className="font-semibold mb-2">
Deploy to Azure Container Apps
</h3>
<p className="text-sm text-muted-foreground">
Azure Container Apps provides serverless containers with
auto-scaling and integrated monitoring.
</p>
</div>
{/* Prerequisites */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm">Prerequisites</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-muted-foreground">
<li>Azure subscription</li>
<li>
Azure CLI installed (
<code className="bg-muted px-1 rounded">
az --version
</code>
)
</li>
<li>Docker installed and running</li>
<li>
Logged in to Azure:{" "}
<code className="bg-muted px-1 rounded">az login</code>
</li>
</ul>
</div>
{/* Step-by-step */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Deployment Steps</h4>
<div className="space-y-3">
{/* Step 1 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
1
</div>
<h5 className="font-medium text-sm">
Create Azure Container Registry
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`# Create resource group
az group create --name myResourceGroup --location eastus
# Create container registry
az acr create --resource-group myResourceGroup \\
--name myregistry --sku Basic`}
</pre>
</div>
{/* Step 2 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
2
</div>
<h5 className="font-medium text-sm">
Build and Push Docker Image
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`# Build and push in one command
az acr build --registry myregistry \\
--image ${agentName.toLowerCase()}-agent:latest .`}
</pre>
</div>
{/* Step 3 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
3
</div>
<h5 className="font-medium text-sm">
Create Container Apps Environment
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`az containerapp env create --name myEnvironment \\
--resource-group myResourceGroup \\
--location eastus`}
</pre>
</div>
{/* Step 4 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
4
</div>
<h5 className="font-medium text-sm">
Deploy Container App
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`az containerapp create --name ${agentName.toLowerCase()}-app \\
--resource-group myResourceGroup \\
--environment myEnvironment \\
--image myregistry.azurecr.io/${agentName.toLowerCase()}-agent:latest \\
--target-port 8080 \\
--ingress 'external' \\
--registry-server myregistry.azurecr.io \\
--env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL_ID=gpt-4o-mini`}
</pre>
</div>
{/* Step 5 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
5
</div>
<h5 className="font-medium text-sm">
Get Application URL
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`az containerapp show --name ${agentName.toLowerCase()}-app \\
--resource-group myResourceGroup \\
--query properties.configuration.ingress.fqdn`}
</pre>
</div>
</div>
</div>
{/* Learn More */}
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3">
<h4 className="text-sm font-semibold mb-2">Learn More</h4>
<p className="text-xs text-muted-foreground mb-3">
Explore Azure Container Apps documentation for advanced
features like scaling, monitoring, and CI/CD integration.
</p>
<Button
size="sm"
variant="outline"
className="w-full"
asChild
>
<a
href="https://learn.microsoft.com/azure/container-apps/"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-3 w-3 mr-1" />
View Azure Container Apps Documentation
</a>
</Button>
</div>
</div>
)}
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,6 @@
/**
* EntitySelector - High-quality dropdown for selecting agents/workflows
* Features: Type indicators, tool counts, keyboard navigation, search
* EntitySelector - Dropdown for selecting agents/workflows
* Features: Loading states, descriptions, lazy loading indicators
*/
import { useState } from "react";
@@ -13,9 +13,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ChevronDown, Bot, Workflow, FolderOpen, Database, Globe, X, Plus } from "lucide-react";
import { ChevronDown, Bot, Workflow, Plus, Loader2 } from "lucide-react";
import type { AgentInfo, WorkflowInfo } from "@/types";
interface EntitySelectorProps {
@@ -23,7 +22,6 @@ interface EntitySelectorProps {
workflows: WorkflowInfo[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onRemove?: (entityId: string) => void;
onBrowseGallery?: () => void;
isLoading?: boolean;
}
@@ -32,30 +30,11 @@ const getTypeIcon = (type: "agent" | "workflow") => {
return type === "workflow" ? Workflow : Bot;
};
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({
agents,
workflows,
selectedItem,
onSelect,
onRemove,
onBrowseGallery,
isLoading = false,
}: EntitySelectorProps) {
@@ -72,11 +51,7 @@ export function EntitySelector({
const TypeIcon = selectedItem ? getTypeIcon(selectedItem.type) : Bot;
const displayName = selectedItem?.name || selectedItem?.id || "Select Agent or Workflow";
const itemCount =
selectedItem?.type === "workflow"
? (selectedItem as WorkflowInfo).executors?.length || 0
: (selectedItem as AgentInfo)?.tools?.length || 0;
const itemLabel = selectedItem?.type === "workflow" ? "executors" : "tools";
const isLoaded = selectedItem?.metadata?.lazy_loaded !== false;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
@@ -96,10 +71,8 @@ export function EntitySelector({
<div className="flex items-center gap-2 min-w-0">
<TypeIcon className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{displayName}</span>
{selectedItem && (
<Badge variant="secondary" className="ml-auto flex-shrink-0">
{itemCount} {itemLabel}
</Badge>
{selectedItem && !isLoaded && (
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin ml-auto flex-shrink-0" />
)}
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
@@ -116,53 +89,29 @@ export function EntitySelector({
Agents ({agents.length})
</DropdownMenuLabel>
{agents.map((agent) => {
const SourceIcon = getSourceIcon(agent.source);
const isAgentLoaded = agent.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={agent.id}
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center justify-between w-full gap-2">
<div
className="flex items-center gap-2 min-w-0 flex-1"
onClick={() => handleSelect(agent)}
>
<Bot className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium">
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{agent.name || agent.id}
</div>
{agent.description && (
<div className="text-xs text-muted-foreground truncate">
</span>
{isAgentLoaded && agent.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{agent.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<SourceIcon className="h-3 w-3 opacity-60" />
<Badge variant="outline" className="text-xs">
{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>
);
@@ -178,53 +127,29 @@ export function EntitySelector({
Workflows ({workflows.length})
</DropdownMenuLabel>
{workflows.map((workflow) => {
const SourceIcon = getSourceIcon(workflow.source);
const isWorkflowLoaded = workflow.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={workflow.id}
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center justify-between w-full gap-2">
<div
className="flex items-center gap-2 min-w-0 flex-1"
onClick={() => handleSelect(workflow)}
>
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium">
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{workflow.name || workflow.id}
</div>
{workflow.description && (
<div className="text-xs text-muted-foreground truncate">
</span>
{isWorkflowLoaded && workflow.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{workflow.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<SourceIcon className="h-3 w-3 opacity-60" />
<Badge variant="outline" className="text-xs">
{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>
);
@@ -7,3 +7,4 @@ export { EntitySelector } from "./entity-selector";
export { DebugPanel } from "./debug-panel";
export { SettingsModal } from "./settings-modal";
export { AboutModal } from "./about-modal";
export { DeploymentModal } from "./deployment-modal";
@@ -24,7 +24,7 @@ interface SettingsModalProps {
type Tab = "about" | "settings";
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("about");
const [activeTab, setActiveTab] = useState<Tab>("settings");
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";
@@ -73,19 +73,6 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
{/* 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 ${
@@ -99,35 +86,23 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => setActiveTab("about")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "about"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
About
{activeTab === "about" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
</div>
{/* Tab Content */}
<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 */}
@@ -188,6 +163,31 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
</div>
</div>
)}
{activeTab === "about" && (
<div className="space-y-4 pt-4">
<p className="text-sm text-muted-foreground">
DevUI is a sample app for getting started with Agent Framework.
</p>
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
"https://github.com/microsoft/agent-framework",
"_blank"
)
}
className="text-xs"
>
<ExternalLink className="h-3 w-3 mr-1" />
Learn More about Agent Framework
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,48 @@
/**
* Alert component - Simple alert/callout component
*/
import * as React from "react";
import { cn } from "@/lib/utils";
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
className
)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
@@ -0,0 +1,565 @@
/**
* Lightweight Markdown Renderer
*
* A minimal markdown renderer with zero dependencies for rendering LLM responses.
* Handles the most common markdown patterns without bloating bundle size.
*
* Supported syntax:
* - **bold** and __bold__
* - *italic* and _italic_
* - `inline code`
* - ```code blocks``` (with copy button on hover)
* - [links](url)
* - **[bold links](url)** and *[italic links](url)*
* - # Headers (H1-H6)
* - Lists (ordered and unordered)
* - > Blockquotes
* - Tables (| col1 | col2 |)
* - Horizontal rules (---)
*/
import React, { useState } from "react";
interface MarkdownRendererProps {
content: string;
className?: string;
}
interface CodeBlockProps {
code: string;
language?: string;
}
/**
* Code block component with copy button
*/
function CodeBlock({ code, language }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
// Cleanup timeout on unmount
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout and store reference
timeoutRef.current = setTimeout(() => {
setCopied(false);
timeoutRef.current = null;
}, 2000);
} catch (err) {
console.error("Failed to copy code:", err);
}
};
return (
<div className="relative group">
<pre className="my-3 p-3 bg-foreground/5 dark:bg-foreground/10 rounded overflow-x-auto border border-foreground/10">
<code className="text-xs font-mono block whitespace-pre-wrap break-words">
{language && (
<span className="opacity-60 text-[10px] mb-1 block uppercase">
{language}
</span>
)}
{code}
</code>
</pre>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md border shadow-sm
bg-background hover:bg-accent
text-muted-foreground hover:text-foreground
transition-all duration-200
opacity-0 group-hover:opacity-100"
title={copied ? "Copied!" : "Copy code"}
>
{copied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-green-600 dark:text-green-400"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
)}
</button>
</div>
);
}
/**
* Parse markdown text into React elements
*/
export function MarkdownRenderer({
content,
className = "",
}: MarkdownRendererProps) {
const lines = content.split("\n");
const elements: React.ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Code blocks (multiline)
if (line.trim().startsWith("```")) {
const codeLines: string[] = [];
const langMatch = line.trim().match(/^```(\w+)?/);
const language = langMatch?.[1] || "";
i++; // Skip opening ```
while (i < lines.length && !lines[i].trim().startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // Skip closing ```
elements.push(
<CodeBlock
key={elements.length}
code={codeLines.join("\n")}
language={language}
/>
);
continue;
}
// Headers
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const text = headerMatch[2];
const sizes = [
"text-2xl",
"text-xl",
"text-lg",
"text-base",
"text-sm",
"text-sm",
];
const className = `${
sizes[level - 1]
} font-semibold mt-4 mb-2 first:mt-0 break-words`;
// Render appropriate header level
const header =
level === 1 ? (
<h1 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h1>
) : level === 2 ? (
<h2 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h2>
) : level === 3 ? (
<h3 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h3>
) : level === 4 ? (
<h4 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h4>
) : level === 5 ? (
<h5 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h5>
) : (
<h6 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h6>
);
elements.push(header);
i++;
continue;
}
// Unordered lists
if (line.match(/^[\s]*[-*+]\s+/)) {
const listItems: string[] = [];
while (i < lines.length && lines[i].match(/^[\s]*[-*+]\s+/)) {
const itemText = lines[i].replace(/^[\s]*[-*+]\s+/, "");
listItems.push(itemText);
i++;
}
elements.push(
<ul
key={elements.length}
className="my-2 ml-4 list-disc space-y-1 break-words"
>
{listItems.map((item, idx) => (
<li key={idx} className="text-sm break-words">
{parseInlineMarkdown(item)}
</li>
))}
</ul>
);
continue;
}
// Ordered lists
if (line.match(/^[\s]*\d+\.\s+/)) {
const listItems: string[] = [];
while (i < lines.length && lines[i].match(/^[\s]*\d+\.\s+/)) {
const itemText = lines[i].replace(/^[\s]*\d+\.\s+/, "");
listItems.push(itemText);
i++;
}
elements.push(
<ol
key={elements.length}
className="my-2 ml-4 list-decimal space-y-1 break-words"
>
{listItems.map((item, idx) => (
<li key={idx} className="text-sm break-words">
{parseInlineMarkdown(item)}
</li>
))}
</ol>
);
continue;
}
// Tables
if (line.trim().startsWith("|") && line.trim().endsWith("|")) {
const tableLines: string[] = [];
// Collect all table lines
while (
i < lines.length &&
lines[i].trim().startsWith("|") &&
lines[i].trim().endsWith("|")
) {
tableLines.push(lines[i].trim());
i++;
}
// Parse table (need at least 2 lines: header + separator)
if (tableLines.length >= 2) {
const headerCells = tableLines[0]
.split("|")
.slice(1, -1)
.map((cell) => cell.trim());
// Check if second line is a separator (contains dashes)
const isSeparator = tableLines[1].match(/^\|[\s\-:|]+\|$/);
if (isSeparator) {
const bodyRows = tableLines.slice(2).map((row) =>
row
.split("|")
.slice(1, -1)
.map((cell) => cell.trim())
);
elements.push(
<div key={elements.length} className="my-3 overflow-x-auto">
<table className="min-w-full border border-foreground/10 text-sm">
<thead className="bg-foreground/5">
<tr>
{headerCells.map((header, idx) => (
<th
key={idx}
className="border-b border-foreground/10 px-3 py-2 text-left font-semibold break-words"
>
{parseInlineMarkdown(header)}
</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row, rowIdx) => (
<tr
key={rowIdx}
className="border-b border-foreground/5 last:border-b-0"
>
{row.map((cell, cellIdx) => (
<td
key={cellIdx}
className="px-3 py-2 border-r border-foreground/5 last:border-r-0 break-words"
>
{parseInlineMarkdown(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
continue;
}
}
// Not a valid table, render as regular paragraphs
for (const tableLine of tableLines) {
elements.push(
<p key={elements.length} className="my-1">
{parseInlineMarkdown(tableLine)}
</p>
);
}
continue;
}
// Blockquotes
if (line.trim().startsWith(">")) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith(">")) {
quoteLines.push(lines[i].replace(/^>\s?/, ""));
i++;
}
elements.push(
<blockquote
key={elements.length}
className="my-2 pl-4 border-l-4 border-current/30 opacity-80 italic break-words"
>
{quoteLines.map((quoteLine, idx) => (
<div key={idx} className="break-words">
{parseInlineMarkdown(quoteLine)}
</div>
))}
</blockquote>
);
continue;
}
// Horizontal rule
if (line.match(/^[\s]*[-*_]{3,}[\s]*$/)) {
elements.push(
<hr key={elements.length} className="my-4 border-t border-border" />
);
i++;
continue;
}
// Empty line
if (line.trim() === "") {
elements.push(<div key={elements.length} className="h-2" />);
i++;
continue;
}
// Regular paragraph
elements.push(
<p key={elements.length} className="my-1 break-words">
{parseInlineMarkdown(line)}
</p>
);
i++;
}
return (
<div className={`markdown-content break-words ${className}`}>
{elements}
</div>
);
}
/**
* Parse inline markdown patterns (bold, italic, code, links)
*/
function parseInlineMarkdown(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
// Pattern priority: code > bold > italic > links
// This prevents conflicts between overlapping patterns
while (remaining.length > 0) {
// Inline code (highest priority to avoid parsing inside code)
const codeMatch = remaining.match(/`([^`]+)`/);
if (codeMatch && codeMatch.index !== undefined) {
// Add text before code
if (codeMatch.index > 0) {
parts.push(
<span key={key++}>
{parseBoldItalicLinks(remaining.slice(0, codeMatch.index))}
</span>
);
}
// Add code
parts.push(
<code
key={key++}
className="px-1.5 py-0.5 bg-foreground/10 rounded text-xs font-mono border border-foreground/20"
>
{codeMatch[1]}
</code>
);
remaining = remaining.slice(codeMatch.index + codeMatch[0].length);
continue;
}
// No more special patterns, parse remaining text for bold/italic/links
parts.push(<span key={key++}>{parseBoldItalicLinks(remaining)}</span>);
break;
}
return parts;
}
/**
* Parse bold, italic, and links (after code has been extracted)
*/
function parseBoldItalicLinks(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
// Try to match patterns in order
// IMPORTANT: Handle **[link](url)** pattern first (bold markers around link)
const patterns = [
{ regex: /\*\*\[([^\]]+)\]\(([^)]+)\)\*\*/, component: "strong-link" }, // **[text](url)**
{ regex: /__\[([^\]]+)\]\(([^)]+)\)__/, component: "strong-link" }, // __[text](url)__
{ regex: /\*\[([^\]]+)\]\(([^)]+)\)\*/, component: "em-link" }, // *[text](url)*
{ regex: /_\[([^\]]+)\]\(([^)]+)\)_/, component: "em-link" }, // _[text](url)_
{ regex: /\[([^\]]+)\]\(([^)]+)\)/, component: "link" }, // [text](url)
{ regex: /\*\*(.+?)\*\*/, component: "strong" }, // **bold**
{ regex: /__(.+?)__/, component: "strong" }, // __bold__
{ regex: /\*(.+?)\*/, component: "em" }, // *italic*
{ regex: /_(.+?)_/, component: "em" }, // _italic_
];
let matched = false;
for (const pattern of patterns) {
const match = remaining.match(pattern.regex);
if (match && match.index !== undefined) {
// Add text before match
if (match.index > 0) {
parts.push(remaining.slice(0, match.index));
}
// Add matched element
if (pattern.component === "strong") {
parts.push(
<strong key={key++} className="font-semibold">
{match[1]}
</strong>
);
} else if (pattern.component === "em") {
parts.push(
<em key={key++} className="italic">
{match[1]}
</em>
);
} else if (pattern.component === "strong-link") {
// **[text](url)** - Bold link
const linkText = match[1];
const linkUrl = match[2];
const formattedLinkText = parseBoldItalicLinks(linkText);
parts.push(
<strong key={key++} className="font-semibold">
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-words"
>
{formattedLinkText}
</a>
</strong>
);
} else if (pattern.component === "em-link") {
// *[text](url)* - Italic link
const linkText = match[1];
const linkUrl = match[2];
const formattedLinkText = parseBoldItalicLinks(linkText);
parts.push(
<em key={key++} className="italic">
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-words"
>
{formattedLinkText}
</a>
</em>
);
} else if (pattern.component === "link") {
// [text](url) - Regular link
const linkText = match[1];
const linkUrl = match[2];
const formattedLinkText = parseBoldItalicLinks(linkText);
parts.push(
<a
key={key++}
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-words"
>
{formattedLinkText}
</a>
);
}
remaining = remaining.slice(match.index + match[0].length);
matched = true;
break;
}
}
// No pattern matched, add remaining text and exit
if (!matched) {
if (remaining.length > 0) {
parts.push(remaining);
}
break;
}
}
return parts;
}
@@ -26,7 +26,6 @@ interface BackendEntityInfo {
tools?: (string | Record<string, unknown>)[];
metadata: Record<string, unknown>;
source?: string;
original_url?: string;
// Agent-specific fields (present when type === "agent")
instructions?: string;
model?: string;
@@ -143,6 +142,7 @@ class ApiClient {
typeof entity.metadata?.module_path === "string"
? entity.metadata.module_path
: undefined,
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
// Agent-specific fields
instructions: entity.instructions,
model: entity.model,
@@ -168,6 +168,7 @@ class ApiClient {
typeof entity.metadata?.module_path === "string"
? entity.metadata.module_path
: undefined,
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
input_schema:
(entity.input_schema as unknown as import("@/types").JSONSchema) || {
type: "string",
@@ -504,31 +505,6 @@ class ApiClient {
body: JSON.stringify(request),
});
}
// Add entity from URL
async addEntity(url: string, metadata?: Record<string, unknown>): Promise<BackendEntityInfo> {
const response = await this.request<{ success: boolean; entity: BackendEntityInfo }>("/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
@@ -0,0 +1,309 @@
/**
* DevUI Unified Store - Single source of truth for all app state
* Organized into logical slices: entity, conversation, UI, gallery, modals
*/
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import type {
AgentInfo,
WorkflowInfo,
ExtendedResponseStreamEvent,
Conversation,
PendingApproval,
} from "@/types";
import type { ConversationItem } from "@/types/openai";
import type { AttachmentItem } from "@/components/ui/attachment-gallery";
// ========================================
// State Interface
// ========================================
interface DevUIState {
// Entity Management Slice
agents: AgentInfo[];
workflows: WorkflowInfo[];
selectedAgent: AgentInfo | WorkflowInfo | undefined;
isLoadingEntities: boolean;
entityError: string | null;
// Conversation Slice (per-agent state)
currentConversation: Conversation | undefined;
availableConversations: Conversation[];
chatItems: ConversationItem[];
isStreaming: boolean;
isSubmitting: boolean;
loadingConversations: boolean;
inputValue: string;
attachments: AttachmentItem[];
conversationUsage: {
total_tokens: number;
message_count: number;
};
pendingApprovals: PendingApproval[];
// UI Slice
showDebugPanel: boolean;
debugPanelWidth: number;
debugEvents: ExtendedResponseStreamEvent[];
isResizing: boolean;
// Modal Slice
showAboutModal: boolean;
showGallery: boolean;
showDeployModal: boolean;
showEntityNotFoundToast: boolean;
}
// ========================================
// Actions Interface
// ========================================
interface DevUIActions {
// Entity Actions
setAgents: (agents: AgentInfo[]) => void;
setWorkflows: (workflows: WorkflowInfo[]) => void;
setSelectedAgent: (agent: AgentInfo | WorkflowInfo | undefined) => void;
addAgent: (agent: AgentInfo) => void;
addWorkflow: (workflow: WorkflowInfo) => void;
updateAgent: (agent: AgentInfo) => void;
updateWorkflow: (workflow: WorkflowInfo) => void;
removeEntity: (entityId: string) => void;
setEntityError: (error: string | null) => void;
setIsLoadingEntities: (loading: boolean) => void;
// Conversation Actions
setCurrentConversation: (conv: Conversation | undefined) => void;
setAvailableConversations: (convs: Conversation[]) => void;
setChatItems: (items: ConversationItem[]) => void;
setIsStreaming: (streaming: boolean) => void;
setIsSubmitting: (submitting: boolean) => void;
setLoadingConversations: (loading: boolean) => void;
setInputValue: (value: string) => void;
setAttachments: (files: AttachmentItem[]) => void;
updateConversationUsage: (tokens: number) => void;
setPendingApprovals: (approvals: PendingApproval[]) => void;
// UI Actions
setShowDebugPanel: (show: boolean) => void;
setDebugPanelWidth: (width: number) => void;
addDebugEvent: (event: ExtendedResponseStreamEvent) => void;
clearDebugEvents: () => void;
setIsResizing: (resizing: boolean) => void;
// Modal Actions
setShowAboutModal: (show: boolean) => void;
setShowGallery: (show: boolean) => void;
setShowDeployModal: (show: boolean) => void;
setShowEntityNotFoundToast: (show: boolean) => void;
// Combined Actions (handle multiple state updates + side effects)
selectEntity: (entity: AgentInfo | WorkflowInfo) => void;
}
type DevUIStore = DevUIState & DevUIActions;
// ========================================
// Store Implementation
// ========================================
export const useDevUIStore = create<DevUIStore>()(
devtools(
persist(
(set) => ({
// ========================================
// Initial State
// ========================================
// Entity State
agents: [],
workflows: [],
selectedAgent: undefined,
isLoadingEntities: true,
entityError: null,
// Conversation State
currentConversation: undefined,
availableConversations: [],
chatItems: [],
isStreaming: false,
isSubmitting: false,
loadingConversations: false,
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
pendingApprovals: [],
// UI State
showDebugPanel: true,
debugPanelWidth: 320,
debugEvents: [],
isResizing: false,
// Modal State
showAboutModal: false,
showGallery: false,
showDeployModal: false,
showEntityNotFoundToast: false,
// ========================================
// Entity Actions
// ========================================
setAgents: (agents) => set({ agents }),
setWorkflows: (workflows) => set({ workflows }),
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
addAgent: (agent) =>
set((state) => ({ agents: [...state.agents, agent] })),
addWorkflow: (workflow) =>
set((state) => ({ workflows: [...state.workflows, workflow] })),
updateAgent: (updatedAgent) =>
set((state) => ({
agents: state.agents.map((a) =>
a.id === updatedAgent.id ? updatedAgent : a
),
// Also update selectedAgent if it's the same one
selectedAgent:
state.selectedAgent?.id === updatedAgent.id &&
state.selectedAgent.type === "agent"
? updatedAgent
: state.selectedAgent,
})),
updateWorkflow: (updatedWorkflow) =>
set((state) => ({
workflows: state.workflows.map((w) =>
w.id === updatedWorkflow.id ? updatedWorkflow : w
),
// Also update selectedAgent if it's the same one
selectedAgent:
state.selectedAgent?.id === updatedWorkflow.id &&
state.selectedAgent.type === "workflow"
? updatedWorkflow
: state.selectedAgent,
})),
removeEntity: (entityId) =>
set((state) => ({
agents: state.agents.filter((a) => a.id !== entityId),
workflows: state.workflows.filter((w) => w.id !== entityId),
selectedAgent:
state.selectedAgent?.id === entityId
? undefined
: state.selectedAgent,
})),
setEntityError: (error) => set({ entityError: error }),
setIsLoadingEntities: (loading) => set({ isLoadingEntities: loading }),
// ========================================
// Conversation Actions
// ========================================
setCurrentConversation: (conv) => set({ currentConversation: conv }),
setAvailableConversations: (convs) =>
set({ availableConversations: convs }),
setChatItems: (items) => set({ chatItems: items }),
setIsStreaming: (streaming) => set({ isStreaming: streaming }),
setIsSubmitting: (submitting) => set({ isSubmitting: submitting }),
setLoadingConversations: (loading) =>
set({ loadingConversations: loading }),
setInputValue: (value) => set({ inputValue: value }),
setAttachments: (files) => set({ attachments: files }),
updateConversationUsage: (tokens) =>
set((state) => ({
conversationUsage: {
total_tokens: state.conversationUsage.total_tokens + tokens,
message_count: state.conversationUsage.message_count + 1,
},
})),
setPendingApprovals: (approvals) => set({ pendingApprovals: approvals }),
// ========================================
// UI Actions
// ========================================
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
setDebugPanelWidth: (width) => set({ debugPanelWidth: width }),
addDebugEvent: (event) =>
set((state) => ({ debugEvents: [...state.debugEvents, event] })),
clearDebugEvents: () => set({ debugEvents: [] }),
setIsResizing: (resizing) => set({ isResizing: resizing }),
// ========================================
// Modal Actions
// ========================================
setShowAboutModal: (show) => set({ showAboutModal: show }),
setShowGallery: (show) => set({ showGallery: show }),
setShowDeployModal: (show) => set({ showDeployModal: show }),
setShowEntityNotFoundToast: (show) =>
set({ showEntityNotFoundToast: show }),
// ========================================
// Combined Actions
// ========================================
/**
* Select an entity (agent/workflow) and handle all side effects:
* - Update selected entity
* - Clear conversation state (FIXES THE BUG!)
* - Clear debug events
* - Update URL
*/
selectEntity: (entity) => {
set({
selectedAgent: entity,
// CRITICAL: Clear all conversation state when switching entities
currentConversation: undefined,
availableConversations: [], // Let AgentView reload conversations
chatItems: [],
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
// Clear debug events when switching
debugEvents: [],
});
// Update URL with selected entity ID
const url = new URL(window.location.href);
url.searchParams.set("entity_id", entity.id);
window.history.pushState({}, "", url);
},
}),
{
name: "devui-storage",
// Only persist UI preferences, not runtime state
partialize: (state) => ({
showDebugPanel: state.showDebugPanel,
debugPanelWidth: state.debugPanelWidth,
}),
}
),
{ name: "DevUI Store" }
)
);
// ========================================
// Usage Notes
// ========================================
/**
* How to use the store:
*
* 1. For state access, use direct selectors:
* const agents = useDevUIStore((state) => state.agents);
*
* 2. For actions, extract them:
* const setAgents = useDevUIStore((state) => state.setAgents);
*
* 3. For combined state access (use sparingly, can cause unnecessary re-renders):
* const { agents, workflows } = useDevUIStore((state) => ({
* agents: state.agents,
* workflows: state.workflows
* }));
*
* 4. To access state outside React components:
* useDevUIStore.getState().agents
* useDevUIStore.getState().setAgents([...])
*/
@@ -0,0 +1,5 @@
/**
* Store exports - single entry point for all store hooks
*/
export { useDevUIStore } from "./devuiStore";
@@ -31,6 +31,7 @@ export interface AgentInfo {
has_env: boolean;
module_path?: string;
required_env_vars?: EnvVarRequirement[];
metadata?: Record<string, unknown>; // Backend metadata including lazy_loaded flag
// Agent-specific fields
instructions?: string;
model?: string;
@@ -103,8 +104,8 @@ export type {
ResponseWorkflowEventComplete,
ResponseTraceEventComplete,
ResponseOutputItemAddedEvent,
ResponseFunctionResultComplete,
ResponseCompletedEvent,
ResponseFunctionResultComplete,
StructuredEvent,
} from "./openai";
@@ -36,17 +36,6 @@ export interface ResponseWorkflowEventComplete {
sequence_number: number;
}
// Custom DevUI: Function result event
// This is a DevUI extension - OpenAI doesn't stream function execution results
export interface ResponseFunctionResultComplete {
type: "response.function_result.complete";
call_id: string;
output: string;
status: "in_progress" | "completed" | "incomplete";
item_id: string;
output_index: number;
sequence_number: number;
}
// Function call event types - matching actual backend output
export interface ResponseFunctionCallComplete {
@@ -173,6 +162,24 @@ export interface ResponseFunctionApprovalRespondedEvent {
sequence_number: number;
}
// DevUI Extension: Function Result Complete
export interface ResponseFunctionResultComplete {
type: "response.function_result.complete";
call_id: string;
output: string;
status: "in_progress" | "completed" | "incomplete";
item_id: string;
output_index: number;
sequence_number: number;
}
// DevUI Extension: Turn Separator (UI-only event for grouping)
export interface TurnSeparatorEvent {
type: "debug.turn_separator";
timestamp: string;
collapsed?: boolean;
}
// Union type for all structured events
export type StructuredEvent =
| ResponseCompletedEvent
@@ -180,13 +187,14 @@ export type StructuredEvent =
| ResponseTraceEventComplete
| ResponseTraceComplete
| ResponseOutputItemAddedEvent
| ResponseFunctionResultComplete
| ResponseFunctionCallComplete
| ResponseFunctionCallDelta
| ResponseFunctionCallArgumentsDelta
| ResponseFunctionResultComplete
| ResponseErrorEvent
| ResponseFunctionApprovalRequestedEvent
| ResponseFunctionApprovalRespondedEvent;
| ResponseFunctionApprovalRespondedEvent
| TurnSeparatorEvent;
// Extended stream event that includes our structured events
export type ExtendedResponseStreamEvent = ResponseStreamEvent | StructuredEvent;
@@ -16,19 +16,9 @@ export default defineConfig({
emptyOutDir: true,
rollupOptions: {
output: {
// Minimize to just 2 files: main app + CSS
manualChunks: undefined,
// Ensure everything goes into a single JS file
inlineDynamicImports: true,
},
},
},
// Ensure proper tree-shaking
optimizeDeps: {
include: ["lucide-react", "@xyflow/react"],
},
// Enable aggressive tree-shaking
esbuild: {
treeShaking: true,
},
});
+6 -1
View File
@@ -2063,7 +2063,7 @@ natural-compare@^1.4.0:
next-themes@^0.4.6:
version "0.4.6"
resolved "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz"
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.4.6.tgz#8d7e92d03b8fea6582892a50a928c9b23502e8b6"
integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==
node-releases@^2.0.19:
@@ -2453,3 +2453,8 @@ zustand@^4.4.0:
integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==
dependencies:
use-sync-external-store "^1.2.2"
zustand@^5.0.8:
version "5.0.8"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.8.tgz#b998a0c088c7027a20f2709141a91cb07ac57f8a"
integrity sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==
+223 -8
View File
@@ -68,7 +68,11 @@ async def test_empty_directory():
async def test_discovery_accepts_agents_with_only_run():
"""Test that discovery accepts agents with only run() method."""
"""Test that discovery accepts agents with only run() method.
With lazy loading, entities with only __init__.py are discovered
but marked as "unknown" type until loaded.
"""
import tempfile
from pathlib import Path
@@ -110,13 +114,224 @@ agent = NonStreamingAgent()
discovery = EntityDiscovery(str(temp_path))
entities = await discovery.discover_entities()
# Should discover the non-streaming agent
agents = [e for e in entities if e.type == "agent"]
assert len(agents) == 1
# ID is auto-generated, just check it exists and starts with agent_
assert agents[0].id.startswith("agent_")
assert agents[0].name == "Non-Streaming Agent"
assert not agents[0].metadata.get("has_run_stream")
# With lazy loading, entity is discovered but type is "unknown"
# (no agent.py or workflow.py to detect type from)
assert len(entities) == 1
entity = entities[0]
assert entity.id == "non_streaming_agent"
assert entity.type == "unknown" # Type not yet determined
assert entity.tools == [] # Sparse metadata
# Trigger lazy loading to get full metadata
agent_obj = await discovery.load_entity(entity.id)
assert agent_obj is not None
# Now check enriched metadata after loading
enriched = discovery.get_entity_info(entity.id)
assert enriched.type == "agent" # Now correctly identified
assert enriched.name == "Non-Streaming Agent"
assert not enriched.metadata.get("has_run_stream")
async def test_lazy_loading():
"""Test that entities are loaded on-demand, not at discovery time."""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create test workflow
workflow_dir = temp_path / "test_workflow"
workflow_dir.mkdir()
(workflow_dir / "workflow.py").write_text("""
from agent_framework import WorkflowBuilder, FunctionExecutor
# Create a simple workflow with a start executor
def test_func(input: str) -> str:
return f"Processed: {input}"
builder = WorkflowBuilder()
executor = FunctionExecutor(id="test_executor", func=test_func)
builder.set_start_executor(executor)
workflow = builder.build()
""")
discovery = EntityDiscovery(str(temp_path))
# Discovery should NOT import module
entities = await discovery.discover_entities()
assert len(entities) == 1
assert entities[0].id == "test_workflow"
assert entities[0].type == "workflow" # Type detected from filename
assert entities[0].tools == [] # Sparse metadata (not loaded yet)
# Entity should NOT be in loaded_objects yet
assert discovery.get_entity_object("test_workflow") is None
# Trigger lazy load
workflow_obj = await discovery.load_entity("test_workflow")
assert workflow_obj is not None
# Now in cache
assert discovery.get_entity_object("test_workflow") is workflow_obj
# Second load is instant (from cache)
workflow_obj2 = await discovery.load_entity("test_workflow")
assert workflow_obj2 is workflow_obj # Same object
async def test_type_detection():
"""Test that entity types are detected from filenames."""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create workflow with workflow.py
workflow_dir = temp_path / "my_workflow"
workflow_dir.mkdir()
(workflow_dir / "workflow.py").write_text("""
from agent_framework import WorkflowBuilder, FunctionExecutor
def test_func(input: str) -> str:
return f"Processed: {input}"
builder = WorkflowBuilder()
executor = FunctionExecutor(id="test_executor", func=test_func)
builder.set_start_executor(executor)
workflow = builder.build()
""")
# Create agent with agent.py
agent_dir = temp_path / "my_agent"
agent_dir.mkdir()
(agent_dir / "agent.py").write_text("""
from agent_framework import AgentRunResponse, AgentThread, ChatMessage, Role, TextContent
class TestAgent:
name = "Test Agent"
async def run(self, messages=None, *, thread=None, **kwargs):
return AgentRunResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="test")])],
response_id="test"
)
def get_new_thread(self, **kwargs):
return AgentThread()
agent = TestAgent()
""")
# Create ambiguous entity with __init__.py only
unknown_dir = temp_path / "my_thing"
unknown_dir.mkdir()
(unknown_dir / "__init__.py").write_text("# thing")
discovery = EntityDiscovery(str(temp_path))
entities = await discovery.discover_entities()
# Check types detected correctly
by_id = {e.id: e for e in entities}
assert by_id["my_workflow"].type == "workflow"
assert by_id["my_agent"].type == "agent"
assert by_id["my_thing"].type == "unknown"
async def test_hot_reload():
"""Test that invalidate_entity() enables hot reload."""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create workflow
workflow_dir = temp_path / "test_workflow"
workflow_dir.mkdir()
workflow_file = workflow_dir / "workflow.py"
workflow_file.write_text("""
from agent_framework import WorkflowBuilder, FunctionExecutor
def test_func(input: str) -> str:
return "v1"
builder = WorkflowBuilder()
executor = FunctionExecutor(id="test_executor", func=test_func)
builder.set_start_executor(executor)
workflow = builder.build()
""")
discovery = EntityDiscovery(str(temp_path))
await discovery.discover_entities()
# Load entity
workflow1 = await discovery.load_entity("test_workflow")
assert workflow1 is not None
# Modify file to create a different workflow
workflow_file.write_text("""
from agent_framework import WorkflowBuilder, FunctionExecutor
def test_func(input: str) -> str:
return "v2"
def test_func2(input: str) -> str:
return "v2_extra"
builder = WorkflowBuilder()
executor1 = FunctionExecutor(id="test_executor", func=test_func)
executor2 = FunctionExecutor(id="test_executor2", func=test_func2)
builder.set_start_executor(executor1)
builder.add_edge(executor1, executor2)
workflow = builder.build()
""")
# Without invalidation, gets cached version
workflow2 = await discovery.load_entity("test_workflow")
assert workflow2 is workflow1 # Same object (cached)
# Old workflow has 1 executor
assert len(workflow2.get_executors_list()) == 1
# Invalidate cache
discovery.invalidate_entity("test_workflow")
# Now reloads from disk
workflow3 = await discovery.load_entity("test_workflow")
assert workflow3 is not workflow1 # Different object
# New workflow has 2 executors
assert len(workflow3.get_executors_list()) == 2
async def test_in_memory_entities_bypass_lazy_loading():
"""Test that in-memory entities work as before (no lazy loading needed)."""
from agent_framework import FunctionExecutor, WorkflowBuilder
# Create in-memory workflow
def test_func(input: str) -> str:
return f"Processed: {input}"
builder = WorkflowBuilder()
executor = FunctionExecutor(id="test_executor", func=test_func)
builder.set_start_executor(executor)
workflow = builder.build()
discovery = EntityDiscovery()
# Register in-memory entity
entity_info = await discovery.create_entity_info_from_object(workflow, entity_type="workflow", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, workflow)
# Should be immediately available (no lazy loading)
loaded = discovery.get_entity_object(entity_info.id)
assert loaded is workflow
# load_entity() should return immediately from cache
loaded2 = await discovery.load_entity(entity_info.id)
assert loaded2 is workflow # Same object (cache hit)
if __name__ == "__main__":
@@ -73,7 +73,11 @@ async def test_executor_entity_discovery(executor):
for entity in entities:
assert entity.id, "Entity should have an ID"
assert entity.name, "Entity should have a name"
assert entity.type in ["agent", "workflow"], "Entity should have valid type"
# Entities with only an `__init__.py` file cannot have their type determined
# until the module is imported during lazy loading. This is why 'unknown' type exists.
assert entity.type in ["agent", "workflow", "unknown"], (
"Entity should have valid type (unknown allowed during discovery phase)"
)
async def test_executor_get_entity_info(executor):
@@ -84,7 +88,7 @@ async def test_executor_get_entity_info(executor):
entity_info = executor.get_entity_info(entity_id)
assert entity_info is not None
assert entity_info.id == entity_id
assert entity_info.type in ["agent", "workflow"]
assert entity_info.type in ["agent", "workflow", "unknown"]
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="requires OpenAI API key")