mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: DevUI improvements. (#1091)
* enable deeplinking in ui, add agent details to entity info, add usage data, add middleware example in samples and foundry agent. * update ui build * Update python/packages/devui/frontend/src/components/workflow/workflow-input-form.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/devui/pyproject.toml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/devui/pyproject.toml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * imporove mapping for agent nodes and serialiation for agent run events * lint fixes * update pyproj toml and ui updates --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
61ac6d43b2
commit
01f438d710
@@ -134,6 +134,52 @@ class EntityDiscovery:
|
||||
# Extract tools/executors using Agent Framework specific logic
|
||||
tools_list = await self._extract_tools_from_object(entity_object, entity_type)
|
||||
|
||||
# Extract agent-specific fields (for agents only)
|
||||
instructions = None
|
||||
model = None
|
||||
chat_client_type = None
|
||||
context_providers_list = None
|
||||
middleware_list = None
|
||||
|
||||
if entity_type == "agent":
|
||||
# Try to get instructions
|
||||
if hasattr(entity_object, "chat_options") and hasattr(entity_object.chat_options, "instructions"):
|
||||
instructions = entity_object.chat_options.instructions
|
||||
|
||||
# Try to get model - check both chat_options and chat_client
|
||||
if (
|
||||
hasattr(entity_object, "chat_options")
|
||||
and hasattr(entity_object.chat_options, "model_id")
|
||||
and entity_object.chat_options.model_id
|
||||
):
|
||||
model = entity_object.chat_options.model_id
|
||||
elif hasattr(entity_object, "chat_client") and hasattr(entity_object.chat_client, "model_id"):
|
||||
model = entity_object.chat_client.model_id
|
||||
|
||||
# Try to get chat client type
|
||||
if hasattr(entity_object, "chat_client"):
|
||||
chat_client_type = entity_object.chat_client.__class__.__name__
|
||||
|
||||
# Try to get context providers
|
||||
if (
|
||||
hasattr(entity_object, "context_provider")
|
||||
and entity_object.context_provider
|
||||
and hasattr(entity_object.context_provider, "__class__")
|
||||
):
|
||||
context_providers_list = [entity_object.context_provider.__class__.__name__]
|
||||
|
||||
# Try to get middleware
|
||||
if hasattr(entity_object, "middleware") and entity_object.middleware:
|
||||
middleware_list = []
|
||||
for m in entity_object.middleware:
|
||||
# Try multiple ways to get a good name for middleware
|
||||
if hasattr(m, "__name__"): # Function or callable
|
||||
middleware_list.append(m.__name__)
|
||||
elif hasattr(m, "__class__"): # Class instance
|
||||
middleware_list.append(m.__class__.__name__)
|
||||
else:
|
||||
middleware_list.append(str(m))
|
||||
|
||||
# Create EntityInfo with Agent Framework specifics
|
||||
return EntityInfo(
|
||||
id=entity_id,
|
||||
@@ -142,6 +188,11 @@ class EntityDiscovery:
|
||||
type=entity_type,
|
||||
framework="agent_framework",
|
||||
tools=[str(tool) for tool in (tools_list or [])],
|
||||
instructions=instructions,
|
||||
model=model,
|
||||
chat_client_type=chat_client_type,
|
||||
context_providers=context_providers_list,
|
||||
middleware=middleware_list,
|
||||
executors=tools_list if entity_type == "workflow" else [],
|
||||
input_schema={"type": "string"}, # Default schema
|
||||
start_executor_id=tools_list[0] if tools_list and entity_type == "workflow" else None,
|
||||
@@ -446,6 +497,48 @@ class EntityDiscovery:
|
||||
if tools:
|
||||
tools_union = [tool for tool in tools]
|
||||
|
||||
# Extract agent-specific fields (for agents only)
|
||||
instructions = None
|
||||
model = None
|
||||
chat_client_type = None
|
||||
context_providers_list = None
|
||||
middleware_list = None
|
||||
|
||||
if obj_type == "agent":
|
||||
# Try to get instructions
|
||||
if hasattr(obj, "chat_options") and hasattr(obj.chat_options, "instructions"):
|
||||
instructions = obj.chat_options.instructions
|
||||
|
||||
# Try to get model - check both chat_options and chat_client
|
||||
if hasattr(obj, "chat_options") and hasattr(obj.chat_options, "model_id") and obj.chat_options.model_id:
|
||||
model = obj.chat_options.model_id
|
||||
elif hasattr(obj, "chat_client") and hasattr(obj.chat_client, "model_id"):
|
||||
model = obj.chat_client.model_id
|
||||
|
||||
# Try to get chat client type
|
||||
if hasattr(obj, "chat_client"):
|
||||
chat_client_type = obj.chat_client.__class__.__name__
|
||||
|
||||
# Try to get context providers
|
||||
if (
|
||||
hasattr(obj, "context_provider")
|
||||
and obj.context_provider
|
||||
and hasattr(obj.context_provider, "__class__")
|
||||
):
|
||||
context_providers_list = [obj.context_provider.__class__.__name__]
|
||||
|
||||
# Try to get middleware
|
||||
if hasattr(obj, "middleware") and obj.middleware:
|
||||
middleware_list = []
|
||||
for m in obj.middleware:
|
||||
# Try multiple ways to get a good name for middleware
|
||||
if hasattr(m, "__name__"): # Function or callable
|
||||
middleware_list.append(m.__name__)
|
||||
elif hasattr(m, "__class__"): # Class instance
|
||||
middleware_list.append(m.__class__.__name__)
|
||||
else:
|
||||
middleware_list.append(str(m))
|
||||
|
||||
entity_info = EntityInfo(
|
||||
id=entity_id,
|
||||
type=obj_type,
|
||||
@@ -453,6 +546,11 @@ class EntityDiscovery:
|
||||
framework="agent_framework",
|
||||
description=description,
|
||||
tools=tools_union,
|
||||
instructions=instructions,
|
||||
model=model,
|
||||
chat_client_type=chat_client_type,
|
||||
context_providers=context_providers_list,
|
||||
middleware=middleware_list,
|
||||
metadata={
|
||||
"module_path": module_path,
|
||||
"entity_type": obj_type,
|
||||
|
||||
@@ -151,6 +151,20 @@ class AgentFrameworkExecutor:
|
||||
if not display_contents:
|
||||
continue
|
||||
|
||||
# Extract usage information if present
|
||||
usage_data = None
|
||||
for content in af_msg.contents:
|
||||
content_type = getattr(content, "type", None)
|
||||
if content_type == "usage":
|
||||
details = getattr(content, "details", None)
|
||||
if details:
|
||||
usage_data = {
|
||||
"total_tokens": getattr(details, "total_token_count", 0) or 0,
|
||||
"prompt_tokens": getattr(details, "input_token_count", 0) or 0,
|
||||
"completion_tokens": getattr(details, "output_token_count", 0) or 0,
|
||||
}
|
||||
break
|
||||
|
||||
ui_message = {
|
||||
"id": af_msg.message_id or f"restored-{i}",
|
||||
"role": role,
|
||||
@@ -160,6 +174,10 @@ class AgentFrameworkExecutor:
|
||||
"message_id": af_msg.message_id,
|
||||
}
|
||||
|
||||
# Add usage data if available
|
||||
if usage_data:
|
||||
ui_message["usage"] = usage_data
|
||||
|
||||
ui_messages.append(ui_message)
|
||||
|
||||
logger.info(f"Restored {len(ui_messages)} display messages for thread {thread_id}")
|
||||
@@ -697,6 +715,8 @@ class AgentFrameworkExecutor:
|
||||
Parsed input for workflow
|
||||
"""
|
||||
try:
|
||||
from ._utils import parse_input_for_type
|
||||
|
||||
# Get the start executor and its input type
|
||||
start_executor, message_types = self._get_start_executor_message_types(workflow)
|
||||
if not start_executor:
|
||||
@@ -713,45 +733,12 @@ class AgentFrameworkExecutor:
|
||||
logger.debug("Could not select primary input type for workflow - using raw dict")
|
||||
return input_data
|
||||
|
||||
# If input type is dict, return as-is
|
||||
if input_type is dict:
|
||||
return input_data
|
||||
|
||||
# Handle primitive types
|
||||
if input_type in (str, int, float, bool):
|
||||
try:
|
||||
if isinstance(input_data, input_type):
|
||||
return input_data
|
||||
if "input" in input_data:
|
||||
return input_type(input_data["input"])
|
||||
if len(input_data) == 1:
|
||||
value = next(iter(input_data.values()))
|
||||
return input_type(value)
|
||||
return input_data
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Failed to convert input to {input_type}: {e}")
|
||||
return input_data
|
||||
|
||||
# If it's a Pydantic model, validate and create instance
|
||||
if hasattr(input_type, "model_validate"):
|
||||
try:
|
||||
return input_type.model_validate(input_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to validate input as {input_type}: {e}")
|
||||
return input_data
|
||||
|
||||
# If it's a dataclass or other type with annotations
|
||||
elif hasattr(input_type, "__annotations__"):
|
||||
try:
|
||||
return input_type(**input_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create {input_type} from input data: {e}")
|
||||
return input_data
|
||||
# Use consolidated parsing logic from _utils
|
||||
return parse_input_for_type(input_data, input_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing structured workflow input: {e}")
|
||||
|
||||
return input_data
|
||||
return input_data
|
||||
|
||||
def _parse_raw_workflow_input(self, workflow: Any, raw_input: str) -> Any:
|
||||
"""Parse raw input string based on workflow's expected input type.
|
||||
@@ -764,6 +751,8 @@ class AgentFrameworkExecutor:
|
||||
Parsed input for workflow
|
||||
"""
|
||||
try:
|
||||
from ._utils import parse_input_for_type
|
||||
|
||||
# Get the start executor and its input type
|
||||
start_executor, message_types = self._get_start_executor_message_types(workflow)
|
||||
if not start_executor:
|
||||
@@ -780,43 +769,9 @@ class AgentFrameworkExecutor:
|
||||
logger.debug("Could not select primary input type for workflow - using raw string")
|
||||
return raw_input
|
||||
|
||||
# If input type is str, return as-is
|
||||
if input_type is str:
|
||||
return raw_input
|
||||
|
||||
# If it's a Pydantic model, try to parse JSON
|
||||
if hasattr(input_type, "model_validate_json"):
|
||||
try:
|
||||
# First try to parse as JSON
|
||||
if raw_input.strip().startswith("{"):
|
||||
return input_type.model_validate_json(raw_input)
|
||||
|
||||
# Try common field names
|
||||
common_fields = ["message", "text", "input", "data", "content"]
|
||||
for field in common_fields:
|
||||
try:
|
||||
return input_type(**{field: raw_input})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse input using field '{field}': {e}")
|
||||
continue
|
||||
|
||||
# Last resort: try default constructor
|
||||
return input_type()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse input as {input_type}: {e}")
|
||||
|
||||
# If it's a dataclass, try JSON parsing
|
||||
elif hasattr(input_type, "__annotations__"):
|
||||
try:
|
||||
if raw_input.strip().startswith("{"):
|
||||
parsed = json.loads(raw_input)
|
||||
return input_type(**parsed)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse input as {input_type}: {e}")
|
||||
# Use consolidated parsing logic from _utils
|
||||
return parse_input_for_type(raw_input, input_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error determining workflow input type: {e}")
|
||||
|
||||
# Fallback: return raw string
|
||||
return raw_input
|
||||
logger.debug(f"Error parsing workflow input: {e}")
|
||||
return raw_input
|
||||
|
||||
@@ -6,7 +6,6 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Union
|
||||
|
||||
@@ -97,8 +96,18 @@ class MessageMapper:
|
||||
# Import Agent Framework types for proper isinstance checks
|
||||
try:
|
||||
from agent_framework import AgentRunResponseUpdate, WorkflowEvent
|
||||
from agent_framework._workflows._events import AgentRunUpdateEvent
|
||||
|
||||
# Handle agent updates (AgentRunResponseUpdate)
|
||||
# Handle AgentRunUpdateEvent - workflow event wrapping AgentRunResponseUpdate
|
||||
# This must be checked BEFORE generic WorkflowEvent check
|
||||
if isinstance(raw_event, AgentRunUpdateEvent):
|
||||
# Extract the AgentRunResponseUpdate from the event's data attribute
|
||||
if raw_event.data and isinstance(raw_event.data, AgentRunResponseUpdate):
|
||||
return await self._convert_agent_update(raw_event.data, context)
|
||||
# If no data, treat as generic workflow event
|
||||
return await self._convert_workflow_event(raw_event, context)
|
||||
|
||||
# Handle agent updates (AgentRunResponseUpdate) - for direct agent execution
|
||||
if isinstance(raw_event, AgentRunResponseUpdate):
|
||||
return await self._convert_agent_update(raw_event, context)
|
||||
|
||||
@@ -258,13 +267,22 @@ class MessageMapper:
|
||||
List of OpenAI response stream events
|
||||
"""
|
||||
try:
|
||||
serialized_payload = self._serialize_payload(getattr(event, "data", None))
|
||||
# Get event data and serialize if it's a SerializationMixin
|
||||
event_data = getattr(event, "data", None)
|
||||
if event_data is not None and hasattr(event_data, "to_dict"):
|
||||
# SerializationMixin objects - convert to dict for JSON serialization
|
||||
try:
|
||||
event_data = event_data.to_dict()
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to serialize event data with to_dict(): {e}")
|
||||
event_data = str(event_data)
|
||||
|
||||
# Create structured workflow event
|
||||
workflow_event = ResponseWorkflowEventComplete(
|
||||
type="response.workflow_event.complete",
|
||||
data={
|
||||
"event_type": event.__class__.__name__,
|
||||
"data": serialized_payload,
|
||||
"data": event_data,
|
||||
"executor_id": getattr(event, "executor_id", None),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
@@ -280,59 +298,6 @@ class MessageMapper:
|
||||
logger.warning(f"Error converting workflow event: {e}")
|
||||
return [await self._create_error_event(str(e), context)]
|
||||
|
||||
def _serialize_payload(self, value: Any) -> Any:
|
||||
"""Best-effort JSON serialization for workflow payloads."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [self._serialize_payload(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(k): self._serialize_payload(v) for k, v in value.items()}
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
try:
|
||||
return self._serialize_payload(asdict(value))
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to serialize dataclass payload: %s", exc)
|
||||
model_dump_method = getattr(value, "model_dump", None)
|
||||
if model_dump_method is not None and callable(model_dump_method):
|
||||
try:
|
||||
dumped = model_dump_method()
|
||||
return self._serialize_payload(dumped)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to serialize payload via model_dump: %s", exc)
|
||||
dict_method = getattr(value, "dict", None)
|
||||
if dict_method is not None and callable(dict_method):
|
||||
try:
|
||||
dict_result = dict_method()
|
||||
return self._serialize_payload(dict_result)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to serialize payload via dict(): %s", exc)
|
||||
to_dict_method = getattr(value, "to_dict", None)
|
||||
if to_dict_method is not None and callable(to_dict_method):
|
||||
try:
|
||||
to_dict_result = to_dict_method()
|
||||
return self._serialize_payload(to_dict_result)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to serialize payload via to_dict(): %s", exc)
|
||||
model_dump_json_method = getattr(value, "model_dump_json", None)
|
||||
if model_dump_json_method is not None and callable(model_dump_json_method):
|
||||
try:
|
||||
json_str = model_dump_json_method()
|
||||
if isinstance(json_str, (str, bytes, bytearray)):
|
||||
return json.loads(json_str)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to serialize payload via model_dump_json: %s", exc)
|
||||
if hasattr(value, "__dict__"):
|
||||
try:
|
||||
return self._serialize_payload({
|
||||
key: self._serialize_payload(val) for key, val in value.__dict__.items() if not key.startswith("_")
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to serialize payload via __dict__: %s", exc)
|
||||
return str(value)
|
||||
|
||||
# Content type mappers - implementing our comprehensive mapping plan
|
||||
|
||||
async def _map_text_content(self, content: Any, context: dict[str, Any]) -> ResponseTextDeltaEvent:
|
||||
@@ -409,13 +374,24 @@ class MessageMapper:
|
||||
context["usage_data"] = []
|
||||
context["usage_data"].append(content)
|
||||
|
||||
# Extract usage from UsageContent.details (UsageDetails object)
|
||||
details = getattr(content, "details", None)
|
||||
total_tokens = 0
|
||||
prompt_tokens = 0
|
||||
completion_tokens = 0
|
||||
|
||||
if details:
|
||||
total_tokens = getattr(details, "total_token_count", 0) or 0
|
||||
prompt_tokens = getattr(details, "input_token_count", 0) or 0
|
||||
completion_tokens = getattr(details, "output_token_count", 0) or 0
|
||||
|
||||
return ResponseUsageEventComplete(
|
||||
type="response.usage.complete",
|
||||
data={
|
||||
"usage_data": getattr(content, "usage_data", {}),
|
||||
"total_tokens": getattr(content, "total_tokens", 0),
|
||||
"completion_tokens": getattr(content, "completion_tokens", 0),
|
||||
"prompt_tokens": getattr(content, "prompt_tokens", 0),
|
||||
"usage_data": details.to_dict() if details and hasattr(details, "to_dict") else {},
|
||||
"total_tokens": total_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
item_id=context["item_id"],
|
||||
|
||||
@@ -263,6 +263,8 @@ class DevServer:
|
||||
start_executor_id = ""
|
||||
|
||||
try:
|
||||
from ._utils import generate_input_schema
|
||||
|
||||
start_executor = entity_obj.get_start_executor()
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not extract input info for workflow {entity_id}: {e}")
|
||||
@@ -278,17 +280,8 @@ class DevServer:
|
||||
if input_type:
|
||||
input_type_name = getattr(input_type, "__name__", str(input_type))
|
||||
|
||||
if input_type is str:
|
||||
input_schema = {"type": "string"}
|
||||
elif input_type is dict:
|
||||
input_schema = {"type": "object"}
|
||||
elif hasattr(input_type, "model_json_schema"):
|
||||
try:
|
||||
input_schema = input_type.model_json_schema()
|
||||
except Exception as exc: # pragma: no cover - defensive path
|
||||
logger.debug(f"model_json_schema() failed for workflow {entity_id}: {exc}")
|
||||
elif hasattr(input_type, "__annotations__"):
|
||||
input_schema = {"type": "object"}
|
||||
# Generate schema using comprehensive schema generation
|
||||
input_schema = generate_input_schema(input_type)
|
||||
|
||||
if not input_schema:
|
||||
input_schema = {"type": "string"}
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Utility functions for DevUI."""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import fields, is_dataclass
|
||||
from typing import Any, get_args, get_origin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# Type System Utilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def is_serialization_mixin(cls: type) -> bool:
|
||||
"""Check if class is a SerializationMixin subclass.
|
||||
|
||||
Args:
|
||||
cls: Class to check
|
||||
|
||||
Returns:
|
||||
True if class is a SerializationMixin subclass
|
||||
"""
|
||||
try:
|
||||
from agent_framework._serialization import SerializationMixin
|
||||
|
||||
return isinstance(cls, type) and issubclass(cls, SerializationMixin)
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _type_to_schema(type_hint: Any, field_name: str) -> dict[str, Any]:
|
||||
"""Convert a type hint to JSON schema.
|
||||
|
||||
Args:
|
||||
type_hint: Type hint to convert
|
||||
field_name: Name of the field (for documentation)
|
||||
|
||||
Returns:
|
||||
JSON schema dict
|
||||
"""
|
||||
type_str = str(type_hint)
|
||||
|
||||
# Handle None/Optional
|
||||
if type_hint is type(None):
|
||||
return {"type": "null"}
|
||||
|
||||
# Handle basic types
|
||||
if type_hint is str or "str" in type_str:
|
||||
return {"type": "string"}
|
||||
if type_hint is int or "int" in type_str:
|
||||
return {"type": "integer"}
|
||||
if type_hint is float or "float" in type_str:
|
||||
return {"type": "number"}
|
||||
if type_hint is bool or "bool" in type_str:
|
||||
return {"type": "boolean"}
|
||||
|
||||
# Handle Literal types (for enum-like values)
|
||||
if "Literal" in type_str:
|
||||
origin = get_origin(type_hint)
|
||||
if origin is not None:
|
||||
args = get_args(type_hint)
|
||||
if args:
|
||||
return {"type": "string", "enum": list(args)}
|
||||
|
||||
# Handle Union/Optional
|
||||
if "Union" in type_str or "Optional" in type_str:
|
||||
origin = get_origin(type_hint)
|
||||
if origin is not None:
|
||||
args = get_args(type_hint)
|
||||
# Filter out None type
|
||||
non_none_args = [arg for arg in args if arg is not type(None)]
|
||||
if len(non_none_args) == 1:
|
||||
return _type_to_schema(non_none_args[0], field_name)
|
||||
# Multiple types - pick first non-None
|
||||
if non_none_args:
|
||||
return _type_to_schema(non_none_args[0], field_name)
|
||||
|
||||
# Handle collections
|
||||
if "list" in type_str or "List" in type_str or "Sequence" in type_str:
|
||||
origin = get_origin(type_hint)
|
||||
if origin is not None:
|
||||
args = get_args(type_hint)
|
||||
if args:
|
||||
items_schema = _type_to_schema(args[0], field_name)
|
||||
return {"type": "array", "items": items_schema}
|
||||
return {"type": "array"}
|
||||
|
||||
if "dict" in type_str or "Dict" in type_str or "Mapping" in type_str:
|
||||
return {"type": "object"}
|
||||
|
||||
# Default fallback
|
||||
return {"type": "string", "description": f"Type: {type_hint}"}
|
||||
|
||||
|
||||
def generate_schema_from_serialization_mixin(cls: type[Any]) -> dict[str, Any]:
|
||||
"""Generate JSON schema from SerializationMixin class.
|
||||
|
||||
Introspects the __init__ signature to extract parameter types and defaults.
|
||||
|
||||
Args:
|
||||
cls: SerializationMixin subclass
|
||||
|
||||
Returns:
|
||||
JSON schema dict
|
||||
"""
|
||||
sig = inspect.signature(cls)
|
||||
|
||||
# Get type hints
|
||||
try:
|
||||
from typing import get_type_hints
|
||||
|
||||
type_hints = get_type_hints(cls)
|
||||
except Exception:
|
||||
type_hints = {}
|
||||
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name in ("self", "kwargs"):
|
||||
continue
|
||||
|
||||
# Get type annotation
|
||||
param_type = type_hints.get(param_name, str)
|
||||
|
||||
# Generate schema for this parameter
|
||||
param_schema = _type_to_schema(param_type, param_name)
|
||||
properties[param_name] = param_schema
|
||||
|
||||
# Check if required (no default value, not VAR_KEYWORD)
|
||||
if param.default == inspect.Parameter.empty and param.kind != inspect.Parameter.VAR_KEYWORD:
|
||||
required.append(param_name)
|
||||
|
||||
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
||||
|
||||
if required:
|
||||
schema["required"] = required
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def generate_schema_from_dataclass(cls: type[Any]) -> dict[str, Any]:
|
||||
"""Generate JSON schema from dataclass.
|
||||
|
||||
Args:
|
||||
cls: Dataclass type
|
||||
|
||||
Returns:
|
||||
JSON schema dict
|
||||
"""
|
||||
if not is_dataclass(cls):
|
||||
return {"type": "object"}
|
||||
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
|
||||
for field in fields(cls):
|
||||
# Generate schema for field type
|
||||
field_schema = _type_to_schema(field.type, field.name)
|
||||
properties[field.name] = field_schema
|
||||
|
||||
# Check if required (no default value)
|
||||
if field.default == field.default_factory: # No default
|
||||
required.append(field.name)
|
||||
|
||||
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
||||
|
||||
if required:
|
||||
schema["required"] = required
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def generate_input_schema(input_type: type) -> dict[str, Any]:
|
||||
"""Generate JSON schema for workflow input type.
|
||||
|
||||
Supports multiple input types in priority order:
|
||||
1. Built-in types (str, dict, int, etc.)
|
||||
2. Pydantic models (via model_json_schema)
|
||||
3. SerializationMixin classes (via __init__ introspection)
|
||||
4. Dataclasses (via fields introspection)
|
||||
5. Fallback to string
|
||||
|
||||
Args:
|
||||
input_type: Input type to generate schema for
|
||||
|
||||
Returns:
|
||||
JSON schema dict
|
||||
"""
|
||||
# 1. Built-in types
|
||||
if input_type is str:
|
||||
return {"type": "string"}
|
||||
if input_type is dict:
|
||||
return {"type": "object"}
|
||||
if input_type is int:
|
||||
return {"type": "integer"}
|
||||
if input_type is float:
|
||||
return {"type": "number"}
|
||||
if input_type is bool:
|
||||
return {"type": "boolean"}
|
||||
|
||||
# 2. Pydantic models (legacy support)
|
||||
if hasattr(input_type, "model_json_schema"):
|
||||
return input_type.model_json_schema() # type: ignore
|
||||
|
||||
# 3. SerializationMixin classes (ChatMessage, etc.)
|
||||
if is_serialization_mixin(input_type):
|
||||
return generate_schema_from_serialization_mixin(input_type)
|
||||
|
||||
# 4. Dataclasses
|
||||
if is_dataclass(input_type):
|
||||
return generate_schema_from_dataclass(input_type)
|
||||
|
||||
# 5. Fallback to string
|
||||
type_name = getattr(input_type, "__name__", str(input_type))
|
||||
return {"type": "string", "description": f"Input type: {type_name}"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Input Parsing Utilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def parse_input_for_type(input_data: Any, target_type: type) -> Any:
|
||||
"""Parse input data to match the target type.
|
||||
|
||||
Handles conversion from raw input (string, dict) to the expected type:
|
||||
- Built-in types: direct conversion
|
||||
- Pydantic models: use model_validate or model_validate_json
|
||||
- SerializationMixin: use from_dict or construct from string
|
||||
- Dataclasses: construct from dict
|
||||
|
||||
Args:
|
||||
input_data: Raw input data (string, dict, or already correct type)
|
||||
target_type: Expected type for the input
|
||||
|
||||
Returns:
|
||||
Parsed input matching target_type, or original input if parsing fails
|
||||
"""
|
||||
# If already correct type, return as-is
|
||||
if isinstance(input_data, target_type):
|
||||
return input_data
|
||||
|
||||
# Handle string input
|
||||
if isinstance(input_data, str):
|
||||
return _parse_string_input(input_data, target_type)
|
||||
|
||||
# Handle dict input
|
||||
if isinstance(input_data, dict):
|
||||
return _parse_dict_input(input_data, target_type)
|
||||
|
||||
# Fallback: return original
|
||||
return input_data
|
||||
|
||||
|
||||
def _parse_string_input(input_str: str, target_type: type) -> Any:
|
||||
"""Parse string input to target type.
|
||||
|
||||
Args:
|
||||
input_str: Input string
|
||||
target_type: Target type
|
||||
|
||||
Returns:
|
||||
Parsed input or original string
|
||||
"""
|
||||
# Built-in types
|
||||
if target_type is str:
|
||||
return input_str
|
||||
if target_type is int:
|
||||
try:
|
||||
return int(input_str)
|
||||
except ValueError:
|
||||
return input_str
|
||||
elif target_type is float:
|
||||
try:
|
||||
return float(input_str)
|
||||
except ValueError:
|
||||
return input_str
|
||||
elif target_type is bool:
|
||||
return input_str.lower() in ("true", "1", "yes")
|
||||
|
||||
# Pydantic models
|
||||
if hasattr(target_type, "model_validate_json"):
|
||||
try:
|
||||
# Try parsing as JSON first
|
||||
if input_str.strip().startswith("{"):
|
||||
return target_type.model_validate_json(input_str) # type: ignore
|
||||
|
||||
# Try common field names with the string value
|
||||
common_fields = ["text", "message", "content", "input", "data"]
|
||||
for field in common_fields:
|
||||
try:
|
||||
return target_type(**{field: input_str}) # type: ignore
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse string input with field '{field}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse string as Pydantic model: {e}")
|
||||
|
||||
# SerializationMixin (like ChatMessage)
|
||||
if is_serialization_mixin(target_type):
|
||||
try:
|
||||
# Try parsing as JSON dict first
|
||||
if input_str.strip().startswith("{"):
|
||||
data = json.loads(input_str)
|
||||
if hasattr(target_type, "from_dict"):
|
||||
return target_type.from_dict(data) # type: ignore
|
||||
return target_type(**data) # type: ignore
|
||||
|
||||
# For ChatMessage specifically: create from text
|
||||
# Try common field patterns
|
||||
common_fields = ["text", "message", "content"]
|
||||
sig = inspect.signature(target_type)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
# If it has 'text' param, use it
|
||||
if "text" in params:
|
||||
try:
|
||||
return target_type(role="user", text=input_str) # type: ignore
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to create SerializationMixin with text field: {e}")
|
||||
|
||||
# Try other common fields
|
||||
for field in common_fields:
|
||||
if field in params:
|
||||
try:
|
||||
return target_type(**{field: input_str}) # type: ignore
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to create SerializationMixin with field '{field}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse string as SerializationMixin: {e}")
|
||||
|
||||
# Dataclasses
|
||||
if is_dataclass(target_type):
|
||||
try:
|
||||
# Try parsing as JSON
|
||||
if input_str.strip().startswith("{"):
|
||||
data = json.loads(input_str)
|
||||
return target_type(**data) # type: ignore
|
||||
|
||||
# Try common field names
|
||||
common_fields = ["text", "message", "content", "input", "data"]
|
||||
for field in common_fields:
|
||||
try:
|
||||
return target_type(**{field: input_str}) # type: ignore
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to create dataclass with field '{field}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse string as dataclass: {e}")
|
||||
|
||||
# Fallback: return original string
|
||||
return input_str
|
||||
|
||||
|
||||
def _parse_dict_input(input_dict: dict[str, Any], target_type: type) -> Any:
|
||||
"""Parse dict input to target type.
|
||||
|
||||
Args:
|
||||
input_dict: Input dictionary
|
||||
target_type: Target type
|
||||
|
||||
Returns:
|
||||
Parsed input or original dict
|
||||
"""
|
||||
# Handle primitive types - extract from common field names
|
||||
if target_type in (str, int, float, bool):
|
||||
try:
|
||||
# If it's already the right type, return as-is
|
||||
if isinstance(input_dict, target_type):
|
||||
return input_dict
|
||||
|
||||
# Try "input" field first (common for workflow inputs)
|
||||
if "input" in input_dict:
|
||||
return target_type(input_dict["input"]) # type: ignore
|
||||
|
||||
# If single-key dict, extract the value
|
||||
if len(input_dict) == 1:
|
||||
value = next(iter(input_dict.values()))
|
||||
return target_type(value) # type: ignore
|
||||
|
||||
# Otherwise, return as-is
|
||||
return input_dict
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.debug(f"Failed to convert dict to {target_type}: {e}")
|
||||
return input_dict
|
||||
|
||||
# If target is dict, return as-is
|
||||
if target_type is dict:
|
||||
return input_dict
|
||||
|
||||
# Pydantic models
|
||||
if hasattr(target_type, "model_validate"):
|
||||
try:
|
||||
return target_type.model_validate(input_dict) # type: ignore
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to validate dict as Pydantic model: {e}")
|
||||
|
||||
# SerializationMixin
|
||||
if is_serialization_mixin(target_type):
|
||||
try:
|
||||
if hasattr(target_type, "from_dict"):
|
||||
return target_type.from_dict(input_dict) # type: ignore
|
||||
return target_type(**input_dict) # type: ignore
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse dict as SerializationMixin: {e}")
|
||||
|
||||
# Dataclasses
|
||||
if is_dataclass(target_type):
|
||||
try:
|
||||
return target_type(**input_dict) # type: ignore
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse dict as dataclass: {e}")
|
||||
|
||||
# Fallback: return original dict
|
||||
return input_dict
|
||||
@@ -37,6 +37,13 @@ class EntityInfo(BaseModel):
|
||||
# Environment variable requirements
|
||||
required_env_vars: list[EnvVarRequirement] | None = None
|
||||
|
||||
# Agent-specific fields (optional, populated when available)
|
||||
instructions: str | None = None
|
||||
model: str | None = None
|
||||
chat_client_type: str | None = None
|
||||
context_providers: list[str] | None = None
|
||||
middleware: list[str] | None = None
|
||||
|
||||
# Workflow-specific fields (populated only for detailed info requests)
|
||||
executors: list[str] | None = None
|
||||
workflow_dump: dict[str, Any] | None = None
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<svg width="805" height="805" viewBox="0 0 805 805" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_iii_510_1294)">
|
||||
<path d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z" fill="url(#paint0_linear_510_1294)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_iii_510_1294" x="103.759" y="93.4694" width="578.735" height="599.314" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="8.39647" dy="8.39647"/>
|
||||
<feGaussianBlur stdDeviation="20.9912"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.835294 0 0 0 0 0.623529 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_510_1294"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="-26.2432" dy="-26.2432"/>
|
||||
<feGaussianBlur stdDeviation="20.9912"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.3 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect1_innerShadow_510_1294" result="effect2_innerShadow_510_1294"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="-26.2432" dy="-26.2432"/>
|
||||
<feGaussianBlur stdDeviation="50"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_510_1294" result="effect3_innerShadow_510_1294"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_510_1294" x1="255.628" y1="-34.3245" x2="618.483" y2="632.032" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D59FFF"/>
|
||||
<stop offset="1" stop-color="#8562C5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,11 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/agentframework.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agent Framework Dev UI</title>
|
||||
<script type="module" crossorigin src="/assets/index-DPEaaIdK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D1AmQWga.css">
|
||||
<script type="module" crossorigin src="/assets/index-D0SfShuZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-WsCIE0bH.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -102,6 +102,27 @@ Messages and events from agents/workflows are mapped to OpenAI response types in
|
||||
| `FunctionApprovalResponseContent` | Custom event | Custom |
|
||||
| `WorkflowEvent` | `ResponseWorkflowEventComplete` | Custom |
|
||||
|
||||
## Frontend Development
|
||||
|
||||
To build the frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
yarn install
|
||||
|
||||
# Create .env.local with backend URL
|
||||
echo 'VITE_API_BASE_URL=http://localhost:8000' > .env.local
|
||||
|
||||
# Create .env.production (empty for relative URLs)
|
||||
echo '' > .env.production
|
||||
|
||||
# Development
|
||||
yarn dev
|
||||
|
||||
# Build (copies to backend)
|
||||
yarn build
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Missing API key**: Make sure your `.env` file is in the `python/` directory with valid credentials. Or set environment variables directly in your shell before running DevUI.
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
# React + TypeScript + Vite
|
||||
# DevUI Frontend
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
## Build Instructions
|
||||
|
||||
Currently, two official plugins are available:
|
||||
```bash
|
||||
cd frontend
|
||||
yarn install
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
# Create .env.local with backend URL
|
||||
echo 'VITE_API_BASE_URL=http://localhost:8000' > .env.local
|
||||
|
||||
# Create .env.production (empty for relative URLs)
|
||||
echo '' > .env.production
|
||||
|
||||
# Development
|
||||
yarn dev
|
||||
|
||||
# Build (copies to backend)
|
||||
yarn build
|
||||
```
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/agentframework.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agent Framework Dev UI</title>
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<svg width="805" height="805" viewBox="0 0 805 805" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_iii_510_1294)">
|
||||
<path d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z" fill="url(#paint0_linear_510_1294)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_iii_510_1294" x="103.759" y="93.4694" width="578.735" height="599.314" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="8.39647" dy="8.39647"/>
|
||||
<feGaussianBlur stdDeviation="20.9912"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.835294 0 0 0 0 0.623529 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_510_1294"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="-26.2432" dy="-26.2432"/>
|
||||
<feGaussianBlur stdDeviation="20.9912"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.3 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect1_innerShadow_510_1294" result="effect2_innerShadow_510_1294"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="-26.2432" dy="-26.2432"/>
|
||||
<feGaussianBlur stdDeviation="50"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_510_1294" result="effect3_innerShadow_510_1294"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_510_1294" x1="255.628" y1="-34.3245" x2="618.483" y2="632.032" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D59FFF"/>
|
||||
<stop offset="1" stop-color="#8562C5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
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 { SettingsModal } from "@/components/shared/settings-modal";
|
||||
@@ -12,8 +11,9 @@ 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 { Toast } from "@/components/ui/toast";
|
||||
import { apiClient } from "@/services/api";
|
||||
import { ChevronLeft, ChevronDown, ServerOff } from "lucide-react";
|
||||
import { PanelRightOpen, ChevronDown, ServerOff } from "lucide-react";
|
||||
import type { SampleEntity } from "@/data/gallery";
|
||||
import type {
|
||||
AgentInfo,
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
AppState,
|
||||
ExtendedResponseStreamEvent,
|
||||
} from "@/types";
|
||||
import { Button } from "./components/ui/button";
|
||||
|
||||
export default function App() {
|
||||
const [appState, setAppState] = useState<AppState>({
|
||||
@@ -29,8 +30,13 @@ export default function App() {
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>([]);
|
||||
const [debugPanelOpen, setDebugPanelOpen] = useState(true);
|
||||
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>(
|
||||
[]
|
||||
);
|
||||
const [showDebugPanel, setShowDebugPanel] = useState(() => {
|
||||
const saved = localStorage.getItem("showDebugPanel");
|
||||
return saved !== null ? saved === "true" : true;
|
||||
});
|
||||
const [debugPanelWidth, setDebugPanelWidth] = useState(() => {
|
||||
const savedWidth = localStorage.getItem("debugPanelWidth");
|
||||
return savedWidth ? parseInt(savedWidth, 10) : 320;
|
||||
@@ -41,6 +47,7 @@ export default function App() {
|
||||
const [addingEntityId, setAddingEntityId] = useState<string | null>(null);
|
||||
const [errorEntityId, setErrorEntityId] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showEntityNotFoundToast, setShowEntityNotFoundToast] = useState(false);
|
||||
|
||||
// Initialize app - load agents and workflows
|
||||
useEffect(() => {
|
||||
@@ -51,16 +58,51 @@ export default function App() {
|
||||
apiClient.getWorkflows(),
|
||||
]);
|
||||
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
agents,
|
||||
workflows,
|
||||
selectedAgent:
|
||||
// Check if there's an entity_id in the URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const entityId = urlParams.get("entity_id");
|
||||
|
||||
let selectedAgent: AgentInfo | WorkflowInfo | undefined;
|
||||
|
||||
// Try to find entity from URL parameter first
|
||||
if (entityId) {
|
||||
selectedAgent =
|
||||
agents.find((a) => a.id === entityId) ||
|
||||
workflows.find((w) => w.id === entityId);
|
||||
|
||||
// If entity not found but was requested, show notification
|
||||
if (!selectedAgent) {
|
||||
setShowEntityNotFoundToast(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first available entity if URL entity not found
|
||||
if (!selectedAgent) {
|
||||
selectedAgent =
|
||||
agents.length > 0
|
||||
? agents[0]
|
||||
: workflows.length > 0
|
||||
? workflows[0]
|
||||
: undefined,
|
||||
: undefined;
|
||||
|
||||
// Update URL to match actual selected entity (or clear if none)
|
||||
if (selectedAgent) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("entity_id", selectedAgent.id);
|
||||
window.history.replaceState({}, "", url);
|
||||
} else {
|
||||
// Clear entity_id if no entities available
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("entity_id");
|
||||
window.history.replaceState({}, "", url);
|
||||
}
|
||||
}
|
||||
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
agents,
|
||||
workflows,
|
||||
selectedAgent,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -76,7 +118,11 @@ export default function App() {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Save debug panel width to localStorage
|
||||
// Save debug panel state to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("showDebugPanel", showDebugPanel.toString());
|
||||
}, [showDebugPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("debugPanelWidth", debugPanelWidth.toString());
|
||||
}, [debugPanelWidth]);
|
||||
@@ -111,11 +157,6 @@ export default function App() {
|
||||
[debugPanelWidth]
|
||||
);
|
||||
|
||||
// Handle double-click to collapse
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
setDebugPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
// Handle entity selection
|
||||
const handleEntitySelect = useCallback((item: AgentInfo | WorkflowInfo) => {
|
||||
setAppState((prev) => ({
|
||||
@@ -124,18 +165,26 @@ export default function App() {
|
||||
currentThread: undefined,
|
||||
}));
|
||||
|
||||
// Update URL with selected entity ID
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("entity_id", item.id);
|
||||
window.history.pushState({}, "", url);
|
||||
|
||||
// Clear debug events when switching entities
|
||||
setDebugEvents([]);
|
||||
}, []);
|
||||
|
||||
// Handle debug events from active view
|
||||
const handleDebugEvent = useCallback((event: ExtendedResponseStreamEvent | 'clear') => {
|
||||
if (event === 'clear') {
|
||||
setDebugEvents([]);
|
||||
} else {
|
||||
setDebugEvents((prev) => [...prev, event]);
|
||||
}
|
||||
}, []);
|
||||
const handleDebugEvent = useCallback(
|
||||
(event: ExtendedResponseStreamEvent | "clear") => {
|
||||
if (event === "clear") {
|
||||
setDebugEvents([]);
|
||||
} else {
|
||||
setDebugEvents((prev) => [...prev, event]);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Handle adding sample entity
|
||||
const handleAddSample = useCallback(async (sample: SampleEntity) => {
|
||||
@@ -146,9 +195,9 @@ export default function App() {
|
||||
try {
|
||||
// Call backend to fetch and add entity
|
||||
const newEntity = await apiClient.addEntity(sample.url, {
|
||||
source: 'remote_gallery',
|
||||
source: "remote_gallery",
|
||||
originalUrl: sample.url,
|
||||
sampleId: sample.id
|
||||
sampleId: sample.id,
|
||||
});
|
||||
|
||||
// Convert backend entity to frontend format
|
||||
@@ -157,52 +206,67 @@ export default function App() {
|
||||
name: newEntity.name,
|
||||
description: newEntity.description,
|
||||
type: newEntity.type,
|
||||
source: (newEntity.source as "directory" | "in_memory" | "remote_gallery") || 'remote_gallery',
|
||||
source:
|
||||
(newEntity.source as "directory" | "in_memory" | "remote_gallery") ||
|
||||
"remote_gallery",
|
||||
has_env: false,
|
||||
module_path: undefined
|
||||
module_path: undefined,
|
||||
};
|
||||
|
||||
// Update app state
|
||||
if (newEntity.type === 'agent') {
|
||||
if (newEntity.type === "agent") {
|
||||
const agentEntity = {
|
||||
...convertedEntity,
|
||||
tools: (newEntity.tools || []).map(tool =>
|
||||
typeof tool === 'string' ? tool : JSON.stringify(tool)
|
||||
)
|
||||
tools: (newEntity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
),
|
||||
} as AgentInfo;
|
||||
|
||||
setAppState(prev => ({
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
agents: [...prev.agents, agentEntity],
|
||||
selectedAgent: agentEntity
|
||||
selectedAgent: agentEntity,
|
||||
}));
|
||||
|
||||
// Update URL with new entity
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("entity_id", agentEntity.id);
|
||||
window.history.pushState({}, "", url);
|
||||
} else {
|
||||
const workflowEntity = {
|
||||
...convertedEntity,
|
||||
executors: (newEntity.tools || []).map(tool =>
|
||||
typeof tool === 'string' ? tool : JSON.stringify(tool)
|
||||
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"
|
||||
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 => ({
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
workflows: [...prev.workflows, workflowEntity],
|
||||
selectedAgent: workflowEntity
|
||||
selectedAgent: workflowEntity,
|
||||
}));
|
||||
|
||||
// Update URL with new entity
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("entity_id", workflowEntity.id);
|
||||
window.history.pushState({}, "", url);
|
||||
}
|
||||
|
||||
// Close gallery and clear debug events
|
||||
setShowGallery(false);
|
||||
setDebugEvents([]);
|
||||
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : 'Failed to add sample entity';
|
||||
console.error('Failed to add sample entity:', errMsg);
|
||||
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 {
|
||||
@@ -216,29 +280,35 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
// Handle removing entity
|
||||
const handleRemoveEntity = useCallback(async (entityId: string) => {
|
||||
try {
|
||||
await apiClient.removeEntity(entityId);
|
||||
const handleRemoveEntity = useCallback(
|
||||
async (entityId: string) => {
|
||||
try {
|
||||
await apiClient.removeEntity(entityId);
|
||||
|
||||
// Update app state
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
agents: prev.agents.filter(a => a.id !== entityId),
|
||||
workflows: prev.workflows.filter(w => w.id !== entityId),
|
||||
selectedAgent: prev.selectedAgent?.id === entityId
|
||||
? undefined
|
||||
: prev.selectedAgent
|
||||
}));
|
||||
// Update 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([]);
|
||||
// Update URL - clear entity_id if we removed the selected entity
|
||||
if (appState.selectedAgent?.id === entityId) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("entity_id");
|
||||
window.history.pushState({}, "", url);
|
||||
setDebugEvents([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to remove entity:", error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to remove entity:', error);
|
||||
}
|
||||
}, [appState.selectedAgent?.id]);
|
||||
},
|
||||
[appState.selectedAgent?.id]
|
||||
);
|
||||
|
||||
// Show loading state while initializing
|
||||
if (appState.isLoading) {
|
||||
@@ -293,24 +363,29 @@ export default function App() {
|
||||
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.
|
||||
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>
|
||||
<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>
|
||||
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>
|
||||
Default:{" "}
|
||||
<span className="font-mono">http://localhost:8080</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -339,10 +414,7 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
open={showAboutModal}
|
||||
onOpenChange={setShowAboutModal}
|
||||
/>
|
||||
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -373,7 +445,9 @@ export default function App() {
|
||||
errorMessage={errorMessage}
|
||||
onClearError={handleClearError}
|
||||
onClose={() => setShowGallery(false)}
|
||||
hasExistingEntities={appState.agents.length > 0 || appState.workflows.length > 0}
|
||||
hasExistingEntities={
|
||||
appState.agents.length > 0 || appState.workflows.length > 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : appState.agents.length === 0 && appState.workflows.length === 0 ? (
|
||||
@@ -409,51 +483,50 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-2 -right-2 flex items-center justify-center">
|
||||
<div
|
||||
className={`h-12 w-1 rounded-full transition-all duration-200 ease-in-out ${
|
||||
isResizing
|
||||
? "bg-primary shadow-lg shadow-primary/25"
|
||||
: "bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showDebugPanel ? (
|
||||
<>
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className={`w-1 cursor-col-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
|
||||
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
|
||||
}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-2 -right-2 flex items-center justify-center">
|
||||
<div
|
||||
className={`h-12 w-1 rounded-full transition-all duration-200 ease-in-out ${
|
||||
isResizing
|
||||
? "bg-primary shadow-lg shadow-primary/25"
|
||||
: "bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button to reopen when closed */}
|
||||
{!debugPanelOpen && (
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDebugPanelOpen(true)}
|
||||
className="h-full w-8 rounded-none border-l"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</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
|
||||
/>
|
||||
{/* Right Panel - Debug */}
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
style={{ width: `${debugPanelWidth}px` }}
|
||||
>
|
||||
<DebugPanel
|
||||
events={debugEvents}
|
||||
isStreaming={false} // Each view manages its own streaming state
|
||||
onClose={() => setShowDebugPanel(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Button to reopen when closed */
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDebugPanel(true)}
|
||||
className="h-full w-10 rounded-none border-l"
|
||||
title="Show debug panel"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -461,10 +534,16 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
open={showAboutModal}
|
||||
onOpenChange={setShowAboutModal}
|
||||
/>
|
||||
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
|
||||
|
||||
{/* Toast Notification */}
|
||||
{showEntityNotFoundToast && (
|
||||
<Toast
|
||||
message="Entity not found. Showing first available entity instead."
|
||||
type="info"
|
||||
onClose={() => setShowEntityNotFoundToast(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { AgentDetailsModal } from "@/components/shared/agent-details-modal";
|
||||
import {
|
||||
SendHorizontal,
|
||||
User,
|
||||
@@ -28,14 +29,9 @@ import {
|
||||
Plus,
|
||||
AlertCircle,
|
||||
Paperclip,
|
||||
Info,
|
||||
Trash2,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
Package,
|
||||
FolderOpen,
|
||||
Database,
|
||||
Globe,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type {
|
||||
@@ -111,8 +107,33 @@ function MessageBubble({ message }: MessageBubbleProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{new Date(message.timestamp).toLocaleTimeString()}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
|
||||
<span>{new Date(message.timestamp).toLocaleTimeString()}</span>
|
||||
{!isUser && message.usage && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-[11px]">
|
||||
{message.usage.total_tokens >= 1000
|
||||
? `${(message.usage.total_tokens / 1000).toFixed(2)}k`
|
||||
: message.usage.total_tokens}{" "}
|
||||
tokens
|
||||
{message.usage.prompt_tokens > 0 && (
|
||||
<span className="opacity-70">
|
||||
{" "}
|
||||
(
|
||||
{message.usage.prompt_tokens >= 1000
|
||||
? `${(message.usage.prompt_tokens / 1000).toFixed(1)}k`
|
||||
: message.usage.prompt_tokens}{" "}
|
||||
in,{" "}
|
||||
{message.usage.completion_tokens >= 1000
|
||||
? `${(message.usage.completion_tokens / 1000).toFixed(1)}k`
|
||||
: message.usage.completion_tokens}{" "}
|
||||
out)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,12 +175,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const [pasteNotification, setPasteNotification] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [detailsExpanded, setDetailsExpanded] = useState(false);
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [threadUsage, setThreadUsage] = useState<{
|
||||
total_tokens: number;
|
||||
message_count: number;
|
||||
}>({ total_tokens: 0, message_count: 0 });
|
||||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const accumulatedText = useRef<string>("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const currentMessageUsage = useRef<{
|
||||
total_tokens: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
} | null>(null);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
@@ -439,12 +469,78 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
setThreadUsage({ total_tokens: 0, message_count: 0 });
|
||||
accumulatedText.current = "";
|
||||
} catch (error) {
|
||||
console.error("Failed to create thread:", error);
|
||||
}
|
||||
}, [selectedAgent]);
|
||||
|
||||
// Handle thread deletion
|
||||
const handleDeleteThread = useCallback(
|
||||
async (threadId: string, e?: React.MouseEvent) => {
|
||||
// Prevent event from bubbling to SelectItem
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Confirm deletion
|
||||
if (!confirm("Delete this thread? This cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await apiClient.deleteThread(threadId);
|
||||
if (success) {
|
||||
// Remove thread from available threads
|
||||
const updatedThreads = availableThreads.filter((t) => t.id !== threadId);
|
||||
setAvailableThreads(updatedThreads);
|
||||
|
||||
// If deleted thread was selected, switch to another thread or clear chat
|
||||
if (currentThread?.id === threadId) {
|
||||
if (updatedThreads.length > 0) {
|
||||
// Select the most recent remaining thread
|
||||
const nextThread = updatedThreads[0];
|
||||
setCurrentThread(nextThread);
|
||||
|
||||
// Load messages for the next thread
|
||||
try {
|
||||
const threadMessages = await apiClient.getThreadMessages(nextThread.id);
|
||||
setChatState({
|
||||
messages: threadMessages,
|
||||
isStreaming: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load thread messages:", error);
|
||||
setChatState({
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No threads left, clear everything
|
||||
setCurrentThread(undefined);
|
||||
setChatState({
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
setThreadUsage({ total_tokens: 0, message_count: 0 });
|
||||
accumulatedText.current = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Clear debug panel
|
||||
onDebugEvent("clear");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete thread:", error);
|
||||
alert("Failed to delete thread. Please try again.");
|
||||
}
|
||||
},
|
||||
[availableThreads, currentThread, onDebugEvent]
|
||||
);
|
||||
|
||||
// Handle thread selection
|
||||
const handleThreadSelect = useCallback(
|
||||
async (threadId: string) => {
|
||||
@@ -465,6 +561,16 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
isStreaming: false,
|
||||
});
|
||||
|
||||
// Calculate cumulative usage for this thread
|
||||
const totalTokens = threadMessages.reduce(
|
||||
(sum, msg) => sum + (msg.usage?.total_tokens || 0),
|
||||
0
|
||||
);
|
||||
const messageCount = threadMessages.filter(
|
||||
(msg) => msg.role === "assistant" && msg.usage
|
||||
).length;
|
||||
setThreadUsage({ total_tokens: totalTokens, message_count: messageCount });
|
||||
|
||||
console.log(
|
||||
`Restored ${threadMessages.length} messages for thread ${threadId}`
|
||||
);
|
||||
@@ -602,6 +708,20 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Pass all events to debug panel
|
||||
onDebugEvent(openAIEvent);
|
||||
|
||||
// Handle usage events
|
||||
if (openAIEvent.type === "response.usage.complete") {
|
||||
const usageEvent = openAIEvent as import("@/types").ResponseUsageEventComplete;
|
||||
console.log("📊 Usage event received:", usageEvent.data);
|
||||
if (usageEvent.data) {
|
||||
currentMessageUsage.current = {
|
||||
total_tokens: usageEvent.data.total_tokens || 0,
|
||||
prompt_tokens: usageEvent.data.prompt_tokens || 0,
|
||||
completion_tokens: usageEvent.data.completion_tokens || 0,
|
||||
};
|
||||
console.log("📊 Set usage:", currentMessageUsage.current);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle error events from the stream
|
||||
if (openAIEvent.type === "error") {
|
||||
const errorEvent = openAIEvent as ExtendedResponseStreamEvent & {
|
||||
@@ -663,14 +783,35 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// (Server will close the stream when done, so we'll exit the loop naturally)
|
||||
}
|
||||
|
||||
// Stream ended - mark as complete
|
||||
// Stream ended - mark as complete and attach usage
|
||||
const finalUsage = currentMessageUsage.current;
|
||||
console.log("📊 Stream ended, attaching usage to message:", finalUsage);
|
||||
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
isStreaming: false,
|
||||
messages: prev.messages.map((msg) =>
|
||||
msg.id === assistantMessage.id ? { ...msg, streaming: false } : msg
|
||||
msg.id === assistantMessage.id
|
||||
? {
|
||||
...msg,
|
||||
streaming: false,
|
||||
usage: finalUsage || undefined,
|
||||
}
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
|
||||
// Update thread-level usage stats
|
||||
if (finalUsage) {
|
||||
setThreadUsage((prev) => ({
|
||||
total_tokens: prev.total_tokens + finalUsage.total_tokens,
|
||||
message_count: prev.message_count + 1,
|
||||
}));
|
||||
console.log("📊 Updated thread usage");
|
||||
}
|
||||
|
||||
// Reset usage for next message
|
||||
currentMessageUsage.current = null;
|
||||
} catch (error) {
|
||||
console.error("Streaming error:", error);
|
||||
setChatState((prev) => ({
|
||||
@@ -831,14 +972,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDetailsExpanded(!detailsExpanded)}
|
||||
onClick={() => setDetailsModalOpen(true)}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title="View agent details"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${
|
||||
detailsExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -849,7 +987,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
onValueChange={handleThreadSelect}
|
||||
disabled={loadingThreads || isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectTrigger className="w-full sm:w-64">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingThreads
|
||||
@@ -860,7 +998,24 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
? `Thread ${currentThread.id.slice(-8)}`
|
||||
: "Select thread"
|
||||
}
|
||||
/>
|
||||
>
|
||||
{currentThread && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>Thread {currentThread.id.slice(-8)}</span>
|
||||
{threadUsage.total_tokens > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="text-muted-foreground">
|
||||
{threadUsage.total_tokens >= 1000
|
||||
? `${(threadUsage.total_tokens / 1000).toFixed(1)}k`
|
||||
: threadUsage.total_tokens}{" "}
|
||||
tokens
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableThreads.map((thread) => (
|
||||
@@ -878,6 +1033,16 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => currentThread && handleDeleteThread(currentThread.id)}
|
||||
disabled={!currentThread || isSubmitting}
|
||||
title={currentThread ? `Delete Thread ${currentThread.id.slice(-8)}` : "No thread selected"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
@@ -896,68 +1061,6 @@ 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 */}
|
||||
@@ -1077,6 +1180,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Details Modal */}
|
||||
<AgentDetailsModal
|
||||
agent={selectedAgent}
|
||||
open={detailsModalOpen}
|
||||
onOpenChange={setDetailsModalOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* AgentDetailsModal - Responsive grid-based modal for displaying agent metadata
|
||||
*/
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Bot,
|
||||
Package,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Database,
|
||||
Globe,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { AgentInfo } from "@/types";
|
||||
|
||||
interface AgentDetailsModalProps {
|
||||
agent: AgentInfo;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface DetailCardProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 bg-card ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentDetailsModal({
|
||||
agent,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AgentDetailsModalProps) {
|
||||
const sourceIcon =
|
||||
agent.source === "directory" ? (
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : agent.source === "in_memory" ? (
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
);
|
||||
|
||||
const sourceLabel =
|
||||
agent.source === "directory"
|
||||
? "Local"
|
||||
: agent.source === "in_memory"
|
||||
? "In-Memory"
|
||||
: "Gallery";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 flex-shrink-0">
|
||||
<DialogTitle>Agent Details</DialogTitle>
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1">
|
||||
{/* Header Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Bot className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{agent.name || agent.id}
|
||||
</h2>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p className="text-muted-foreground">{agent.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border mb-6" />
|
||||
|
||||
{/* Grid Layout for Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Model & Client */}
|
||||
{(agent.model || agent.chat_client_type) && (
|
||||
<DetailCard
|
||||
title="Model & Client"
|
||||
icon={<Bot className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{agent.model && (
|
||||
<div className="font-mono text-foreground">{agent.model}</div>
|
||||
)}
|
||||
{agent.chat_client_type && (
|
||||
<div className="text-xs">({agent.chat_client_type})</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailCard>
|
||||
)}
|
||||
|
||||
{/* Source */}
|
||||
<DetailCard title="Source" icon={sourceIcon}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-foreground">{sourceLabel}</div>
|
||||
{agent.module_path && (
|
||||
<div className="font-mono text-xs break-all">
|
||||
{agent.module_path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailCard>
|
||||
|
||||
{/* Environment */}
|
||||
<DetailCard
|
||||
title="Environment"
|
||||
icon={
|
||||
agent.has_env ? (
|
||||
<XCircle className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
)
|
||||
}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
agent.has_env ? "text-orange-600 dark:text-orange-400" : "text-green-600 dark:text-green-400"
|
||||
}
|
||||
>
|
||||
{agent.has_env
|
||||
? "Requires environment variables"
|
||||
: "No environment variables required"}
|
||||
</div>
|
||||
</DetailCard>
|
||||
</div>
|
||||
|
||||
{/* Full Width Sections */}
|
||||
{agent.instructions && (
|
||||
<DetailCard
|
||||
title="Instructions"
|
||||
icon={<FileText className="h-4 w-4 text-muted-foreground" />}
|
||||
className="mb-4"
|
||||
>
|
||||
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{agent.instructions}
|
||||
</div>
|
||||
</DetailCard>
|
||||
)}
|
||||
|
||||
{/* Tools and Middleware Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Tools */}
|
||||
<DetailCard
|
||||
title={`Tools (${agent.tools.length})`}
|
||||
icon={<Package className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
{agent.tools.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{agent.tools.map((tool, index) => (
|
||||
<li key={index} className="font-mono text-xs text-foreground">
|
||||
• {tool}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-muted-foreground">No tools configured</div>
|
||||
)}
|
||||
</DetailCard>
|
||||
|
||||
{/* Middleware */}
|
||||
{agent.middleware && agent.middleware.length > 0 && (
|
||||
<DetailCard
|
||||
title={`Middleware (${agent.middleware.length})`}
|
||||
icon={<Package className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{agent.middleware.map((mw, index) => (
|
||||
<li key={index} className="font-mono text-xs text-foreground">
|
||||
• {mw}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailCard>
|
||||
)}
|
||||
|
||||
{/* Context Providers */}
|
||||
{agent.context_providers && agent.context_providers.length > 0 && (
|
||||
<DetailCard
|
||||
title={`Context Providers (${agent.context_providers.length})`}
|
||||
icon={<Database className="h-4 w-4 text-muted-foreground" />}
|
||||
className={!agent.middleware || agent.middleware.length === 0 ? "md:col-start-2" : ""}
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{agent.context_providers.map((cp, index) => (
|
||||
<li key={index} className="font-mono text-xs text-foreground">
|
||||
• {cp}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,35 @@ export function AppHeader({
|
||||
}: AppHeaderProps) {
|
||||
return (
|
||||
<header className="flex h-14 items-center gap-4 border-b px-4">
|
||||
<div className="font-semibold">Dev UI</div>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 805 805"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z"
|
||||
fill="url(#paint0_linear_510_1294)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_510_1294"
|
||||
x1="255.628"
|
||||
y1="-34.3245"
|
||||
x2="618.483"
|
||||
y2="632.032"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#D59FFF" />
|
||||
<stop offset="1" stopColor="#8562C5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
Dev UI
|
||||
</div>
|
||||
<EntitySelector
|
||||
agents={agents}
|
||||
workflows={workflows}
|
||||
@@ -51,4 +79,4 @@ export function AppHeader({
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRef, useState } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Activity,
|
||||
Search,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Info,
|
||||
PanelRightClose,
|
||||
} from "lucide-react";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
|
||||
@@ -65,6 +67,7 @@ interface TraceEventData extends EventDataBase {
|
||||
interface DebugPanelProps {
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
isStreaming?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Helper function to accumulate OpenAI events into meaningful units
|
||||
@@ -1359,12 +1362,16 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function DebugPanel({ events, isStreaming = false }: DebugPanelProps) {
|
||||
export function DebugPanel({
|
||||
events,
|
||||
isStreaming = false,
|
||||
onClose,
|
||||
}: DebugPanelProps) {
|
||||
return (
|
||||
<div className=" overflow-auto h-[calc(100vh-3.7rem)] border-l">
|
||||
<Tabs defaultValue="events" className="h-full flex flex-col">
|
||||
<div className="px-3 pt-3">
|
||||
<TabsList className="w-full">
|
||||
<div className="px-3 pt-3 flex items-center gap-2">
|
||||
<TabsList className="flex-1">
|
||||
<TabsTrigger value="events" className="flex-1">
|
||||
Events
|
||||
</TabsTrigger>
|
||||
@@ -1375,6 +1382,17 @@ export function DebugPanel({ events, isStreaming = false }: DebugPanelProps) {
|
||||
Tools
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0 flex-shrink-0"
|
||||
title="Hide debug panel"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="events" className="flex-1 mt-0">
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* WorkflowDetailsModal - Responsive grid-based modal for displaying workflow metadata
|
||||
*/
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Workflow as WorkflowIcon,
|
||||
Package,
|
||||
FolderOpen,
|
||||
Database,
|
||||
Globe,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
PlayCircle,
|
||||
} from "lucide-react";
|
||||
import type { WorkflowInfo } from "@/types";
|
||||
|
||||
interface WorkflowDetailsModalProps {
|
||||
workflow: WorkflowInfo;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface DetailCardProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 bg-card ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowDetailsModal({
|
||||
workflow,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: WorkflowDetailsModalProps) {
|
||||
const sourceIcon =
|
||||
workflow.source === "directory" ? (
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : workflow.source === "in_memory" ? (
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
);
|
||||
|
||||
const sourceLabel =
|
||||
workflow.source === "directory"
|
||||
? "Local"
|
||||
: workflow.source === "in_memory"
|
||||
? "In-Memory"
|
||||
: "Gallery";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 flex-shrink-0">
|
||||
<DialogTitle>Workflow Details</DialogTitle>
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1">
|
||||
{/* Header Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<WorkflowIcon className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{workflow.name || workflow.id}
|
||||
</h2>
|
||||
</div>
|
||||
{workflow.description && (
|
||||
<p className="text-muted-foreground">{workflow.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border mb-6" />
|
||||
|
||||
{/* Grid Layout for Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Start Executor */}
|
||||
<DetailCard
|
||||
title="Start Executor"
|
||||
icon={<PlayCircle className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
<div className="font-mono text-foreground">
|
||||
{workflow.start_executor_id}
|
||||
</div>
|
||||
</DetailCard>
|
||||
|
||||
{/* Source */}
|
||||
<DetailCard title="Source" icon={sourceIcon}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-foreground">{sourceLabel}</div>
|
||||
{workflow.module_path && (
|
||||
<div className="font-mono text-xs break-all">
|
||||
{workflow.module_path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailCard>
|
||||
|
||||
{/* Environment */}
|
||||
<DetailCard
|
||||
title="Environment"
|
||||
icon={
|
||||
workflow.has_env ? (
|
||||
<XCircle className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
)
|
||||
}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
workflow.has_env
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-green-600 dark:text-green-400"
|
||||
}
|
||||
>
|
||||
{workflow.has_env
|
||||
? "Requires environment variables"
|
||||
: "No environment variables required"}
|
||||
</div>
|
||||
</DetailCard>
|
||||
</div>
|
||||
|
||||
{/* Executors */}
|
||||
<DetailCard
|
||||
title={`Executors (${workflow.executors.length})`}
|
||||
icon={<Package className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
{workflow.executors.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{workflow.executors.map((executor, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded"
|
||||
>
|
||||
{executor}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">No executors configured</div>
|
||||
)}
|
||||
</DetailCard>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Simple toast notification component
|
||||
* Displays floating notifications in the top-right corner
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export interface ToastProps {
|
||||
message: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
duration?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Toast({ message, type = "info", duration = 4000, onClose }: ToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 300); // Wait for fade out animation
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
const bgColorClass = {
|
||||
info: "bg-primary/10 border-primary/20",
|
||||
success: "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800",
|
||||
warning: "bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800",
|
||||
error: "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800",
|
||||
}[type];
|
||||
|
||||
const textColorClass = {
|
||||
info: "text-primary",
|
||||
success: "text-green-800 dark:text-green-200",
|
||||
warning: "text-orange-800 dark:text-orange-200",
|
||||
error: "text-red-800 dark:text-red-200",
|
||||
}[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-4 right-4 z-50 flex items-start gap-3 p-4 rounded-lg border shadow-lg max-w-md transition-all duration-300 ${
|
||||
isVisible ? "opacity-100 translate-x-0" : "opacity-0 translate-x-4"
|
||||
} ${bgColorClass}`}
|
||||
>
|
||||
<p className={`text-sm flex-1 ${textColorClass}`}>{message}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 300);
|
||||
}}
|
||||
className={`flex-shrink-0 hover:opacity-70 transition-opacity ${textColorClass}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Toast container for managing multiple toasts
|
||||
export interface ToastData {
|
||||
id: string;
|
||||
message: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: ToastData[];
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
duration={toast.duration}
|
||||
onClose={() => onRemove(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
DialogClose,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Send } from "lucide-react";
|
||||
import { Send, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { JSONSchemaProperty } from "@/types";
|
||||
|
||||
@@ -29,22 +29,27 @@ interface FormFieldProps {
|
||||
schema: JSONSchemaProperty;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
function FormField({ name, schema, value, onChange }: FormFieldProps) {
|
||||
function FormField({ name, schema, value, onChange, isRequired = false }: FormFieldProps) {
|
||||
const { type, description, enum: enumValues, default: defaultValue } = schema;
|
||||
|
||||
// For text/message/content fields, treat as textarea for better UX
|
||||
const isTextContentField = ['text', 'message', 'content', 'query', 'prompt'].includes(name.toLowerCase());
|
||||
|
||||
// Determine if this field should span full width
|
||||
// Only span full if it's a textarea or has very long description
|
||||
const shouldSpanFullWidth =
|
||||
schema.format === "textarea" ||
|
||||
(description && description.length > 100) ||
|
||||
type === "object" ||
|
||||
type === "array";
|
||||
isTextContentField || // text/message fields span full width
|
||||
(description && description.length > 150);
|
||||
|
||||
const shouldSpanTwoColumns =
|
||||
type === "object" ||
|
||||
schema.format === "textarea" ||
|
||||
(description && description.length > 50);
|
||||
isTextContentField ||
|
||||
(description && description.length > 80) ||
|
||||
type === "array"; // Arrays might need more space for comma-separated values
|
||||
|
||||
const fieldContent = (() => {
|
||||
// Handle different field types based on JSON Schema
|
||||
@@ -54,7 +59,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
|
||||
// Enum select
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>{name}</Label>
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Select
|
||||
value={
|
||||
typeof value === "string" && value
|
||||
@@ -83,12 +91,16 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
|
||||
);
|
||||
} else if (
|
||||
schema.format === "textarea" ||
|
||||
isTextContentField ||
|
||||
(description && description.length > 100)
|
||||
) {
|
||||
// Multi-line text
|
||||
// Multi-line text (including text/message/content fields)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>{name}</Label>
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
@@ -98,7 +110,7 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
|
||||
? defaultValue
|
||||
: `Enter ${name}`
|
||||
}
|
||||
rows={2}
|
||||
rows={isTextContentField ? 4 : 2}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
@@ -109,7 +121,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
|
||||
// Single-line text
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>{name}</Label>
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type="text"
|
||||
@@ -131,7 +146,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
|
||||
case "number":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>{name}</Label>
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type="number"
|
||||
@@ -161,7 +179,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
/>
|
||||
<Label htmlFor={name}>{name}</Label>
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
@@ -172,7 +193,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
|
||||
case "array":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>{name}</Label>
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={
|
||||
@@ -203,7 +227,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
|
||||
// For complex objects or unknown types, use JSON textarea
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>{name}</Label>
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={
|
||||
@@ -235,7 +262,7 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
|
||||
|
||||
// Return the field with appropriate grid column spanning
|
||||
const getColumnSpan = () => {
|
||||
if (shouldSpanFullWidth) return "md:col-span-3 xl:col-span-4";
|
||||
if (shouldSpanFullWidth) return "md:col-span-2 lg:col-span-3 xl:col-span-4";
|
||||
if (shouldSpanTwoColumns) return "xl:col-span-2";
|
||||
return "";
|
||||
};
|
||||
@@ -259,6 +286,7 @@ export function WorkflowInputForm({
|
||||
className,
|
||||
}: WorkflowInputFormProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
|
||||
|
||||
// Check if we're in embedded mode (being used inside another modal)
|
||||
const isEmbedded = className?.includes('embedded');
|
||||
@@ -268,10 +296,55 @@ export function WorkflowInputForm({
|
||||
// Determine field info
|
||||
const properties = inputSchema.properties || {};
|
||||
const fieldNames = Object.keys(properties);
|
||||
const requiredFields = inputSchema.required || [];
|
||||
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
|
||||
const primaryField = isSimpleInput ? "value" : fieldNames[0];
|
||||
const canSubmit = primaryField
|
||||
? formData[primaryField] !== undefined && formData[primaryField] !== ""
|
||||
|
||||
// Plan D: Separate required and optional fields first
|
||||
const allOptionalFieldNames = fieldNames.filter(name => !requiredFields.includes(name));
|
||||
|
||||
// Detect ChatMessage-like pattern
|
||||
const isChatMessageLike =
|
||||
requiredFields.includes('role') &&
|
||||
allOptionalFieldNames.some(f => ['text', 'message', 'content'].includes(f)) &&
|
||||
properties['role']?.type === 'string';
|
||||
|
||||
// For ChatMessage: hide 'role' field (will be auto-filled)
|
||||
const requiredFieldNames = fieldNames.filter(name =>
|
||||
requiredFields.includes(name) && !(isChatMessageLike && name === 'role')
|
||||
);
|
||||
const optionalFieldNames = allOptionalFieldNames;
|
||||
|
||||
// For ChatMessage: prioritize text/message/content field to show first
|
||||
const sortedOptionalFields = isChatMessageLike
|
||||
? [...optionalFieldNames].sort((a, b) => {
|
||||
const priority = (name: string) =>
|
||||
['text', 'message', 'content'].includes(name) ? 1 : 0;
|
||||
return priority(b) - priority(a);
|
||||
})
|
||||
: optionalFieldNames;
|
||||
|
||||
// Always show ALL required fields + fill to minimum visible with optional fields
|
||||
// For ChatMessage: show only 1 optional field (text)
|
||||
const MIN_VISIBLE_FIELDS = isChatMessageLike ? 1 : 6;
|
||||
const visibleOptionalCount = Math.max(0, MIN_VISIBLE_FIELDS - requiredFieldNames.length);
|
||||
const visibleOptionalFields = sortedOptionalFields.slice(0, visibleOptionalCount);
|
||||
const collapsedOptionalFields = sortedOptionalFields.slice(visibleOptionalCount);
|
||||
|
||||
const hasCollapsedFields = collapsedOptionalFields.length > 0;
|
||||
const hasRequiredFields = requiredFieldNames.length > 0;
|
||||
|
||||
// Update canSubmit to check required fields properly
|
||||
// For ChatMessage: role is auto-filled, so it's always valid
|
||||
const canSubmit = isSimpleInput
|
||||
? formData.value !== undefined && formData.value !== ""
|
||||
: requiredFields.length > 0
|
||||
? requiredFields.every(fieldName => {
|
||||
// Auto-filled fields are always valid
|
||||
if (isChatMessageLike && fieldName === 'role' && formData['role'] === 'user') {
|
||||
return true;
|
||||
}
|
||||
return formData[fieldName] !== undefined && formData[fieldName] !== "";
|
||||
})
|
||||
: Object.keys(formData).length > 0;
|
||||
|
||||
// Initialize form data
|
||||
@@ -287,9 +360,15 @@ export function WorkflowInputForm({
|
||||
initialData[key] = fieldSchema.enum[0];
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-fill role="user" for ChatMessage-like inputs
|
||||
if (isChatMessageLike && !initialData['role']) {
|
||||
initialData['role'] = 'user';
|
||||
}
|
||||
|
||||
setFormData(initialData);
|
||||
}
|
||||
}, [inputSchema]);
|
||||
}, [inputSchema, isChatMessageLike]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -306,7 +385,16 @@ export function WorkflowInputForm({
|
||||
const fieldName = fieldNames[0];
|
||||
onSubmit({ [fieldName]: formData[fieldName] || "" });
|
||||
} else {
|
||||
onSubmit(formData);
|
||||
// Filter out empty optional fields before submission
|
||||
const filteredData: Record<string, unknown> = {};
|
||||
Object.keys(formData).forEach(key => {
|
||||
const value = formData[key];
|
||||
// Include if: 1) required field, OR 2) has non-empty value
|
||||
if (requiredFields.includes(key) || (value !== undefined && value !== "" && value !== null)) {
|
||||
filteredData[key] = value;
|
||||
}
|
||||
});
|
||||
onSubmit(filteredData);
|
||||
}
|
||||
} else {
|
||||
onSubmit(formData);
|
||||
@@ -330,27 +418,84 @@ export function WorkflowInputForm({
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Simple input */}
|
||||
{isSimpleInput && primaryField && (
|
||||
{isSimpleInput && (
|
||||
<FormField
|
||||
name="Input"
|
||||
schema={inputSchema}
|
||||
value={formData.value}
|
||||
onChange={(value) => updateField("value", value)}
|
||||
isRequired={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Complex form fields */}
|
||||
{/* Complex form fields - Plan D: Required + Optional separation */}
|
||||
{!isSimpleInput && (
|
||||
<>
|
||||
{fieldNames.map((fieldName) => (
|
||||
{/* Required fields section */}
|
||||
{requiredFieldNames.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={true}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Separator between required and optional (only if both exist) */}
|
||||
{hasRequiredFields && optionalFieldNames.length > 0 && (
|
||||
<div className="sm:col-span-2 border-t border-border my-2"></div>
|
||||
)}
|
||||
|
||||
{/* Visible optional fields */}
|
||||
{visibleOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Collapsed optional fields toggle */}
|
||||
{hasCollapsedFields && (
|
||||
<div className="sm:col-span-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
|
||||
className="w-full justify-center gap-2"
|
||||
>
|
||||
{showAdvancedFields ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
Hide {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
Show {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed optional fields - only show when toggled */}
|
||||
{showAdvancedFields && collapsedOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -440,15 +585,16 @@ export function WorkflowInputForm({
|
||||
{/* Scrollable Form Content */}
|
||||
<div className="px-8 py-6 overflow-y-auto flex-1 min-h-0">
|
||||
<form id="workflow-modal-form" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-8 max-w-none">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 md:gap-8 max-w-none">
|
||||
{/* Simple input */}
|
||||
{isSimpleInput && primaryField && (
|
||||
<div className="md:col-span-3 xl:col-span-4">
|
||||
{isSimpleInput && (
|
||||
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||
<FormField
|
||||
name="Input"
|
||||
schema={inputSchema}
|
||||
value={formData.value}
|
||||
onChange={(value) => updateField("value", value)}
|
||||
isRequired={false}
|
||||
/>
|
||||
{inputSchema.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
@@ -458,16 +604,74 @@ export function WorkflowInputForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Complex form fields - Show all */}
|
||||
{/* Complex form fields - Plan D: Required + Optional separation */}
|
||||
{!isSimpleInput && (
|
||||
<>
|
||||
{fieldNames.map((fieldName) => (
|
||||
{/* Required fields section */}
|
||||
{requiredFieldNames.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={true}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Separator between required and optional (only if both exist) */}
|
||||
{hasRequiredFields && optionalFieldNames.length > 0 && (
|
||||
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||
<div className="border-t border-border"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visible optional fields */}
|
||||
{visibleOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Collapsed optional fields toggle */}
|
||||
{hasCollapsedFields && (
|
||||
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
|
||||
className="w-full justify-center gap-2"
|
||||
>
|
||||
{showAdvancedFields ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
Hide {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
Show {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed optional fields - only show when toggled */}
|
||||
{showAdvancedFields && collapsedOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -11,12 +11,7 @@ import {
|
||||
Play,
|
||||
Settings,
|
||||
RotateCcw,
|
||||
ChevronDown,
|
||||
Package,
|
||||
FolderOpen,
|
||||
Database,
|
||||
Globe,
|
||||
XCircle,
|
||||
Info,
|
||||
Workflow as WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import { LoadingState } from "@/components/ui/loading-state";
|
||||
@@ -24,6 +19,7 @@ import { WorkflowInputForm } from "@/components/workflow/workflow-input-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { WorkflowFlow } from "@/components/workflow/workflow-flow";
|
||||
import { useWorkflowEventCorrelation } from "@/hooks/useWorkflowEventCorrelation";
|
||||
import { WorkflowDetailsModal } from "@/components/shared/workflow-details-modal";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type {
|
||||
WorkflowInfo,
|
||||
@@ -190,11 +186,12 @@ function RunWorkflowButton({
|
||||
variant={
|
||||
buttonVariant === "destructive" ? "destructive" : "default"
|
||||
}
|
||||
size="icon"
|
||||
className="rounded-l-none border-l-0 w-9"
|
||||
title="Configure inputs"
|
||||
size="default"
|
||||
className="rounded-l-none border-l-0 px-3"
|
||||
title="Configure workflow inputs - customize parameters before running"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
<Settings className="w-4 h-4" />
|
||||
<span className="ml-1.5">Inputs</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -273,7 +270,7 @@ export function WorkflowView({
|
||||
const [workflowResult, setWorkflowResult] = useState<string>("");
|
||||
const [workflowError, setWorkflowError] = useState<string>("");
|
||||
const accumulatedText = useRef<string>("");
|
||||
const [detailsExpanded, setDetailsExpanded] = useState(false);
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
|
||||
// Panel resize state
|
||||
const [bottomPanelHeight, setBottomPanelHeight] = useState(() => {
|
||||
@@ -554,14 +551,11 @@ export function WorkflowView({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDetailsExpanded(!detailsExpanded)}
|
||||
onClick={() => setDetailsModalOpen(true)}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title="View workflow details"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${
|
||||
detailsExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -593,78 +587,6 @@ export function WorkflowView({
|
||||
{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 */}
|
||||
@@ -954,6 +876,13 @@ export function WorkflowView({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Details Modal */}
|
||||
<WorkflowDetailsModal
|
||||
workflow={selectedWorkflow}
|
||||
open={detailsModalOpen}
|
||||
onOpenChange={setDetailsModalOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import type {
|
||||
RunAgentRequest,
|
||||
RunWorkflowRequest,
|
||||
ThreadInfo,
|
||||
WorkflowInfo,
|
||||
} from "@/types";
|
||||
import type { AgentFrameworkRequest } from "@/types/agent-framework";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types/openai";
|
||||
|
||||
// Backend API response types to match Python Pydantic models
|
||||
interface EntityInfo {
|
||||
// Backend API response type - polymorphic entity that can be agent or workflow
|
||||
// This matches the Python Pydantic EntityInfo model which has all fields optional
|
||||
interface BackendEntityInfo {
|
||||
id: string;
|
||||
type: "agent" | "workflow";
|
||||
name: string;
|
||||
@@ -25,6 +27,13 @@ interface EntityInfo {
|
||||
metadata: Record<string, unknown>;
|
||||
source?: string;
|
||||
original_url?: string;
|
||||
// Agent-specific fields (present when type === "agent")
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
chat_client_type?: string;
|
||||
context_providers?: string[];
|
||||
middleware?: string[];
|
||||
// Workflow-specific fields (present when type === "workflow")
|
||||
executors?: string[];
|
||||
workflow_dump?: Record<string, unknown>;
|
||||
input_schema?: Record<string, unknown>;
|
||||
@@ -33,7 +42,7 @@ interface EntityInfo {
|
||||
}
|
||||
|
||||
interface DiscoveryResponse {
|
||||
entities: EntityInfo[];
|
||||
entities: BackendEntityInfo[];
|
||||
}
|
||||
|
||||
interface ThreadApiResponse {
|
||||
@@ -119,15 +128,15 @@ class ApiClient {
|
||||
|
||||
// Entity discovery using new unified endpoint
|
||||
async getEntities(): Promise<{
|
||||
entities: (AgentInfo | import("@/types").WorkflowInfo)[];
|
||||
entities: (AgentInfo | WorkflowInfo)[];
|
||||
agents: AgentInfo[];
|
||||
workflows: import("@/types").WorkflowInfo[];
|
||||
workflows: WorkflowInfo[];
|
||||
}> {
|
||||
const response = await this.request<DiscoveryResponse>("/v1/entities");
|
||||
|
||||
// Separate agents and workflows
|
||||
const agents: AgentInfo[] = [];
|
||||
const workflows: import("@/types").WorkflowInfo[] = [];
|
||||
const workflows: WorkflowInfo[] = [];
|
||||
|
||||
response.entities.forEach((entity) => {
|
||||
if (entity.type === "agent") {
|
||||
@@ -145,6 +154,12 @@ class ApiClient {
|
||||
typeof entity.metadata?.module_path === "string"
|
||||
? entity.metadata.module_path
|
||||
: undefined,
|
||||
// Agent-specific fields
|
||||
instructions: entity.instructions,
|
||||
model: entity.model,
|
||||
chat_client_type: entity.chat_client_type,
|
||||
context_providers: entity.context_providers,
|
||||
middleware: entity.middleware,
|
||||
});
|
||||
} else if (entity.type === "workflow") {
|
||||
const firstTool = entity.tools?.[0];
|
||||
@@ -183,7 +198,7 @@ class ApiClient {
|
||||
return agents;
|
||||
}
|
||||
|
||||
async getWorkflows(): Promise<import("@/types").WorkflowInfo[]> {
|
||||
async getWorkflows(): Promise<WorkflowInfo[]> {
|
||||
const { workflows } = await this.getEntities();
|
||||
return workflows;
|
||||
}
|
||||
@@ -467,8 +482,8 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// 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", {
|
||||
async addEntity(url: string, metadata?: Record<string, unknown>): Promise<BackendEntityInfo> {
|
||||
const response = await this.request<{ success: boolean; entity: BackendEntityInfo }>("/v1/entities/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url, metadata }),
|
||||
});
|
||||
|
||||
@@ -31,6 +31,12 @@ export interface AgentInfo {
|
||||
has_env: boolean;
|
||||
module_path?: string;
|
||||
required_env_vars?: EnvVarRequirement[];
|
||||
// Agent-specific fields
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
chat_client_type?: string;
|
||||
context_providers?: string[];
|
||||
middleware?: string[];
|
||||
}
|
||||
|
||||
// JSON Schema types for workflow input
|
||||
@@ -133,6 +139,11 @@ export interface ChatMessage {
|
||||
author_name?: string;
|
||||
message_id?: string;
|
||||
error?: boolean; // Flag to indicate this is an error message
|
||||
usage?: {
|
||||
total_tokens: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
// UI State types
|
||||
|
||||
@@ -6,7 +6,7 @@ readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251001"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
|
||||
@@ -2,12 +2,73 @@
|
||||
"""Sample weather agent for Agent Framework Debug UI."""
|
||||
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework import (
|
||||
ChatAgent,
|
||||
ChatContext,
|
||||
ChatMessage,
|
||||
ChatResponse,
|
||||
FunctionInvocationContext,
|
||||
Role,
|
||||
chat_middleware,
|
||||
function_middleware,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
|
||||
|
||||
@chat_middleware
|
||||
async def security_filter_middleware(
|
||||
context: ChatContext,
|
||||
next: Callable[[ChatContext], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Chat middleware that blocks requests containing sensitive information."""
|
||||
# Block requests with sensitive information
|
||||
blocked_terms = ["password", "secret", "api_key", "token"]
|
||||
|
||||
for message in context.messages:
|
||||
if message.text:
|
||||
message_lower = message.text.lower()
|
||||
for term in blocked_terms:
|
||||
if term in message_lower:
|
||||
# Override the response without calling the LLM
|
||||
context.result = ChatResponse(
|
||||
messages=[
|
||||
ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
text=(
|
||||
"I cannot process requests containing sensitive information. "
|
||||
"Please rephrase your question without including passwords, secrets, "
|
||||
"or other sensitive data."
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
return
|
||||
|
||||
await next(context)
|
||||
|
||||
|
||||
@function_middleware
|
||||
async def atlantis_location_filter_middleware(
|
||||
context: FunctionInvocationContext,
|
||||
next: Callable[[FunctionInvocationContext], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Function middleware that blocks weather requests for Atlantis."""
|
||||
# Check if location parameter is "atlantis"
|
||||
location = getattr(context.arguments, "location", None)
|
||||
if location and location.lower() == "atlantis":
|
||||
context.result = (
|
||||
"Blocked! Hold up right there!! Tell the user that "
|
||||
"'Atlantis is a special place, we must never ask about the weather there!!'"
|
||||
)
|
||||
context.terminate = True
|
||||
return
|
||||
|
||||
await next(context)
|
||||
|
||||
|
||||
def get_weather(
|
||||
location: Annotated[str, "The location to get the weather for."],
|
||||
) -> str:
|
||||
@@ -46,6 +107,7 @@ agent = ChatAgent(
|
||||
api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""),
|
||||
),
|
||||
tools=[get_weather, get_forecast],
|
||||
middleware=[security_filter_middleware, atlantis_location_filter_middleware],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Sequential Agents Workflow - Writer → Reviewer."""
|
||||
|
||||
from .workflow import workflow
|
||||
|
||||
__all__ = ["workflow"]
|
||||
@@ -0,0 +1,167 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent Workflow - Content Review with Quality Routing.
|
||||
|
||||
This sample demonstrates:
|
||||
- Using agents directly as executors
|
||||
- Conditional routing based on structured outputs
|
||||
- Quality-based workflow paths with convergence
|
||||
|
||||
Use case: Content creation with automated review.
|
||||
Writer creates content, Reviewer evaluates quality:
|
||||
- High quality (score >= 80): → Publisher → Summarizer
|
||||
- Low quality (score < 80): → Editor → Publisher → Summarizer
|
||||
Both paths converge at Summarizer for final report.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentExecutorResponse, WorkflowBuilder
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# Define structured output for review results
|
||||
class ReviewResult(BaseModel):
|
||||
"""Review evaluation with scores and feedback."""
|
||||
|
||||
score: int # Overall quality score (0-100)
|
||||
feedback: str # Concise, actionable feedback
|
||||
clarity: int # Clarity score (0-100)
|
||||
completeness: int # Completeness score (0-100)
|
||||
accuracy: int # Accuracy score (0-100)
|
||||
structure: int # Structure score (0-100)
|
||||
|
||||
|
||||
# Condition function: route to editor if score < 80
|
||||
def needs_editing(message: Any) -> bool:
|
||||
"""Check if content needs editing based on review score."""
|
||||
if not isinstance(message, AgentExecutorResponse):
|
||||
return False
|
||||
try:
|
||||
review = ReviewResult.model_validate_json(message.agent_run_response.text)
|
||||
return review.score < 80
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Condition function: content is approved (score >= 80)
|
||||
def is_approved(message: Any) -> bool:
|
||||
"""Check if content is approved (high quality)."""
|
||||
if not isinstance(message, AgentExecutorResponse):
|
||||
return True
|
||||
try:
|
||||
review = ReviewResult.model_validate_json(message.agent_run_response.text)
|
||||
return review.score >= 80
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
# Create Azure OpenAI chat client
|
||||
chat_client = AzureOpenAIChatClient(api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""))
|
||||
|
||||
# Create Writer agent - generates content
|
||||
writer = chat_client.create_agent(
|
||||
name="Writer",
|
||||
instructions=(
|
||||
"You are an excellent content writer. "
|
||||
"Create clear, engaging content based on the user's request. "
|
||||
"Focus on clarity, accuracy, and proper structure."
|
||||
),
|
||||
)
|
||||
|
||||
# Create Reviewer agent - evaluates and provides structured feedback
|
||||
reviewer = chat_client.create_agent(
|
||||
name="Reviewer",
|
||||
instructions=(
|
||||
"You are an expert content reviewer. "
|
||||
"Evaluate the writer's content based on:\n"
|
||||
"1. Clarity - Is it easy to understand?\n"
|
||||
"2. Completeness - Does it fully address the topic?\n"
|
||||
"3. Accuracy - Is the information correct?\n"
|
||||
"4. Structure - Is it well-organized?\n\n"
|
||||
"Return a JSON object with:\n"
|
||||
"- score: overall quality (0-100)\n"
|
||||
"- feedback: concise, actionable feedback\n"
|
||||
"- clarity, completeness, accuracy, structure: individual scores (0-100)"
|
||||
),
|
||||
response_format=ReviewResult,
|
||||
)
|
||||
|
||||
# Create Editor agent - improves content based on feedback
|
||||
editor = chat_client.create_agent(
|
||||
name="Editor",
|
||||
instructions=(
|
||||
"You are a skilled editor. "
|
||||
"You will receive content along with review feedback. "
|
||||
"Improve the content by addressing all the issues mentioned in the feedback. "
|
||||
"Maintain the original intent while enhancing clarity, completeness, accuracy, and structure."
|
||||
),
|
||||
)
|
||||
|
||||
# Create Publisher agent - formats content for publication
|
||||
publisher = chat_client.create_agent(
|
||||
name="Publisher",
|
||||
instructions=(
|
||||
"You are a publishing agent. "
|
||||
"You receive either approved content or edited content. "
|
||||
"Format it for publication with proper headings and structure."
|
||||
),
|
||||
)
|
||||
|
||||
# Create Summarizer agent - creates final publication report
|
||||
summarizer = chat_client.create_agent(
|
||||
name="Summarizer",
|
||||
instructions=(
|
||||
"You are a summarizer agent. "
|
||||
"Create a final publication report that includes:\n"
|
||||
"1. A brief summary of the published content\n"
|
||||
"2. The workflow path taken (direct approval or edited)\n"
|
||||
"3. Key highlights and takeaways\n"
|
||||
"Keep it concise and professional."
|
||||
),
|
||||
)
|
||||
|
||||
# Build workflow with branching and convergence:
|
||||
# Writer → Reviewer → [branches]:
|
||||
# - If score >= 80: → Publisher → Summarizer (direct approval path)
|
||||
# - If score < 80: → Editor → Publisher → Summarizer (improvement path)
|
||||
# Both paths converge at Summarizer for final report
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(writer)
|
||||
.add_edge(writer, reviewer)
|
||||
# Branch 1: High quality (>= 80) goes directly to publisher
|
||||
.add_edge(reviewer, publisher, condition=is_approved)
|
||||
# Branch 2: Low quality (< 80) goes to editor first, then publisher
|
||||
.add_edge(reviewer, editor, condition=needs_editing)
|
||||
.add_edge(editor, publisher)
|
||||
# Both paths converge: Publisher → Summarizer
|
||||
.add_edge(publisher, summarizer)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Launch the branching workflow in DevUI."""
|
||||
import logging
|
||||
|
||||
from agent_framework.devui import serve
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("Starting Agent Workflow (Content Review with Quality Routing)")
|
||||
logger.info("Available at: http://localhost:8093")
|
||||
logger.info("\nThis workflow demonstrates:")
|
||||
logger.info("- Conditional routing based on structured outputs")
|
||||
logger.info("- Path 1 (score >= 80): Reviewer → Publisher → Summarizer")
|
||||
logger.info("- Path 2 (score < 80): Reviewer → Editor → Publisher → Summarizer")
|
||||
logger.info("- Both paths converge at Summarizer for final report")
|
||||
|
||||
serve(entities=[workflow], port=8093, auto_open=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -158,217 +158,6 @@ async def test_unknown_content_fallback(mapper: MessageMapper, test_request: Age
|
||||
assert "WeirdUnknownContent" in event.delta
|
||||
|
||||
|
||||
def test_serialize_payload_primitives(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of primitive types."""
|
||||
assert mapper._serialize_payload(None) is None
|
||||
assert mapper._serialize_payload("test") == "test"
|
||||
assert mapper._serialize_payload(42) == 42
|
||||
assert mapper._serialize_payload(3.14) == 3.14
|
||||
assert mapper._serialize_payload(True) is True
|
||||
assert mapper._serialize_payload(False) is False
|
||||
|
||||
|
||||
def test_serialize_payload_sequences(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of lists, tuples, and sets."""
|
||||
# List
|
||||
result = mapper._serialize_payload([1, 2, "three"])
|
||||
assert result == [1, 2, "three"]
|
||||
assert isinstance(result, list)
|
||||
|
||||
# Tuple - should convert to list
|
||||
result = mapper._serialize_payload((1, 2, "three"))
|
||||
assert result == [1, 2, "three"]
|
||||
assert isinstance(result, list)
|
||||
|
||||
# Set - should convert to list (order may vary)
|
||||
result = mapper._serialize_payload({1, 2, 3})
|
||||
assert isinstance(result, list)
|
||||
assert set(result) == {1, 2, 3}
|
||||
|
||||
# Nested sequences
|
||||
result = mapper._serialize_payload([1, [2, 3], (4, 5)])
|
||||
assert result == [1, [2, 3], [4, 5]]
|
||||
|
||||
|
||||
def test_serialize_payload_dicts(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of dictionaries."""
|
||||
# Simple dict
|
||||
result = mapper._serialize_payload({"a": 1, "b": 2})
|
||||
assert result == {"a": 1, "b": 2}
|
||||
|
||||
# Dict with non-string keys (should convert to string)
|
||||
result = mapper._serialize_payload({1: "one", 2: "two"})
|
||||
assert result == {"1": "one", "2": "two"}
|
||||
|
||||
# Nested dicts
|
||||
result = mapper._serialize_payload({"outer": {"inner": {"deep": 42}}})
|
||||
assert result == {"outer": {"inner": {"deep": 42}}}
|
||||
|
||||
# Dict with mixed value types
|
||||
result = mapper._serialize_payload({"str": "text", "num": 123, "list": [1, 2], "dict": {"nested": True}})
|
||||
assert result == {"str": "text", "num": 123, "list": [1, 2], "dict": {"nested": True}}
|
||||
|
||||
|
||||
def test_serialize_payload_dataclass(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of dataclasses."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str
|
||||
age: int
|
||||
active: bool = True
|
||||
|
||||
person = Person(name="Alice", age=30)
|
||||
result = mapper._serialize_payload(person)
|
||||
|
||||
assert result == {"name": "Alice", "age": 30, "active": True}
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
def test_serialize_payload_pydantic_model(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of Pydantic models."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class User(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
is_active: bool = True
|
||||
|
||||
user = User(username="testuser", email="test@example.com")
|
||||
result = mapper._serialize_payload(user)
|
||||
|
||||
assert result == {"username": "testuser", "email": "test@example.com", "is_active": True}
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
def test_serialize_payload_nested_pydantic(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of nested Pydantic models."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Address(BaseModel):
|
||||
street: str
|
||||
city: str
|
||||
|
||||
class Person(BaseModel):
|
||||
name: str
|
||||
address: Address
|
||||
|
||||
person = Person(name="Bob", address=Address(street="123 Main St", city="Springfield"))
|
||||
result = mapper._serialize_payload(person)
|
||||
|
||||
assert result == {"name": "Bob", "address": {"street": "123 Main St", "city": "Springfield"}}
|
||||
|
||||
|
||||
def test_serialize_payload_object_with_dict_method(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of objects with dict() method."""
|
||||
|
||||
class CustomObject:
|
||||
def __init__(self):
|
||||
self.value = 42
|
||||
|
||||
def dict(self):
|
||||
return {"value": self.value, "type": "custom"}
|
||||
|
||||
obj = CustomObject()
|
||||
result = mapper._serialize_payload(obj)
|
||||
|
||||
assert result == {"value": 42, "type": "custom"}
|
||||
|
||||
|
||||
def test_serialize_payload_object_with_to_dict_method(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of objects with to_dict() method."""
|
||||
|
||||
class CustomObject:
|
||||
def __init__(self):
|
||||
self.value = 42
|
||||
|
||||
def to_dict(self):
|
||||
return {"value": self.value, "type": "custom_to_dict"}
|
||||
|
||||
obj = CustomObject()
|
||||
result = mapper._serialize_payload(obj)
|
||||
|
||||
assert result == {"value": 42, "type": "custom_to_dict"}
|
||||
|
||||
|
||||
def test_serialize_payload_object_with_model_dump_json(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of objects with model_dump_json() method."""
|
||||
import json
|
||||
|
||||
class CustomObject:
|
||||
def __init__(self):
|
||||
self.value = 42
|
||||
|
||||
def model_dump_json(self):
|
||||
return json.dumps({"value": self.value, "type": "json_dump"})
|
||||
|
||||
obj = CustomObject()
|
||||
result = mapper._serialize_payload(obj)
|
||||
|
||||
assert result == {"value": 42, "type": "json_dump"}
|
||||
|
||||
|
||||
def test_serialize_payload_object_with_dict_attr(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of objects with __dict__ attribute."""
|
||||
|
||||
class SimpleObject:
|
||||
def __init__(self):
|
||||
self.public_value = 42
|
||||
self._private_value = 100 # Should be excluded
|
||||
|
||||
obj = SimpleObject()
|
||||
result = mapper._serialize_payload(obj)
|
||||
|
||||
assert "public_value" in result
|
||||
assert result["public_value"] == 42
|
||||
assert "_private_value" not in result
|
||||
|
||||
|
||||
def test_serialize_payload_fallback_to_string(mapper: MessageMapper) -> None:
|
||||
"""Test that unserializable objects fall back to string representation."""
|
||||
|
||||
class WeirdObject:
|
||||
__slots__ = () # Prevent __dict__ attribute
|
||||
|
||||
def __str__(self):
|
||||
return "weird_object_string"
|
||||
|
||||
obj = WeirdObject()
|
||||
result = mapper._serialize_payload(obj)
|
||||
|
||||
assert result == "weird_object_string"
|
||||
|
||||
|
||||
def test_serialize_payload_complex_nested(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of complex nested structures."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@dataclass
|
||||
class DataItem:
|
||||
value: int
|
||||
|
||||
class ConfigModel(BaseModel):
|
||||
enabled: bool
|
||||
count: int
|
||||
|
||||
complex_data = {
|
||||
"items": [DataItem(value=1), DataItem(value=2)],
|
||||
"config": ConfigModel(enabled=True, count=5),
|
||||
"nested": {"list": [1, 2, 3], "tuple": (4, 5, 6)},
|
||||
"primitive": 42,
|
||||
}
|
||||
|
||||
result = mapper._serialize_payload(complex_data)
|
||||
|
||||
assert result["items"] == [{"value": 1}, {"value": 2}]
|
||||
assert result["config"] == {"enabled": True, "count": 5}
|
||||
assert result["nested"] == {"list": [1, 2, 3], "tuple": [4, 5, 6]}
|
||||
assert result["primitive"] == 42
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Simple test runner
|
||||
async def run_all_tests() -> None:
|
||||
@@ -377,7 +166,7 @@ if __name__ == "__main__":
|
||||
model="agent-framework", input="Test", stream=True, extra_body=AgentFrameworkExtraBody(entity_id="test")
|
||||
)
|
||||
|
||||
async_tests = [
|
||||
tests = [
|
||||
("Critical isinstance bug detection", test_critical_isinstance_bug_detection),
|
||||
("Text content mapping", test_text_content_mapping),
|
||||
("Function call mapping", test_function_call_mapping),
|
||||
@@ -386,34 +175,12 @@ if __name__ == "__main__":
|
||||
("Unknown content fallback", test_unknown_content_fallback),
|
||||
]
|
||||
|
||||
sync_tests = [
|
||||
("Serialize primitives", test_serialize_payload_primitives),
|
||||
("Serialize sequences", test_serialize_payload_sequences),
|
||||
("Serialize dicts", test_serialize_payload_dicts),
|
||||
("Serialize dataclass", test_serialize_payload_dataclass),
|
||||
("Serialize pydantic model", test_serialize_payload_pydantic_model),
|
||||
("Serialize nested pydantic", test_serialize_payload_nested_pydantic),
|
||||
("Serialize dict method", test_serialize_payload_object_with_dict_method),
|
||||
("Serialize to_dict method", test_serialize_payload_object_with_to_dict_method),
|
||||
("Serialize model_dump_json", test_serialize_payload_object_with_model_dump_json),
|
||||
("Serialize __dict__ attr", test_serialize_payload_object_with_dict_attr),
|
||||
("Serialize fallback to string", test_serialize_payload_fallback_to_string),
|
||||
("Serialize complex nested", test_serialize_payload_complex_nested),
|
||||
]
|
||||
|
||||
passed = 0
|
||||
for _test_name, test_func in async_tests:
|
||||
for _test_name, test_func in tests:
|
||||
try:
|
||||
await test_func(mapper, test_request)
|
||||
passed += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for _test_name, test_func in sync_tests:
|
||||
try:
|
||||
test_func(mapper)
|
||||
passed += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
asyncio.run(run_all_tests())
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Test schema generation for different input types."""
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add parent package to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from agent_framework_devui._utils import generate_input_schema
|
||||
|
||||
|
||||
@dataclass
|
||||
class InputData:
|
||||
text: str
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Address:
|
||||
street: str
|
||||
city: str
|
||||
zipcode: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonData:
|
||||
name: str
|
||||
age: int
|
||||
address: Address
|
||||
|
||||
|
||||
def test_builtin_types_schema_generation():
|
||||
"""Test schema generation for built-in types."""
|
||||
# Test str schema
|
||||
str_schema = generate_input_schema(str)
|
||||
assert str_schema is not None
|
||||
assert isinstance(str_schema, dict)
|
||||
|
||||
# Test dict schema
|
||||
dict_schema = generate_input_schema(dict)
|
||||
assert dict_schema is not None
|
||||
assert isinstance(dict_schema, dict)
|
||||
|
||||
# Test int schema
|
||||
int_schema = generate_input_schema(int)
|
||||
assert int_schema is not None
|
||||
assert isinstance(int_schema, dict)
|
||||
|
||||
|
||||
def test_dataclass_schema_generation():
|
||||
"""Test schema generation for dataclass."""
|
||||
schema = generate_input_schema(InputData)
|
||||
|
||||
assert schema is not None
|
||||
assert isinstance(schema, dict)
|
||||
|
||||
# Basic schema structure checks
|
||||
if "properties" in schema:
|
||||
properties = schema["properties"]
|
||||
assert "text" in properties
|
||||
assert "source" in properties
|
||||
|
||||
|
||||
def test_chat_message_schema_generation():
|
||||
"""Test schema generation for ChatMessage (SerializationMixin)."""
|
||||
try:
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
schema = generate_input_schema(ChatMessage)
|
||||
assert schema is not None
|
||||
assert isinstance(schema, dict)
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("ChatMessage not available - agent_framework not installed")
|
||||
|
||||
|
||||
def test_pydantic_model_schema_generation():
|
||||
"""Test schema generation for Pydantic models."""
|
||||
try:
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class UserInput(BaseModel):
|
||||
name: str = Field(description="User's name")
|
||||
age: int = Field(description="User's age")
|
||||
email: str | None = Field(default=None, description="Optional email")
|
||||
|
||||
schema = generate_input_schema(UserInput)
|
||||
assert schema is not None
|
||||
assert isinstance(schema, dict)
|
||||
|
||||
# Check if properties exist
|
||||
if "properties" in schema:
|
||||
properties = schema["properties"]
|
||||
assert "name" in properties
|
||||
assert "age" in properties
|
||||
assert "email" in properties
|
||||
|
||||
except ImportError:
|
||||
pytest.skip("Pydantic not available")
|
||||
|
||||
|
||||
def test_nested_dataclass_schema_generation():
|
||||
"""Test schema generation for nested dataclass."""
|
||||
schema = generate_input_schema(PersonData)
|
||||
|
||||
assert schema is not None
|
||||
assert isinstance(schema, dict)
|
||||
|
||||
# Basic schema structure checks
|
||||
if "properties" in schema:
|
||||
properties = schema["properties"]
|
||||
assert "name" in properties
|
||||
assert "age" in properties
|
||||
assert "address" in properties
|
||||
|
||||
|
||||
def test_schema_generation_error_handling():
|
||||
"""Test schema generation with invalid inputs."""
|
||||
# Test with a non-type object - should handle gracefully
|
||||
try:
|
||||
# Use a non-type object that might cause issues
|
||||
schema = generate_input_schema("not_a_type") # type: ignore
|
||||
# If it doesn't raise an exception, the result should be valid
|
||||
if schema is not None:
|
||||
assert isinstance(schema, dict)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
# It's acceptable for this to raise an error
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Simple test runner for manual execution
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user