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:
@@ -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>
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+18
-45
@@ -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} />;
|
||||
}
|
||||
|
||||
+207
@@ -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>
|
||||
);
|
||||
}
|
||||
+17
-10
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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==
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user