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