Python: DevUI fixes : Add multimodal input support for workflows and refactor chat input (#2593)

* show app version in devui .NET: Python: Improved Versioning for DevUI
Fixes #2059

* feat: Add multimodal input support for workflows and refactor chat input

This PR adds support for multimodal content (images, files) in workflow
inputs and refactors the chat input into a reusable component.

## Multimodal Workflow Support
- Add `isChatMessageSchema()` to detect ChatMessage input schemas
- Update `RunWorkflowButton` to use `ChatMessageInput` for ChatMessage workflows
- Wrap multimodal content in OpenAI message format for backend processing
- Add `_is_openai_multimodal_format()` to detect OpenAI ResponseInputParam
- Update `_parse_workflow_input()` to route multimodal input through
  existing `_convert_input_to_chat_message()` converter

## Reusable ChatMessageInput Component
- Extract chat input logic from agent-view into `ChatMessageInput` component
- Support file upload, drag & drop, paste handling, and attachments
- Add `useDragDrop` hook for parent-level drag handling with full-area
  drop zones
- Refactor agent-view to use the new shared component

## Other Improvements
- Add `isStreaming` prop to executor nodes for animation control
- Clean up unused imports and state variables in agent-view
- Add tests for multimodal workflow input handling

Fixes workflow input not receiving images when using AgentExecutor nodes.

* add self loop edge, fix #2470

* fix test
This commit is contained in:
Victor Dibia
2025-12-03 12:15:51 -08:00
committed by GitHub
Unverified
parent 6835161f2d
commit 411ee7a60f
38 changed files with 3488 additions and 1493 deletions
@@ -134,9 +134,12 @@ class ConversationStore(ABC):
pass
@abstractmethod
def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
async def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
"""Get a specific conversation item by ID.
Supports checkpoint items - will load full checkpoint state from storage.
For checkpoints, the full state is included in metadata.full_checkpoint.
Args:
conversation_id: Conversation ID
item_id: Item ID
@@ -162,7 +165,7 @@ class ConversationStore(ABC):
pass
@abstractmethod
def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
async def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
"""Filter conversations by metadata (e.g., agent_id).
Args:
@@ -444,7 +447,15 @@ class InMemoryConversationStore(ConversationStore):
# Get all checkpoints for this conversation
checkpoints = await checkpoint_storage.list_checkpoints()
for checkpoint in checkpoints:
# Create a conversation item for each checkpoint
# Create a conversation item for each checkpoint with summary metadata
# Full checkpoint state is NOT included here (too large for list view)
# Use get_item() to retrieve full checkpoint details
# Calculate approximate size of checkpoint
import json
checkpoint_json = json.dumps(checkpoint.to_dict())
checkpoint_size = len(checkpoint_json.encode("utf-8"))
checkpoint_item = {
"id": f"checkpoint_{checkpoint.checkpoint_id}",
"type": "checkpoint",
@@ -452,6 +463,15 @@ class InMemoryConversationStore(ConversationStore):
"workflow_id": checkpoint.workflow_id,
"timestamp": checkpoint.timestamp,
"status": "completed",
"metadata": {
# Summary metrics for list view
"iteration_count": checkpoint.iteration_count,
"pending_hil_count": len(checkpoint.pending_request_info_events),
"has_pending_hil": len(checkpoint.pending_request_info_events) > 0,
"message_count": sum(len(msgs) for msgs in checkpoint.messages.values()),
"size_bytes": checkpoint_size,
"version": checkpoint.version,
},
}
items.append(cast(ConversationItem, checkpoint_item))
@@ -472,24 +492,91 @@ class InMemoryConversationStore(ConversationStore):
return paginated_items, has_more
def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
"""Get a specific conversation item by ID."""
# Use the item index for O(1) lookup
async def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
"""Get a specific conversation item by ID.
Supports checkpoint items - will load full checkpoint state from storage.
For checkpoints, the full state is included in metadata.full_checkpoint.
"""
# First check item index for messages, function calls, etc. (O(1) lookup)
conv_items = self._item_index.get(conversation_id, {})
return conv_items.get(item_id)
item = conv_items.get(item_id)
if item:
return item
# If not found and ID is a checkpoint, load from checkpoint storage
if item_id.startswith("checkpoint_"):
checkpoint_id = item_id[len("checkpoint_") :] # Remove "checkpoint_" prefix
conv_data = self._conversations.get(conversation_id)
if not conv_data:
return None
checkpoint_storage = conv_data.get("checkpoint_storage")
if not checkpoint_storage:
return None
# Load full checkpoint from storage
checkpoint = await checkpoint_storage.load_checkpoint(checkpoint_id)
if not checkpoint:
return None
# Calculate size of checkpoint
import json
checkpoint_json = json.dumps(checkpoint.to_dict())
checkpoint_size = len(checkpoint_json.encode("utf-8"))
# Build checkpoint item with FULL state in metadata
checkpoint_item = {
"id": item_id,
"type": "checkpoint",
"checkpoint_id": checkpoint.checkpoint_id,
"workflow_id": checkpoint.workflow_id,
"timestamp": checkpoint.timestamp,
"status": "completed",
"metadata": {
# Summary metrics (same as list view)
"iteration_count": checkpoint.iteration_count,
"pending_hil_count": len(checkpoint.pending_request_info_events),
"has_pending_hil": len(checkpoint.pending_request_info_events) > 0,
"message_count": sum(len(msgs) for msgs in checkpoint.messages.values()),
"size_bytes": checkpoint_size,
"version": checkpoint.version,
# 🔥 FULL checkpoint state (lazy loaded)
"full_checkpoint": checkpoint.to_dict(),
},
}
return cast(ConversationItem, checkpoint_item)
return None
def get_thread(self, conversation_id: str) -> AgentThread | None:
"""Get AgentThread for execution - CRITICAL for agent.run_stream()."""
conv_data = self._conversations.get(conversation_id)
return conv_data["thread"] if conv_data else None
def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
async def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
"""Filter conversations by metadata (e.g., agent_id)."""
results = []
for conv_data in self._conversations.values():
conv_meta = conv_data.get("metadata", {})
conv_meta = conv_data.get("metadata", {}).copy() # Copy to avoid mutating original
# Check if all filter items match
if all(conv_meta.get(k) == v for k, v in metadata_filter.items()):
# Enrich workflow sessions with checkpoint summary
if conv_meta.get("type") == "workflow_session":
checkpoint_storage = conv_data.get("checkpoint_storage")
if checkpoint_storage:
checkpoints = await checkpoint_storage.list_checkpoints()
latest = checkpoints[0] if checkpoints else None
conv_meta["checkpoint_summary"] = {
"count": len(checkpoints),
"latest_iteration": latest.iteration_count if latest else 0,
"has_pending_hil": len(latest.pending_request_info_events) > 0 if latest else False,
"pending_hil_count": len(latest.pending_request_info_events) if latest else 0,
}
results.append(
Conversation(
id=conv_data["id"],
@@ -498,6 +585,10 @@ class InMemoryConversationStore(ConversationStore):
metadata=conv_meta,
)
)
# Sort by created_at descending (most recent first)
results.sort(key=lambda c: c.created_at, reverse=True)
return results
@@ -722,6 +722,20 @@ class AgentFrameworkExecutor:
return json.dumps(input_data)
return str(input_data)
def _is_openai_multimodal_format(self, input_data: Any) -> bool:
"""Check if input is OpenAI ResponseInputParam format (list with message items).
Args:
input_data: Input data to check
Returns:
True if input is OpenAI multimodal format
"""
if not isinstance(input_data, list) or not input_data:
return False
first_item = input_data[0]
return isinstance(first_item, dict) and first_item.get("type") == "message"
async def _parse_workflow_input(self, workflow: Any, raw_input: Any) -> Any:
"""Parse input based on workflow's expected input type.
@@ -733,9 +747,26 @@ class AgentFrameworkExecutor:
Parsed input appropriate for the workflow
"""
try:
# Handle structured input
# Handle JSON string input (from frontend api.ts JSON.stringify)
if isinstance(raw_input, str):
try:
parsed = json.loads(raw_input)
raw_input = parsed
except (json.JSONDecodeError, TypeError):
# Plain text string, continue with string handling
pass
# Check for OpenAI multimodal format (list with type: "message")
# This handles ChatMessage inputs with images, files, etc.
if self._is_openai_multimodal_format(raw_input):
logger.debug("Detected OpenAI multimodal format, converting to ChatMessage")
return self._convert_input_to_chat_message(raw_input)
# Handle structured input (dict)
if isinstance(raw_input, dict):
return self._parse_structured_workflow_input(workflow, raw_input)
# Handle string input
return self._parse_raw_workflow_input(workflow, str(raw_input))
except Exception as e:
@@ -885,6 +885,10 @@ class MessageMapper:
context[f"exec_item_{executor_id}"] = item_id
context["output_index"] = context.get("output_index", -1) + 1
# Track current executor for routing Magentic agent events
# This allows MagenticAgentDeltaEvent to route to the executor's item
context["current_executor_id"] = executor_id
# Create ExecutorActionItem with proper type
executor_item = ExecutorActionItem(
type="executor_action",
@@ -908,6 +912,10 @@ class MessageMapper:
executor_id = getattr(event, "executor_id", "unknown")
item_id = context.get(f"exec_item_{executor_id}", f"exec_{executor_id}_unknown")
# Clear current executor tracking when executor completes
if context.get("current_executor_id") == executor_id:
context.pop("current_executor_id", None)
# Create ExecutorActionItem with completed status
# ExecutorCompletedEvent uses 'data' field, not 'result'
executor_item = ExecutorActionItem(
@@ -1059,6 +1067,30 @@ class MessageMapper:
text = getattr(event, "text", None)
if text:
# Check if we're inside an executor - route to executor's item
# This prevents duplicate timeline entries (executor + inner agent)
current_executor_id = context.get("current_executor_id")
executor_item_key = f"exec_item_{current_executor_id}" if current_executor_id else None
if executor_item_key and executor_item_key in context:
# Route delta to the executor's item instead of creating a new message item
item_id = context[executor_item_key]
# Emit text delta event routed to the executor's item
return [
ResponseTextDeltaEvent(
type="response.output_text.delta",
output_index=context.get("output_index", 0),
content_index=0,
item_id=item_id,
delta=text,
logprobs=[],
sequence_number=self._next_sequence(context),
)
]
# Fallback: No executor context - create separate message item (original behavior)
# This handles cases where MagenticAgentDeltaEvent is emitted outside an executor
events = []
# Track Magentic agent messages separately from regular messages
@@ -1181,7 +1213,21 @@ class MessageMapper:
agent_id = getattr(event, "agent_id", "unknown_agent")
message = getattr(event, "message", None)
# Track Magentic agent messages
# Check if we're inside an executor - if so, deltas were already routed there
# We don't need to emit a separate message completion event
current_executor_id = context.get("current_executor_id")
executor_item_key = f"exec_item_{current_executor_id}" if current_executor_id else None
if executor_item_key and executor_item_key in context:
# Deltas were routed to executor item - no separate message item to complete
# The executor's output_item.done will mark completion
logger.debug(
f"MagenticAgentMessageEvent from {agent_id} - "
f"deltas routed to executor {current_executor_id}, skipping"
)
return []
# Fallback: Handle case where we created a separate message item (no executor context)
magentic_key = f"magentic_message_{agent_id}"
# Check if we were streaming for this agent
@@ -2,14 +2,17 @@
"""FastAPI server implementation."""
import asyncio
import importlib.metadata
import inspect
import json
import logging
import os
import secrets
import uuid
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any
from typing import Any, cast
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
@@ -26,6 +29,12 @@ from .models._discovery_models import Deployment, DeploymentConfig, DiscoveryRes
logger = logging.getLogger(__name__)
# Get package version
try:
__version__ = importlib.metadata.version("agent-framework-devui")
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
# No AuthMiddleware class needed - we'll use the decorator pattern instead
@@ -70,6 +79,7 @@ class DevServer:
self.deployment_manager = DeploymentManager()
self._app: FastAPI | None = None
self._pending_entities: list[Any] | None = None
self._running_tasks: dict[str, asyncio.Task[Any]] = {} # Track running response tasks for cancellation
def _is_dev_mode(self) -> bool:
"""Check if running in developer mode.
@@ -293,7 +303,7 @@ class DevServer:
app = FastAPI(
title="Agent Framework Server",
description="OpenAI-compatible API server for Agent Framework and other AI frameworks",
version="1.0.0",
version=__version__,
lifespan=lifespan,
)
@@ -388,8 +398,6 @@ class DevServer:
"""Get server metadata and configuration."""
import os
from . import __version__
# Ensure executors are initialized to check capabilities
openai_executor = await self._ensure_openai_executor()
@@ -731,13 +739,18 @@ class DevServer:
# Execute request
if request.stream:
# Generate response ID for tracking
response_id = f"resp_{uuid.uuid4().hex[:8]}"
logger.info(f"[CANCELLATION] Creating response {response_id} for entity {entity_id}")
return StreamingResponse(
self._stream_execution(executor, request),
self._stream_with_cancellation(executor, request, response_id),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"X-Response-ID": response_id, # Include ID for debugging/tracking
},
)
return await executor.execute_sync(request)
@@ -747,6 +760,30 @@ class DevServer:
error = OpenAIError.create(error_msg)
return JSONResponse(status_code=500, content=error.to_dict())
@app.post("/v1/responses/{response_id}/cancel")
async def cancel_response(response_id: str) -> dict[str, Any]:
"""Cancel a running response execution.
This endpoint allows explicit cancellation of a running stream.
Note: Cancellation also happens automatically when the client disconnects.
"""
logger.info(f"[CANCELLATION] Cancel request received for {response_id}")
if task := self._running_tasks.get(response_id):
if not task.done():
logger.info(f"[CANCELLATION] Cancelling task for {response_id}")
task.cancel()
# Wait briefly for cancellation to propagate
try: # noqa: SIM105
await asyncio.wait_for(task, timeout=0.5)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
return {"status": "cancelled", "response_id": response_id}
logger.warning(f"[CANCELLATION] Task already completed for {response_id}")
return {"status": "already_completed", "response_id": response_id}
logger.warning(f"[CANCELLATION] No task found for {response_id}")
return {"status": "not_found", "response_id": response_id}
# ========================================
# OpenAI Conversations API (Standard)
# ========================================
@@ -862,7 +899,7 @@ class DevServer:
filters["type"] = type
# Apply filters
conversations = executor.conversation_store.list_conversations_by_metadata(filters)
conversations = await executor.conversation_store.list_conversations_by_metadata(filters)
return {
"object": "list",
@@ -973,13 +1010,19 @@ class DevServer:
@app.get("/v1/conversations/{conversation_id}/items/{item_id}")
async def retrieve_conversation_item(conversation_id: str, item_id: str) -> dict[str, Any]:
"""Get specific conversation item - OpenAI standard."""
"""Get specific conversation item - OpenAI standard.
Supports checkpoint items - returns full checkpoint state in metadata.full_checkpoint.
"""
try:
executor = await self._ensure_executor()
item = executor.conversation_store.get_item(conversation_id, item_id)
item = await executor.conversation_store.get_item(conversation_id, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
result: dict[str, Any] = item.model_dump()
# Handle both Pydantic models and dicts
result: dict[str, Any] = (
item.model_dump() if hasattr(item, "model_dump") else cast(dict[str, Any], item)
)
return result
except HTTPException:
raise
@@ -1139,6 +1182,100 @@ class DevServer:
}
yield f"data: {json.dumps(error_event)}\n\n"
async def _stream_with_cancellation(
self, executor: AgentFrameworkExecutor, request: AgentFrameworkRequest, response_id: str
) -> AsyncGenerator[str, None]:
"""Stream execution with automatic cancellation on client disconnect.
This wrapper adds cancellation support to the execution stream:
1. Tracks the execution as an asyncio Task
2. Detects client disconnection via GeneratorExit
3. Cancels the task when client disconnects
4. Propagates CancelledError through the execution chain
Args:
executor: Agent Framework executor instance
request: Request to execute
response_id: Unique ID for this response/execution
Yields:
SSE-formatted event strings from the original stream
"""
task = None
async def execution_wrapper() -> AsyncGenerator[str, None]:
"""Inner wrapper to handle the actual execution."""
try:
logger.debug(f"[CANCELLATION] Starting execution for {response_id}")
async for chunk in self._stream_execution(executor, request):
# Check if we're being cancelled
current_task = asyncio.current_task()
if current_task and current_task.cancelled():
logger.info(f"[CANCELLATION] Detected cancellation, breaking stream for {response_id}")
break
yield chunk
except asyncio.CancelledError:
logger.info(f"[CANCELLATION] Execution cancelled via CancelledError for {response_id}")
# Emit cancellation event to client (if still connected)
cancelled_event = {
"type": "response.cancelled",
"response_id": response_id,
"message": "Execution cancelled by user",
}
yield f"data: {json.dumps(cancelled_event)}\n\n"
raise
except Exception as e:
logger.error(f"[CANCELLATION] Error in cancellable execution for {response_id}: {e}")
raise
try:
# Get or create the current task and track it
task = asyncio.current_task()
if task:
self._running_tasks[response_id] = task
logger.debug(f"[CANCELLATION] Tracking task {task.get_name()} for response {response_id}")
else:
logger.warning(f"[CANCELLATION] No current task found to track for {response_id}")
# Stream the execution
async for chunk in execution_wrapper():
yield chunk
logger.debug(f"[CANCELLATION] Stream completed normally for {response_id}")
except GeneratorExit:
# Client disconnected - this is raised when the generator is closed
logger.info(f"[CANCELLATION] Client disconnected, initiating cancellation for {response_id}")
if task and not task.done():
logger.info(f"[CANCELLATION] Cancelling task for disconnected client {response_id}")
task.cancel()
# Give it a moment to cancel gracefully
# Note: We should NOT use asyncio.shield here as it prevents cancellation
try:
await asyncio.wait_for(task, timeout=1.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
logger.debug(f"[CANCELLATION] Task cancelled successfully for {response_id}")
except Exception as e:
logger.warning(f"[CANCELLATION] Error during task cancellation for {response_id}: {e}")
raise # Re-raise GeneratorExit to properly close the generator
except asyncio.CancelledError:
logger.info(f"[CANCELLATION] Stream cancelled for {response_id}")
raise
except Exception as e:
logger.error(f"[CANCELLATION] Unexpected error in stream for {response_id}: {e}")
raise
finally:
# Clean up tracking
if response_id in self._running_tasks:
self._running_tasks.pop(response_id)
logger.debug(f"[CANCELLATION] Cleaned up task tracking for {response_id}")
def _mount_ui(self, app: FastAPI) -> None:
"""Mount the UI as static files."""
from pathlib import Path
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -107,6 +107,7 @@ export default function App() {
runtime: meta.runtime,
capabilities: meta.capabilities,
authRequired: meta.auth_required,
version: meta.version,
});
// Single API call instead of two parallel calls to same endpoint
@@ -4,16 +4,11 @@
*/
import { useState, useCallback, useRef, useEffect } from "react";
import { useCancellableRequest, isAbortError, useDragDrop } from "@/hooks";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FileUpload } from "@/components/ui/file-upload";
import {
AttachmentGallery,
type AttachmentItem,
} from "@/components/ui/attachment-gallery";
import { ChatMessageInput } from "@/components/ui/chat-message-input";
import { OpenAIMessageRenderer } from "./message-renderers/OpenAIMessageRenderer";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
Select,
SelectContent,
@@ -23,21 +18,19 @@ import {
} from "@/components/ui/select";
import { AgentDetailsModal } from "./agent-details-modal";
import {
SendHorizontal,
User,
Bot,
Plus,
AlertCircle,
Paperclip,
Info,
Trash2,
FileText,
Check,
X,
Copy,
CheckCheck,
RefreshCw,
Wrench,
Square,
} from "lucide-react";
import { apiClient } from "@/services/api";
import type {
@@ -273,8 +266,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const isStreaming = useDevUIStore((state) => state.isStreaming);
const isSubmitting = useDevUIStore((state) => state.isSubmitting);
const loadingConversations = useDevUIStore((state) => state.loadingConversations);
const inputValue = useDevUIStore((state) => state.inputValue);
const attachments = useDevUIStore((state) => state.attachments);
const uiMode = useDevUIStore((state) => state.uiMode);
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
@@ -287,15 +278,10 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const setIsStreaming = useDevUIStore((state) => state.setIsStreaming);
const setIsSubmitting = useDevUIStore((state) => state.setIsSubmitting);
const setLoadingConversations = useDevUIStore((state) => state.setLoadingConversations);
const setInputValue = useDevUIStore((state) => state.setInputValue);
const setAttachments = useDevUIStore((state) => state.setAttachments);
const updateConversationUsage = useDevUIStore((state) => state.updateConversationUsage);
const setPendingApprovals = useDevUIStore((state) => state.setPendingApprovals);
// Local UI state (not in Zustand - component-specific)
const [isDragOver, setIsDragOver] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [conversationError, setConversationError] = useState<{
message: string;
@@ -303,10 +289,18 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
type?: string;
} | null>(null);
const [isReloading, setIsReloading] = useState(false);
const [wasCancelled, setWasCancelled] = useState(false);
// Use the cancellation hook
const { isCancelling, createAbortSignal, handleCancel, resetCancelling } = useCancellableRequest();
// Use the drag/drop hook for parent-level file dropping
const { isDragOver, droppedFiles, clearDroppedFiles, dragHandlers } = useDragDrop({
disabled: isSubmitting || isStreaming,
});
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const currentMessageUsage = useRef<{
total_tokens: number;
input_tokens: number;
@@ -350,10 +344,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
}, [chatItems, isStreaming]);
// Return focus to input after streaming completes
// Note: Focus handling is now managed by ChatMessageInput component
useEffect(() => {
if (!isStreaming && !isSubmitting) {
textareaRef.current?.focus();
}
// ChatMessageInput will handle its own focus
}, [isStreaming, isSubmitting]);
// Load conversations when agent changes
@@ -385,6 +378,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
agent.id,
openAIRequest,
conversation.id,
undefined, // No abort signal for resume
storedState.responseId // Pass response ID for resume
);
@@ -711,225 +705,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAgent, onDebugEvent, setChatItems, setIsStreaming, setLoadingConversations, setAvailableConversations, setCurrentConversation, setPendingApprovals, updateConversationUsage]);
// Handle file uploads
const handleFilesSelected = async (files: File[]) => {
const newAttachments: AttachmentItem[] = [];
for (const file of files) {
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const type = getFileType(file);
let preview: string | undefined;
if (type === "image") {
preview = await readFileAsDataURL(file);
}
newAttachments.push({
id,
file,
preview,
type,
});
}
setAttachments([...useDevUIStore.getState().attachments, ...newAttachments]);
};
const handleRemoveAttachment = (id: string) => {
setAttachments(useDevUIStore.getState().attachments.filter((att) => att.id !== id));
};
// Drag and drop handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragCounter((prev) => prev + 1);
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragOver(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const newCounter = dragCounter - 1;
setDragCounter(newCounter);
if (newCounter === 0) {
setIsDragOver(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
setDragCounter(0);
if (isSubmitting || isStreaming) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await handleFilesSelected(files);
}
};
// Paste handler
const handlePaste = async (e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const files: File[] = [];
let hasProcessedText = false;
const TEXT_THRESHOLD = 8000; // Convert to file if text is larger than this
for (const item of items) {
// Handle pasted images (screenshots)
if (item.type.startsWith("image/")) {
e.preventDefault();
const blob = item.getAsFile();
if (blob) {
const timestamp = Date.now();
files.push(
new File([blob], `screenshot-${timestamp}.png`, { type: blob.type })
);
}
}
// Handle text - only process first text item (browsers often duplicate)
else if (item.type === "text/plain" && !hasProcessedText) {
hasProcessedText = true;
// We need to check the text synchronously to decide whether to prevent default
// Unfortunately, getAsString is async, so we'll prevent default for all text
// and then decide whether to actually create a file or manually insert the text
e.preventDefault();
await new Promise<void>((resolve) => {
item.getAsString((text) => {
// Check if text should be converted to file
const lineCount = (text.match(/\n/g) || []).length;
const shouldConvert =
text.length > TEXT_THRESHOLD ||
lineCount > 50 || // Many lines suggests logs/data
/^\s*[{[][\s\S]*[}\]]\s*$/.test(text) || // JSON-like
/^<\?xml|^<html|^<!DOCTYPE/i.test(text); // XML/HTML
if (shouldConvert) {
// Create file for large/complex text
const extension = detectFileExtension(text);
const timestamp = Date.now();
const blob = new Blob([text], { type: "text/plain" });
files.push(
new File([blob], `pasted-text-${timestamp}${extension}`, {
type: "text/plain",
})
);
} else {
// For small text, manually insert into textarea since we prevented default
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const currentValue = textarea.value;
const newValue =
currentValue.slice(0, start) + text + currentValue.slice(end);
setInputValue(newValue);
// Restore cursor position after the inserted text
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd =
start + text.length;
textarea.focus();
}, 0);
}
}
resolve();
});
});
}
}
// Process collected files
if (files.length > 0) {
await handleFilesSelected(files);
// Show notification with appropriate icon
const message =
files.length === 1
? files[0].name.includes("screenshot")
? "Screenshot added as attachment"
: "Large text converted to file"
: `${files.length} files added`;
setPasteNotification(message);
setTimeout(() => setPasteNotification(null), 3000);
}
};
// Detect file extension from content
const detectFileExtension = (text: string): string => {
const trimmed = text.trim();
const lines = trimmed.split("\n");
// JSON detection
if (/^{[\s\S]*}$|^\[[\s\S]*\]$/.test(trimmed)) return ".json";
// XML/HTML detection
if (/^<\?xml|^<html|^<!DOCTYPE/i.test(trimmed)) return ".html";
// Markdown detection (code blocks)
if (/^```/.test(trimmed)) return ".md";
// TSV detection (tabs with multiple lines)
if (/\t/.test(text) && lines.length > 1) return ".tsv";
// CSV detection (more strict) - need multiple lines with consistent comma patterns
if (lines.length > 2) {
const commaLines = lines.filter((line) => line.includes(","));
const semicolonLines = lines.filter((line) => line.includes(";"));
// If >50% of lines have commas and it looks tabular
if (commaLines.length > lines.length * 0.5) {
const avgCommas =
commaLines.reduce(
(sum, line) => sum + (line.match(/,/g) || []).length,
0
) / commaLines.length;
if (avgCommas >= 2) return ".csv";
}
// If >50% of lines have semicolons and it looks tabular
if (semicolonLines.length > lines.length * 0.5) {
const avgSemicolons =
semicolonLines.reduce(
(sum, line) => sum + (line.match(/;/g) || []).length,
0
) / semicolonLines.length;
if (avgSemicolons >= 2) return ".csv";
}
}
return ".txt";
};
// Helper functions
const getFileType = (file: File): AttachmentItem["type"] => {
if (file.type.startsWith("image/")) return "image";
if (file.type === "application/pdf") return "pdf";
if (file.type.startsWith("audio/")) return "audio";
return "other";
};
const readFileAsDataURL = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
// Removed old input handling functions - now handled by ChatMessageInput component
// Handle new conversation creation
const handleNewConversation = useCallback(async () => {
@@ -1299,10 +1075,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Clear text accumulator for new response
accumulatedTextRef.current = "";
// Create new AbortController for this request
const signal = createAbortSignal();
// Use OpenAI-compatible API streaming - direct event handling
const streamGenerator = apiClient.streamAgentExecutionOpenAI(
selectedAgent.id,
apiRequest
apiRequest,
signal
);
for await (const openAIEvent of streamGenerator) {
@@ -1589,152 +1369,100 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Reset usage for next message
currentMessageUsage.current = null;
} catch (error) {
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
));
// Handle abort separately - don't show error message
if (isAbortError(error)) {
// User cancelled - mark as cancelled for UI feedback
setWasCancelled(true);
// Mark the message as completed with what we have
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
status: accumulatedTextRef.current ? "completed" as const : "incomplete" as const,
// Keep whatever text we have accumulated
content: item.content,
}
: item
));
} else {
// Other errors - show error message
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
));
}
setIsStreaming(false);
resetCancelling();
}
},
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage]
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage, createAbortSignal, resetCancelling]
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (
(!inputValue.trim() && attachments.length === 0) ||
isSubmitting ||
!selectedAgent
)
return;
// Handle message submission from ChatMessageInput
const handleChatInputSubmit = async (content: import("@/types/agent-framework").ResponseInputContent[]) => {
if (!selectedAgent || content.length === 0) return;
// Set flag to force scroll when user sends message
userJustSentMessage.current = true;
setWasCancelled(false); // Reset cancelled state for new message
setIsSubmitting(true);
const messageText = inputValue.trim();
setInputValue("");
try {
// Create OpenAI Responses API format
if (attachments.length > 0 || messageText) {
const content: import("@/types/agent-framework").ResponseInputContent[] =
[];
const openaiInput: import("@/types/agent-framework").ResponseInputParam = [
{
type: "message",
role: "user",
content,
},
];
// Add text content if present - EXACT OpenAI ResponseInputTextParam
if (messageText) {
content.push({
text: messageText,
type: "input_text",
} as import("@/types/agent-framework").ResponseInputTextParam);
}
// Add attachments using EXACT OpenAI types
for (const attachment of attachments) {
const dataUri = await readFileAsDataURL(attachment.file);
if (attachment.file.type.startsWith("image/")) {
// EXACT OpenAI ResponseInputImageParam
content.push({
detail: "auto",
type: "input_image",
image_url: dataUri,
} as import("@/types/agent-framework").ResponseInputImageParam);
} else if (
attachment.file.type === "text/plain" &&
(attachment.file.name.includes("pasted-text-") ||
attachment.file.name.endsWith(".txt") ||
attachment.file.name.endsWith(".csv") ||
attachment.file.name.endsWith(".json") ||
attachment.file.name.endsWith(".html") ||
attachment.file.name.endsWith(".md") ||
attachment.file.name.endsWith(".tsv"))
) {
// Convert all text files (from pasted large text) back to input_text
const text = await attachment.file.text();
content.push({
text: text,
type: "input_text",
} as import("@/types/agent-framework").ResponseInputTextParam);
} else {
// EXACT OpenAI ResponseInputFileParam for other files
const base64Data = dataUri.split(",")[1]; // Extract base64 part
content.push({
type: "input_file",
file_data: base64Data,
file_url: dataUri, // Use data URI as the URL
filename: attachment.file.name,
} as import("@/types/agent-framework").ResponseInputFileParam);
}
}
const openaiInput: import("@/types/agent-framework").ResponseInputParam =
[
{
type: "message",
role: "user",
content,
},
];
// Use pure OpenAI format
await handleSendMessage({
input: openaiInput,
conversation_id: currentConversation?.id,
});
} else {
// Simple text message using OpenAI format
const openaiInput: import("@/types/agent-framework").ResponseInputParam =
[
{
type: "message",
role: "user",
content: [
{
text: messageText,
type: "input_text",
} as import("@/types/agent-framework").ResponseInputTextParam,
],
},
];
await handleSendMessage({
input: openaiInput,
conversation_id: currentConversation?.id,
});
}
// Clear attachments after sending
setAttachments([]);
// Use pure OpenAI format
await handleSendMessage({
input: openaiInput,
conversation_id: currentConversation?.id,
});
} finally {
setIsSubmitting(false);
}
};
const canSendMessage =
selectedAgent &&
!isSubmitting &&
!isStreaming &&
(inputValue.trim() || attachments.length > 0);
// Old handleSubmit and canSendMessage removed - replaced by handleChatInputSubmit
return (
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
<div className="flex h-[calc(100vh-3.5rem)] flex-col relative" {...dragHandlers}>
{/* Full-area drop overlay */}
{isDragOver && (
<div className="absolute inset-0 z-50 bg-blue-50/95 dark:bg-blue-950/95 backdrop-blur-sm flex items-center justify-center border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg m-2">
<div className="text-center p-8">
<div className="text-blue-600 dark:text-blue-400 text-lg font-medium mb-2">
Drop files here
</div>
<div className="text-blue-500/80 dark:text-blue-400/70 text-sm">
Images, PDFs, audio, and other files
</div>
</div>
</div>
)}
{/* Header */}
<div className="border-b pb-2 p-4 flex-shrink-0">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3">
@@ -1927,29 +1655,70 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
) : (
(() => {
// Group tool calls and results with their assistant messages
// Bidirectional association:
// - Loading mode: tools come BEFORE assistant message (associate forward)
// - Streaming mode: tools come AFTER assistant message placeholder (associate backward)
const processedItems: React.ReactElement[] = [];
const toolCallsByMessage = new Map<string, import("@/types/openai").ConversationFunctionCall[]>();
const toolResultsByMessage = new Map<string, import("@/types/openai").ConversationFunctionCallOutput[]>();
// First pass: collect tool calls and results, associate with preceding assistant message
// Track the last assistant message for backward association (streaming)
let lastAssistantMessageId: string | null = null;
// Track orphaned tools for forward association (loading)
const orphanedToolCalls: import("@/types/openai").ConversationFunctionCall[] = [];
const orphanedToolResults: import("@/types/openai").ConversationFunctionCallOutput[] = [];
for (let i = 0; i < chatItems.length; i++) {
const item = chatItems[i];
for (const item of chatItems) {
if (item.type === "message" && item.role === "assistant") {
lastAssistantMessageId = item.id;
// Initialize arrays for this message if they don't exist
// Initialize arrays for this message
if (!toolCallsByMessage.has(item.id)) {
toolCallsByMessage.set(item.id, []);
toolResultsByMessage.set(item.id, []);
}
} else if (item.type === "function_call" && lastAssistantMessageId) {
const calls = toolCallsByMessage.get(lastAssistantMessageId) || [];
calls.push(item);
toolCallsByMessage.set(lastAssistantMessageId, calls);
} else if (item.type === "function_call_output" && lastAssistantMessageId) {
const results = toolResultsByMessage.get(lastAssistantMessageId) || [];
results.push(item);
toolResultsByMessage.set(lastAssistantMessageId, results);
// Forward association: if we have orphaned tools, associate with this message
if (orphanedToolCalls.length > 0) {
const calls = toolCallsByMessage.get(item.id) || [];
calls.push(...orphanedToolCalls);
toolCallsByMessage.set(item.id, calls);
orphanedToolCalls.length = 0;
}
if (orphanedToolResults.length > 0) {
const results = toolResultsByMessage.get(item.id) || [];
results.push(...orphanedToolResults);
toolResultsByMessage.set(item.id, results);
orphanedToolResults.length = 0;
}
// Track this as the last assistant message for backward association
lastAssistantMessageId = item.id;
} else if (item.type === "function_call") {
// Try backward association first (streaming mode)
if (lastAssistantMessageId) {
const calls = toolCallsByMessage.get(lastAssistantMessageId) || [];
calls.push(item);
toolCallsByMessage.set(lastAssistantMessageId, calls);
} else {
// No previous assistant message, store for forward association
orphanedToolCalls.push(item);
}
} else if (item.type === "function_call_output") {
// Try backward association first (streaming mode)
if (lastAssistantMessageId) {
const results = toolResultsByMessage.get(lastAssistantMessageId) || [];
results.push(item);
toolResultsByMessage.set(lastAssistantMessageId, results);
} else {
// No previous assistant message, store for forward association
orphanedToolResults.push(item);
}
} else if (item.type === "message" && item.role === "user") {
// User message resets the backward association context
// Tools after a user message belong to the next assistant response
lastAssistantMessageId = null;
}
}
@@ -1967,13 +1736,25 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
/>
);
}
// Tool calls and results are now rendered within messages, so skip them here
// Tool calls and results are rendered within messages, skip standalone
}
return processedItems;
})()
)}
{/* Response cancelled card */}
{wasCancelled && !isStreaming && (
<div className="px-4 py-2">
<div className="border rounded-lg border-orange-500/40 bg-orange-500/5 dark:bg-orange-500/10">
<div className="px-4 py-3 flex items-center gap-2">
<Square className="w-4 h-4 text-orange-500 dark:text-orange-400 fill-current" />
<span className="font-medium text-sm text-orange-700 dark:text-orange-300">Response stopped by user</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
@@ -2047,94 +1828,20 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
{/* Input */}
<div className="border-t flex-shrink-0">
<div
className={`p-4 relative transition-all duration-300 ease-in-out ${
isDragOver ? "bg-blue-50 dark:bg-blue-950/20" : ""
}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDragOver && (
<div className="absolute inset-2 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg bg-blue-50/80 dark:bg-blue-950/40 backdrop-blur-sm flex items-center justify-center transition-all duration-200 ease-in-out z-10">
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-sm font-medium mb-1">
Drop files here
</div>
<div className="text-blue-500 dark:text-blue-500 text-xs">
Images, PDFs, and other files
</div>
</div>
</div>
)}
{/* Attachment gallery */}
{attachments.length > 0 && (
<div className="mb-3">
<AttachmentGallery
attachments={attachments}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
)}
{/* Paste notification */}
{pasteNotification && (
<div
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20
bg-blue-500 text-white px-4 py-2 rounded-full text-sm
animate-in slide-in-from-bottom-2 fade-in duration-200
flex items-center gap-2 shadow-lg"
>
{pasteNotification.includes("screenshot") ? (
<Paperclip className="h-3 w-3" />
) : (
<FileText className="h-3 w-3" />
)}
{pasteNotification}
</div>
)}
{/* Input form */}
<form onSubmit={handleSubmit} className="flex gap-2 items-end">
<Textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
// Submit on Enter (without shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
placeholder={`Message ${
selectedAgent.name || selectedAgent.id
}... (Shift+Enter for new line)`}
disabled={isSubmitting || isStreaming}
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
style={{ fieldSizing: "content" } as React.CSSProperties}
/>
<FileUpload
onFilesSelected={handleFilesSelected}
disabled={isSubmitting || isStreaming}
/>
<Button
type="submit"
size="icon"
disabled={!canSendMessage}
className="shrink-0 h-10"
>
{isSubmitting ? (
<LoadingSpinner size="sm" />
) : (
<SendHorizontal className="h-4 w-4" />
)}
</Button>
</form>
<div className="p-4">
<ChatMessageInput
onSubmit={handleChatInputSubmit}
isSubmitting={isSubmitting}
isStreaming={isStreaming}
onCancel={handleCancel}
isCancelling={isCancelling}
placeholder={`Message ${selectedAgent.name || selectedAgent.id}... (Shift+Enter for new line)`}
showFileUpload={true}
entityName={selectedAgent.name || selectedAgent.id}
disabled={!selectedAgent}
externalFiles={droppedFiles}
onExternalFilesProcessed={clearDroppedFiles}
/>
</div>
</div>
@@ -0,0 +1,395 @@
/**
* CheckpointInfoModal - Timeline view of workflow checkpoints
*/
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Clock,
MessageSquare,
AlertCircle,
Loader2,
Package,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/services/api";
import type { CheckpointItem, WorkflowSession } from "@/types";
interface CheckpointInfoModalProps {
session: WorkflowSession | null;
checkpoints: CheckpointItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CheckpointInfoModal({
session,
checkpoints,
open,
onOpenChange,
}: CheckpointInfoModalProps) {
const [selectedCheckpointId, setSelectedCheckpointId] = useState<string | null>(null);
const [fullCheckpoint, setFullCheckpoint] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [jsonExpanded, setJsonExpanded] = useState(true);
// Select first checkpoint when modal opens or checkpoints change
useEffect(() => {
if (open && checkpoints.length > 0) {
// Only reset selection if current selection is invalid
const currentSelectionValid = checkpoints.some(
cp => cp.checkpoint_id === selectedCheckpointId
);
if (!currentSelectionValid) {
setSelectedCheckpointId(checkpoints[0].checkpoint_id);
}
}
}, [open, checkpoints]);
// Load full checkpoint details
useEffect(() => {
if (!selectedCheckpointId || !session) return;
const loadDetails = async () => {
// Don't clear the previous checkpoint to avoid UI flash
setLoading(true);
try {
const item = await apiClient.getConversationItem(
session.conversation_id,
`checkpoint_${selectedCheckpointId}`
);
setFullCheckpoint((item as CheckpointItem).metadata?.full_checkpoint);
} catch (error) {
console.error("Failed to load checkpoint:", error);
setFullCheckpoint(null);
} finally {
setLoading(false);
}
};
loadDetails();
}, [selectedCheckpointId, session]);
if (!session) return null;
const selectedCheckpoint = checkpoints.find(
(cp) => cp.checkpoint_id === selectedCheckpointId
);
const executorIds = fullCheckpoint?.shared_state?._executor_state
? Object.keys(fullCheckpoint.shared_state._executor_state)
: [];
const messageExecutors = fullCheckpoint?.messages
? Object.keys(fullCheckpoint.messages)
: [];
// Format checkpoint size for display
const formatSize = (bytes?: number): string => {
if (!bytes) return "";
const kb = bytes / 1024;
if (kb < 1) {
return `${bytes} B`;
} else if (kb < 1024) {
return `${kb.toFixed(1)} KB`;
} else {
return `${(kb / 1024).toFixed(1)} MB`;
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[90vw] max-w-6xl min-w-[800px] h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex-1">
<DialogTitle>{session.metadata.name}</DialogTitle>
<div className="text-sm text-muted-foreground mt-1">
{checkpoints.length} checkpoint{checkpoints.length !== 1 ? "s" : ""}
</div>
<div className="text-xs text-muted-foreground mt-2 max-w-2xl">
This is a read only view of the current checkpoint ids in the checkpoint storage for this workflow run.
</div>
</div>
<DialogClose onClose={() => onOpenChange(false)} />
</div>
</DialogHeader>
{/* Main Content - Timeline + Details */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Timeline Sidebar */}
<div className="w-80 border-r flex flex-col">
<ScrollArea className="flex-1">
<div className="p-4 space-y-2">
{checkpoints.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-8">
No checkpoints yet
</div>
) : (
checkpoints.map((checkpoint, index) => {
const isSelected = checkpoint.checkpoint_id === selectedCheckpointId;
const hasHil = checkpoint.metadata.has_pending_hil;
return (
<div key={checkpoint.checkpoint_id} className="relative">
<button
onClick={() => setSelectedCheckpointId(checkpoint.checkpoint_id)}
className={cn(
"relative w-full text-left p-3 rounded-lg border transition-colors",
isSelected
? "bg-primary/10 border-primary"
: "hover:bg-muted/50 border-transparent"
)}
>
<div className="flex items-start gap-3">
{/* Timeline Dot */}
<div className="flex flex-col items-center pt-1">
<div
className={cn(
"w-2 h-2 rounded-full z-10",
hasHil
? "bg-blue-500 ring-2 ring-blue-500/20"
: "bg-muted-foreground/30"
)}
/>
</div>
{/* Checkpoint Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{checkpoint.metadata.iteration_count === 0 ? "Initial State" : `Step ${checkpoint.metadata.iteration_count}`}
</span>
<span className="text-[10px] font-mono text-muted-foreground/70" title={checkpoint.checkpoint_id}>
{checkpoint.checkpoint_id.slice(0, 8)}
</span>
{index === 0 && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">
Latest
</Badge>
)}
{hasHil && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">
{checkpoint.metadata.pending_hil_count} HIL
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span>{new Date(checkpoint.timestamp).toLocaleTimeString()}</span>
{checkpoint.metadata.size_bytes && (
<>
<span></span>
<span>{formatSize(checkpoint.metadata.size_bytes)}</span>
</>
)}
</div>
</div>
</div>
</button>
{/* Connecting Line - positioned absolutely */}
{index < checkpoints.length - 1 && (
<div className="absolute left-[18px] top-[30px] w-px h-[calc(100%+8px)] bg-border" />
)}
</div>
);
})
)}
</div>
</ScrollArea>
</div>
{/* Details Panel */}
<div className="flex-1 flex flex-col overflow-hidden">
{!fullCheckpoint && !loading ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Select a checkpoint to view details
</div>
) : (
<ScrollArea className="flex-1">
<div className="p-6 space-y-6 relative">
{/* Loading overlay */}
{loading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center z-10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Header */}
<div className="flex items-start justify-between pb-4 border-b">
<div>
<div className="flex items-center gap-2 mb-1">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{selectedCheckpoint?.metadata.iteration_count === 0
? "Initial State"
: `Step ${selectedCheckpoint?.metadata.iteration_count}`}
</span>
{selectedCheckpoint?.metadata.size_bytes && (
<span className="text-xs text-muted-foreground">
{formatSize(selectedCheckpoint.metadata.size_bytes)}
</span>
)}
</div>
<div className="text-sm text-muted-foreground">
{selectedCheckpoint &&
new Date(selectedCheckpoint.timestamp).toLocaleString()}
</div>
{selectedCheckpoint && (
<div className="text-xs font-mono text-muted-foreground/70 mt-1">
ID: {selectedCheckpoint.checkpoint_id}
</div>
)}
</div>
{selectedCheckpoint?.metadata.has_pending_hil && (
<Badge variant="secondary">
{selectedCheckpoint.metadata.pending_hil_count} HIL Pending
</Badge>
)}
</div>
{/* Executors */}
{executorIds.length > 0 && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<Package className="h-4 w-4" />
Active Executors ({executorIds.length})
</div>
<div className="flex flex-wrap gap-2">
{executorIds.map((execId) => (
<Badge key={execId} variant="outline" className="font-mono text-xs">
{execId}
</Badge>
))}
</div>
</div>
)}
{/* Messages */}
{messageExecutors.length > 0 && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Messages
</div>
<div className="grid grid-cols-2 gap-3">
{messageExecutors.map((execId) => {
const count = (fullCheckpoint.messages[execId] as unknown[])?.length;
return (
<div key={execId} className="bg-muted/50 p-3 rounded-lg">
<div className="text-xs font-mono text-muted-foreground mb-1">
{execId}
</div>
<div className="font-medium">
{count} message{count !== 1 ? "s" : ""}
</div>
</div>
);
})}
</div>
</div>
)}
{/* HIL Requests */}
{fullCheckpoint?.pending_request_info_events &&
Object.keys(fullCheckpoint.pending_request_info_events).length > 0 && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
Pending HIL Requests (
{Object.keys(fullCheckpoint.pending_request_info_events).length})
</div>
<div className="space-y-2">
{Object.entries(fullCheckpoint.pending_request_info_events).map(
([reqId, reqData]: [string, any]) => (
<div
key={reqId}
className="bg-muted/50 border border-border p-3 rounded-lg"
>
<div className="flex items-center justify-between mb-2">
<code className="text-xs bg-background px-2 py-1 rounded">
{reqId.slice(0, 24)}...
</code>
<Badge variant="outline" className="text-xs">
{reqData.source_executor_id}
</Badge>
</div>
<div className="text-xs space-y-1">
<div>
<span className="text-muted-foreground">Request:</span>{" "}
<code className="bg-background px-1 py-0.5 rounded">
{reqData.request_type?.split(".").pop() || reqData.request_type}
</code>
</div>
<div>
<span className="text-muted-foreground">Response:</span>{" "}
<code className="bg-background px-1 py-0.5 rounded">
{reqData.response_type?.split(".").pop() || reqData.response_type}
</code>
</div>
</div>
</div>
)
)}
</div>
</div>
)}
{/* Shared State */}
<div>
<div className="text-sm font-medium mb-3">Shared State</div>
{fullCheckpoint?.shared_state && Object.keys(fullCheckpoint.shared_state).filter(
(k) => k !== "_executor_state"
).length > 0 ? (
<div className="flex flex-wrap gap-2">
{Object.keys(fullCheckpoint.shared_state)
.filter((k) => k !== "_executor_state")
.map((key) => (
<Badge key={key} variant="secondary" className="font-mono text-xs">
{key}
</Badge>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">No custom state</div>
)}
</div>
{/* Raw JSON (Collapsible) */}
<div className="border-t pt-6">
<button
onClick={() => setJsonExpanded(!jsonExpanded)}
className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full"
>
{jsonExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
Raw JSON
</button>
{jsonExpanded && (
<pre className="mt-3 text-[10px] font-mono bg-muted p-4 rounded overflow-x-auto">
{JSON.stringify(fullCheckpoint, null, 2)}
</pre>
)}
</div>
</div>
</ScrollArea>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -7,6 +7,8 @@ import { useState, useEffect, useMemo, useRef } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { HilTimelineItem } from "./hil-timeline-item";
import { RunWorkflowButton } from "./run-workflow-button";
import {
Loader2,
CheckCircle,
@@ -16,8 +18,9 @@ import {
ChevronRight,
Copy,
Check,
Square,
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
import type { ExtendedResponseStreamEvent, JSONSchemaProperty } from "@/types";
import type { ExecutorState } from "./executor-node";
import { truncateText } from "@/utils/workflow-utils";
@@ -40,12 +43,30 @@ interface ExecutionTimelineProps {
onExecutorClick?: (executorId: string) => void;
selectedExecutorId?: string | null;
workflowResult?: string;
// HIL support
pendingHilRequests?: Array<{
request_id: string;
request_data: Record<string, unknown>;
request_schema: import("@/types").JSONSchemaProperty;
}>;
hilResponses?: Record<string, Record<string, unknown>>;
onHilResponseChange?: (requestId: string, values: Record<string, unknown>) => void;
onHilSubmit?: () => void;
isSubmittingHil?: boolean;
// Workflow control props for bottom bar
inputSchema?: JSONSchemaProperty;
onRun?: (data: Record<string, unknown>, checkpointId?: string) => void;
onCancel?: () => void;
isCancelling?: boolean;
workflowState?: "ready" | "running" | "completed" | "error" | "cancelled";
wasCancelled?: boolean;
checkpoints?: import("@/types").CheckpointItem[];
}
function getStateIcon(state: ExecutorState) {
function getStateIcon(state: ExecutorState, isStreaming: boolean = true) {
switch (state) {
case "running":
return <Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin" />;
return <Loader2 className={`w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] ${isStreaming ? 'animate-spin' : ''}`} />;
case "completed":
return <CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />;
case "failed":
@@ -78,12 +99,14 @@ function ExecutorRunItem({
onToggle,
onClick,
isSelected,
isStreaming,
}: {
run: ExecutorRun;
isExpanded: boolean;
onToggle: () => void;
onClick: () => void;
isSelected: boolean;
isStreaming: boolean;
}) {
const timestamp = new Date(run.timestamp).toLocaleTimeString();
const hasOutput = run.output.trim().length > 0;
@@ -125,7 +148,7 @@ function ExecutorRunItem({
</>
)}
</div>
<div>{getStateIcon(run.state)}</div>
<div>{getStateIcon(run.state, isStreaming)}</div>
<span className="font-medium text-sm truncate overflow-hidden">
{run.executorName}
</span>
@@ -187,12 +210,26 @@ export function ExecutionTimeline({
onExecutorClick,
selectedExecutorId,
workflowResult,
pendingHilRequests = [],
hilResponses = {},
onHilResponseChange,
onHilSubmit,
isSubmittingHil = false,
// New props
inputSchema,
onRun,
onCancel,
isCancelling = false,
workflowState = "ready",
wasCancelled = false,
checkpoints = [],
}: ExecutionTimelineProps) {
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(new Set());
const [updateTrigger, setUpdateTrigger] = useState(0);
const [copied, setCopied] = useState(false);
const lastScrolledRunRef = useRef<string | null>(null);
const timelineEndRef = useRef<HTMLDivElement>(null);
const hilFormRef = useRef<HTMLDivElement>(null);
// Force re-render when streaming to show updated outputs from itemOutputs ref
// Note: itemOutputs is a ref (not state), so changes don't trigger re-renders automatically.
@@ -413,6 +450,39 @@ export function ExecutionTimeline({
}
}, [workflowResult, isStreaming]);
// Auto-scroll when streaming ends to show final executor state
// (Ensures last executor's completion is visible, even if no workflow result)
useEffect(() => {
// Only scroll when streaming ends AND no HIL requests are pending
// (HIL has its own scroll handler below)
if (!isStreaming && executorRuns.length > 0 && pendingHilRequests.length === 0) {
setTimeout(() => {
timelineEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}, 100);
}
}, [isStreaming, pendingHilRequests.length, executorRuns.length]);
// Auto-scroll to HIL form when it appears
useEffect(() => {
if (pendingHilRequests.length > 0 && hilFormRef.current) {
// Small delay to ensure the form is rendered
setTimeout(() => {
hilFormRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
// Add highlight animation
hilFormRef.current?.classList.add('highlight-attention');
setTimeout(() => {
hilFormRef.current?.classList.remove('highlight-attention');
}, 1000);
}, 100);
}
}, [pendingHilRequests.length]);
const handleCopyAll = () => {
const text = executorRuns
.map((run) => {
@@ -471,35 +541,54 @@ export function ExecutionTimeline({
<div className="p-3 space-y-2">
{executorRuns.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
No executor runs yet. Start the workflow to see execution timeline.
{isStreaming ? "Workflow is running..." : "Ready to run workflow"}
</div>
) : (
executorRuns.map((run, index) => {
const runKey = `${run.executorId}-${run.runNumber}`;
return (
<ExecutorRunItem
key={`${runKey}-${index}`}
run={run}
isExpanded={expandedRuns.has(runKey)}
onToggle={() => {
setExpandedRuns((prev) => {
const next = new Set(prev);
if (next.has(runKey)) {
next.delete(runKey);
} else {
next.add(runKey);
}
return next;
});
}}
onClick={() => onExecutorClick?.(run.executorId)}
isSelected={selectedExecutorId === run.executorId}
/>
);
})
<>
{executorRuns.map((run, index) => {
const runKey = `${run.executorId}-${run.runNumber}`;
return (
<ExecutorRunItem
key={`${runKey}-${index}`}
run={run}
isExpanded={expandedRuns.has(runKey)}
onToggle={() => {
setExpandedRuns((prev) => {
const next = new Set(prev);
if (next.has(runKey)) {
next.delete(runKey);
} else {
next.add(runKey);
}
return next;
});
}}
onClick={() => onExecutorClick?.(run.executorId)}
isSelected={selectedExecutorId === run.executorId}
isStreaming={isStreaming}
/>
);
})}
{/* HIL Request Items */}
{pendingHilRequests.length > 0 && (
<div ref={hilFormRef} data-hil-form className="transition-all duration-300">
{pendingHilRequests.map((request) => (
<HilTimelineItem
key={request.request_id}
request={request}
response={hilResponses[request.request_id] || {}}
onResponseChange={(values) => onHilResponseChange?.(request.request_id, values)}
onSubmit={() => onHilSubmit?.()}
isSubmitting={isSubmittingHil}
/>
))}
</div>
)}
</>
)}
{/* Workflow final output card */}
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && (
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && !wasCancelled && (
<div className="border rounded-lg border-green-500/40 bg-green-500/5 dark:bg-green-500/10">
<div className="p-3 bg-green-500/10 border-b border-green-500/20">
<div className="flex items-center gap-2 mb-1">
@@ -519,10 +608,36 @@ export function ExecutionTimeline({
</div>
</div>
)}
{/* Workflow cancelled card */}
{wasCancelled && !isStreaming && (
<div className="border rounded-lg border-orange-500/40 bg-orange-500/5 dark:bg-orange-500/10">
<div className="px-4 py-3 flex items-center gap-2">
<Square className="w-4 h-4 text-orange-500 dark:text-orange-400 fill-current" />
<span className="font-medium text-sm text-orange-700 dark:text-orange-300">Execution stopped by user</span>
</div>
</div>
)}
{/* Invisible element at the end for scroll target */}
<div ref={timelineEndRef} />
</div>
</ScrollArea>
{/* Bottom Control Bar - Sticky (hidden when HIL is active) */}
{(onRun || onCancel) && pendingHilRequests.length === 0 && (
<div className="border-t p-3 bg-background flex-shrink-0">
<RunWorkflowButton
inputSchema={inputSchema}
onRun={onRun || (() => {})}
onCancel={onCancel}
isSubmitting={workflowState === "running"}
isCancelling={isCancelling}
workflowState={workflowState}
checkpoints={checkpoints}
showCheckpoints={false}
/>
</div>
)}
</div>
);
}
@@ -28,6 +28,7 @@ export interface ExecutorNodeData extends Record<string, unknown> {
isEndNode?: boolean;
layoutDirection?: "LR" | "TB";
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
isStreaming?: boolean;
}
const getExecutorStateConfig = (state: ExecutorState) => {
@@ -72,6 +73,7 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const hasData = nodeData.inputData || nodeData.outputData || nodeData.error;
const isRunning = nodeData.state === "running";
const shouldAnimate = isRunning && (nodeData.isStreaming ?? true); // Default to true for backwards compatibility
// Determine handle positions based on layout direction
const isVertical = nodeData.layoutDirection === "TB";
@@ -205,7 +207,7 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
{nodeData.name || nodeData.executorId}
</h3>
{isRunning && (
<Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin flex-shrink-0" />
<Loader2 className={`w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] ${shouldAnimate ? 'animate-spin' : ''} flex-shrink-0`} />
)}
</div>
{nodeData.executorType && (
@@ -1,150 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MessageCircle, Send, Loader2 } from "lucide-react";
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
import type { JSONSchemaProperty } from "@/types";
interface HilRequest {
request_id: string;
request_data: Record<string, unknown>;
request_schema: JSONSchemaProperty;
}
interface HilInputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
requests: HilRequest[];
responses: Record<string, Record<string, unknown>>;
onResponseChange: (requestId: string, values: Record<string, unknown>) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export function HilInputModal({
open,
onOpenChange,
requests,
responses,
onResponseChange,
onSubmit,
isSubmitting,
}: HilInputModalProps) {
// Check if all required fields are filled
const areAllRequiredFieldsFilled = () => {
return requests.every((req) => {
const response = responses[req.request_id] || {};
return validateSchemaForm(req.request_schema, response);
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<MessageCircle className="w-5 h-5" />
Workflow Requires Input ({requests.length} request
{requests.length > 1 ? "s" : ""})
</DialogTitle>
<DialogDescription>
The workflow is paused and needs your input to continue.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{requests.map((req, index) => (
<Card key={req.request_id}>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
Request {index + 1}
<Badge variant="outline" className="ml-2 font-mono text-xs">
{req.request_id.slice(0, 8)}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{/* Show request data as readonly context */}
{Object.keys(req.request_data).length > 0 && (
<div className="mb-4 p-3 bg-muted rounded-md max-h-48 overflow-y-auto">
<p className="text-xs font-medium text-muted-foreground mb-2">
Request Context:
</p>
<div className="space-y-1">
{Object.entries(req.request_data)
.filter(([key]) => !["request_id", "source_executor_id"].includes(key))
.map(([key, value]) => (
<div key={key} className="text-xs">
<span className="font-medium">{key}:</span>{" "}
<span className="text-muted-foreground break-all">
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</span>
</div>
))}
</div>
</div>
)}
{/* Show expected response hint if available */}
{req.request_schema?.description && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-md">
<p className="text-xs font-medium text-blue-900 dark:text-blue-100 mb-1">
Expected Response:
</p>
<p className="text-xs text-blue-700 dark:text-blue-300">
{req.request_schema.description}
</p>
</div>
)}
{/* Use schema-based form renderer for RESPONSE (not request) */}
<SchemaFormRenderer
schema={req.request_schema}
values={responses[req.request_id] || {}}
onChange={(values) => onResponseChange(req.request_id, values)}
disabled={isSubmitting}
/>
</CardContent>
</Card>
))}
</div>
<DialogFooter>
<div className="flex gap-2 w-full justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={onSubmit}
disabled={isSubmitting || !areAllRequiredFieldsFilled()}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Submitting...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Submit & Continue
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,153 @@
/**
* HilTimelineItem - Inline HIL request form for the ExecutionTimeline
* Shows HIL requests as part of the workflow execution flow
*/
import { useState } from "react";
import { Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
import type { JSONSchemaProperty } from "@/types";
export interface HilRequest {
request_id: string;
request_data: Record<string, unknown>;
request_schema: JSONSchemaProperty;
}
interface HilTimelineItemProps {
request: HilRequest;
response: Record<string, unknown>;
onResponseChange: (values: Record<string, unknown>) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export function HilTimelineItem({
request,
response,
onResponseChange,
onSubmit,
isSubmitting,
}: HilTimelineItemProps) {
const [isExpanded, setIsExpanded] = useState(true);
const handleResponseChange = (values: Record<string, unknown>) => {
onResponseChange(values);
};
const isValid = validateSchemaForm(request.request_schema, response);
return (
<div className="relative group">
{/* Main content - removed icon and adjusted layout */}
<div>
{/* Content area - removed pb-4 padding */}
<div className="flex-1">
<div className="border border-orange-200 dark:border-orange-800 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden rounded-lg">
{/* Header */}
<div
className="px-4 py-3 bg-orange-100/50 dark:bg-orange-950/30 border-b border-orange-200 dark:border-orange-800 flex items-center justify-between cursor-pointer hover:bg-orange-100 dark:hover:bg-orange-950/40 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-orange-900 dark:text-orange-100">
Workflow needs your input
</span>
<Badge
variant="outline"
className="text-xs font-mono border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300"
>
{request.request_id.slice(0, 8)}
</Badge>
{!isExpanded && (
<span className="text-xs text-orange-600 dark:text-orange-400 animate-pulse">
Click to respond
</span>
)}
</div>
{isSubmitting && (
<Badge variant="secondary" className="animate-pulse">
Submitting...
</Badge>
)}
</div>
{/* Expanded content */}
{isExpanded && (
<div className="p-4 space-y-4">
{/* Request context - scrollable */}
{Object.keys(request.request_data).length > 0 && (
<div className="bg-white/60 dark:bg-gray-900/30 rounded-md p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Context
</p>
<div className="max-h-48 overflow-y-auto space-y-1 pr-2">
{Object.entries(request.request_data)
.filter(
([key]) =>
!["request_id", "source_executor_id"].includes(key)
)
.map(([key, value]) => (
<div key={key} className="text-sm">
<span className="font-medium text-muted-foreground">
{key}:
</span>{" "}
<span className="text-foreground break-all">
{typeof value === "object"
? JSON.stringify(value, null, 2)
: String(value)}
</span>
</div>
))}
</div>
</div>
)}
{/* Description hint */}
{request.request_schema?.description && (
<div className="text-sm text-muted-foreground bg-blue-50 dark:bg-blue-950/20 p-3 rounded-md border border-blue-200 dark:border-blue-800">
<p className="font-medium text-blue-900 dark:text-blue-100 mb-1">
What's needed:
</p>
<p className="text-blue-800 dark:text-blue-200">
{request.request_schema.description}
</p>
</div>
)}
{/* Input form */}
<div className="space-y-3">
<SchemaFormRenderer
schema={request.request_schema}
values={response}
onChange={handleResponseChange}
/>
</div>
{/* Actions */}
<div className="space-y-2 pt-2">
<Button
size="default"
onClick={onSubmit}
disabled={!isValid || isSubmitting}
className="w-full gap-2"
>
<Send className="w-4 h-4" />
Submit Response
</Button>
{!isValid && (
<div className="text-xs text-muted-foreground text-center">
Please fill in all required fields
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -8,4 +8,6 @@ export { WorkflowFlow } from "./workflow-flow";
export { WorkflowInputForm } from "./workflow-input-form";
export { ExecutorNode } from "./executor-node";
export { SchemaFormRenderer, validateSchemaForm, filterEmptyOptionalFields } from "./schema-form-renderer";
export { HilInputModal } from "./hil-input-modal";
export { CheckpointInfoModal } from "./checkpoint-info-modal";
export { RunWorkflowButton } from "./run-workflow-button";
export type { RunWorkflowButtonProps } from "./run-workflow-button";
@@ -0,0 +1,427 @@
/**
* RunWorkflowButton - Shared component for running workflows with checkpoint support
* Features: Split button with dropdown for checkpoint selection, input validation, modal dialog
*/
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { WorkflowInputForm } from "./workflow-input-form";
import { ChatMessageInput } from "@/components/ui/chat-message-input";
import { isChatMessageSchema } from "@/utils/workflow-utils";
import {
ChevronDown,
Clock,
Loader2,
Play,
RotateCcw,
Settings,
Square,
RefreshCw,
} from "lucide-react";
import type { JSONSchemaProperty, CheckpointItem } from "@/types";
import type { ResponseInputContent } from "@/types/agent-framework";
export interface RunWorkflowButtonProps {
inputSchema?: JSONSchemaProperty;
onRun: (data: Record<string, unknown>, checkpointId?: string) => void;
onCancel?: () => void;
isSubmitting: boolean;
isCancelling?: boolean;
workflowState: "ready" | "running" | "completed" | "error" | "cancelled";
checkpoints?: CheckpointItem[];
// Optional prop to control whether to show checkpoints dropdown
showCheckpoints?: boolean;
}
export function RunWorkflowButton({
inputSchema,
onRun,
onCancel,
isSubmitting,
isCancelling = false,
workflowState,
checkpoints = [],
showCheckpoints = true,
}: RunWorkflowButtonProps) {
const [showModal, setShowModal] = useState(false);
// Handle escape key to close modal
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && showModal) {
setShowModal(false);
}
};
if (showModal) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [showModal]);
// Analyze input requirements
const inputAnalysis = useMemo(() => {
// Check if this is a ChatMessage schema (for AgentExecutor workflows)
const isChatMessage = isChatMessageSchema(inputSchema);
if (!inputSchema)
return {
needsInput: false,
hasDefaults: false,
fieldCount: 0,
canRunDirectly: true,
isChatMessage: false,
};
if (inputSchema.type === "string") {
return {
needsInput: !inputSchema.default,
hasDefaults: !!inputSchema.default,
fieldCount: 1,
canRunDirectly: !!inputSchema.default,
isChatMessage: false,
};
}
if (inputSchema.type === "object" && inputSchema.properties) {
const properties = inputSchema.properties;
const fields = Object.entries(properties);
const fieldsWithDefaults = fields.filter(
([, schema]: [string, JSONSchemaProperty]) =>
schema.default !== undefined ||
(schema.enum && schema.enum.length > 0)
);
return {
needsInput: fields.length > 0,
hasDefaults: fieldsWithDefaults.length > 0,
fieldCount: fields.length,
canRunDirectly:
fields.length === 0 || fieldsWithDefaults.length === fields.length,
isChatMessage,
};
}
return {
needsInput: false,
hasDefaults: false,
fieldCount: 0,
canRunDirectly: true,
isChatMessage: false,
};
}, [inputSchema]);
const handleDirectRun = () => {
if (workflowState === "running" && onCancel) {
onCancel();
} else if (inputAnalysis.canRunDirectly) {
// Build default data
const defaultData: Record<string, unknown> = {};
if (inputSchema?.type === "string" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "object" && inputSchema.properties) {
Object.entries(inputSchema.properties).forEach(
([key, schema]: [string, JSONSchemaProperty]) => {
if (schema.default !== undefined) {
defaultData[key] = schema.default;
} else if (schema.enum && schema.enum.length > 0) {
defaultData[key] = schema.enum[0];
}
}
);
}
onRun(defaultData);
} else {
setShowModal(true);
}
};
const handleRunFromCheckpoint = (checkpointId: string) => {
if (inputAnalysis.canRunDirectly) {
// Build default data
const defaultData: Record<string, unknown> = {};
if (inputSchema?.type === "string" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "object" && inputSchema.properties) {
Object.entries(inputSchema.properties).forEach(
([key, schema]: [string, JSONSchemaProperty]) => {
if (schema.default !== undefined) {
defaultData[key] = schema.default;
} else if (schema.enum && schema.enum.length > 0) {
defaultData[key] = schema.enum[0];
}
}
);
}
onRun(defaultData, checkpointId);
} else {
// TODO: Pass checkpoint ID to modal for custom inputs
setShowModal(true);
}
};
const hasCheckpoints = showCheckpoints && checkpoints.length > 0;
// Format checkpoint size for display
const formatSize = (bytes?: number): string => {
if (!bytes) return "";
const kb = bytes / 1024;
if (kb < 1) {
return `${bytes} B`;
} else if (kb < 1024) {
return `${kb.toFixed(1)} KB`;
} else {
return `${(kb / 1024).toFixed(1)} MB`;
}
};
// Build the button content based on state
const getButtonContent = () => {
const icon = isCancelling ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : workflowState === "running" && onCancel ? (
<Square className="w-4 h-4 fill-current" />
) : workflowState === "running" ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : workflowState === "error" ? (
<RotateCcw className="w-4 h-4" />
) : inputAnalysis.needsInput && !inputAnalysis.canRunDirectly ? (
<Settings className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
);
const text = isCancelling
? "Stopping..."
: workflowState === "running" && onCancel
? "Stop"
: workflowState === "running"
? "Running..."
: workflowState === "completed"
? "Run Again"
: workflowState === "error"
? "Retry"
: inputAnalysis.fieldCount === 0
? "Run Workflow"
: inputAnalysis.canRunDirectly
? "Run Workflow"
: "Configure & Run";
return { icon, text };
};
const { icon, text } = getButtonContent();
const isDisabled = (workflowState === "running" && !onCancel) || isCancelling;
const buttonVariant = workflowState === "error" ? "destructive" : "default";
// Unified layout for both variants
const renderButton = () => {
// Always show split button if there are checkpoints OR if inputs need configuration
const showDropdown = hasCheckpoints || inputAnalysis.needsInput;
if (!showDropdown) {
// Simple button - no dropdown needed
return (
<Button
onClick={handleDirectRun}
disabled={isDisabled}
variant={buttonVariant}
className="gap-2 w-full"
title={
workflowState === "running" && onCancel
? "Stop workflow execution"
: undefined
}
>
{icon}
{text}
</Button>
);
}
// Split button with dropdown
return (
<DropdownMenu>
<div className="flex w-full">
<Button
onClick={handleDirectRun}
disabled={isDisabled}
variant={buttonVariant}
className="gap-2 rounded-r-none flex-1"
title={
workflowState === "running" && onCancel
? "Stop workflow execution"
: undefined
}
>
{icon}
{text}
</Button>
<DropdownMenuTrigger asChild>
<Button
disabled={isDisabled}
variant={buttonVariant}
className="rounded-l-none border-l-0 px-2"
title="More options"
>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="end"
className="w-80 max-h-[400px] overflow-y-auto"
>
{/* Run Fresh option - only show when checkpoints are enabled */}
{hasCheckpoints && (
<DropdownMenuItem onClick={handleDirectRun}>
<Play className="w-4 h-4 mr-2" />
Run Fresh
</DropdownMenuItem>
)}
{/* Configure inputs option */}
{inputAnalysis.needsInput && (
<DropdownMenuItem onClick={() => setShowModal(true)}>
<Settings className="w-4 h-4 mr-2" />
Configure Inputs
</DropdownMenuItem>
)}
{/* Checkpoint options */}
{hasCheckpoints && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs text-muted-foreground">
Resume from checkpoint
</div>
{checkpoints.map((checkpoint, index) => (
<DropdownMenuItem
key={checkpoint.checkpoint_id}
onClick={() =>
handleRunFromCheckpoint(checkpoint.checkpoint_id)
}
className="flex flex-col items-start py-2"
>
<div className="flex items-center gap-2 w-full">
<RefreshCw className="w-4 h-4 flex-shrink-0" />
<span className="font-medium">
{checkpoint.metadata.iteration_count === 0
? "Initial State"
: `Step ${checkpoint.metadata.iteration_count}`}
</span>
{index === 0 && (
<Badge
variant="secondary"
className="text-[10px] h-4 px-1 ml-auto"
>
Latest
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-6 mt-0.5">
<Clock className="w-3 h-3" />
<span>
{new Date(checkpoint.timestamp).toLocaleTimeString()}
</span>
{checkpoint.metadata.size_bytes && (
<>
<span></span>
<span>
{formatSize(checkpoint.metadata.size_bytes)}
</span>
</>
)}
</div>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
return (
<>
{renderButton()}
{/* Modal for input configuration */}
{inputSchema && (
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="w-full min-w-[400px] max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-8 pt-6">
<DialogTitle>Configure Workflow Inputs</DialogTitle>
<DialogClose onClose={() => setShowModal(false)} />
</DialogHeader>
<div className="px-8 py-4 border-b flex-shrink-0">
<div className="text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span className="font-medium">Input Type:</span>
<Badge variant="secondary">
{inputAnalysis.isChatMessage
? "Chat Message"
: inputSchema.type === "string"
? "Simple Text"
: "Structured Data"}
</Badge>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto py-4 px-8">
{inputAnalysis.isChatMessage ? (
<ChatMessageInput
onSubmit={async (content: ResponseInputContent[]) => {
// Wrap in OpenAI message format (same structure as agent-view)
// This preserves multimodal content (images, files) for the backend
const openaiInput = [
{ type: "message", role: "user", content },
];
onRun(openaiInput as unknown as Record<string, unknown>);
setShowModal(false);
}}
isSubmitting={isSubmitting}
placeholder="Enter your message..."
entityName="workflow"
showFileUpload={true}
/>
) : (
<WorkflowInputForm
inputSchema={inputSchema}
inputTypeName="Input"
onSubmit={(values) => {
onRun(values as Record<string, unknown>);
setShowModal(false);
}}
isSubmitting={isSubmitting}
className="embedded"
/>
)}
</div>
</DialogContent>
</Dialog>
)}
</>
);
}
@@ -429,7 +429,7 @@ export function SchemaFormRenderer({
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
<div className="space-y-3">
{/* Required fields section */}
{requiredFieldNames.map((fieldName) => (
<FormField
@@ -445,7 +445,7 @@ export function SchemaFormRenderer({
{/* Separator between required and optional */}
{hasRequiredFields && optionalFieldNames.length > 0 && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<div>
<div className="border-t border-border"></div>
</div>
)}
@@ -0,0 +1,56 @@
import { memo, type CSSProperties } from "react";
import { BaseEdge, useInternalNode } from "@xyflow/react";
interface SelfLoopEdgeProps {
id: string;
source: string;
markerEnd?: string;
style?: CSSProperties;
}
/**
* Custom edge for self-referencing nodes. Renders a bezier loop from output back to input.
*/
export const SelfLoopEdge = memo(function SelfLoopEdge({
id,
source,
markerEnd,
style,
}: SelfLoopEdgeProps) {
const sourceNode = useInternalNode(source);
if (!sourceNode) return null;
const { width, height } = sourceNode.measured;
const { x, y } = sourceNode.internals.positionAbsolute;
if (!width || !height) return null;
const nodeData = sourceNode.data as Record<string, unknown> | undefined;
const isVertical = nodeData?.layoutDirection === "TB";
const loopOffset = 100;
const riseOffset = 40;
let edgePath: string;
if (isVertical) {
// TB: bottom center → curves right → top center
const startX = x + width / 2;
const startY = y + height;
const endX = x + width / 2;
const endY = y;
const cpX = x + width + loopOffset;
edgePath = `M ${startX} ${startY} C ${startX} ${startY + riseOffset}, ${cpX} ${startY + riseOffset}, ${cpX} ${y + height / 2} C ${cpX} ${endY - riseOffset}, ${endX} ${endY - riseOffset}, ${endX} ${endY}`;
} else {
// LR: right center → curves down → left center
const startX = x + width;
const startY = y + height / 2;
const endX = x;
const endY = y + height / 2;
const cpY = y + height + loopOffset;
edgePath = `M ${startX} ${startY} C ${startX + riseOffset} ${startY}, ${startX + riseOffset} ${cpY}, ${x + width / 2} ${cpY} C ${endX - riseOffset} ${cpY}, ${endX - riseOffset} ${endY}, ${endX} ${endY}`;
}
return <BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />;
});
@@ -33,6 +33,7 @@ import {
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { ExecutorNode, type ExecutorNodeData } from "./executor-node";
import { SelfLoopEdge } from "./self-loop-edge";
import {
convertWorkflowDumpToNodes,
convertWorkflowDumpToEdges,
@@ -50,6 +51,10 @@ const nodeTypes: NodeTypes = {
executor: ExecutorNode,
};
const edgeTypes = {
selfLoop: SelfLoopEdge,
};
// ViewOptions panel component that renders inside ReactFlow
function ViewOptionsPanel({
workflowDump,
@@ -61,7 +66,12 @@ function ViewOptionsPanel({
}: {
workflowDump?: Workflow;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean; consolidateBidirectionalEdges: boolean };
viewOptions: {
showMinimap: boolean;
showGrid: boolean;
animateRun: boolean;
consolidateBidirectionalEdges: boolean;
};
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
layoutDirection: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
@@ -138,13 +148,18 @@ function ViewOptionsPanel({
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("consolidateBidirectionalEdges")}
onClick={() =>
onToggleViewOption?.("consolidateBidirectionalEdges")
}
>
<div className="flex items-center">
<ArrowLeftRight className="mr-2 h-4 w-4" />
Merge Bidirectional Edges
</div>
<Checkbox checked={viewOptions.consolidateBidirectionalEdges} onChange={() => {}} />
<Checkbox
checked={viewOptions.consolidateBidirectionalEdges}
onChange={() => {}}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -263,22 +278,24 @@ function WorkflowAnimationHandler({
}
// Timeline resize handler component that runs inside ReactFlow context
const TimelineResizeHandler = memo(({ timelineVisible }: { timelineVisible: boolean }) => {
const { fitView } = useReactFlow();
const TimelineResizeHandler = memo(
({ timelineVisible }: { timelineVisible: boolean }) => {
const { fitView } = useReactFlow();
// Trigger fitView when timeline visibility changes to adjust ReactFlow viewport
useEffect(() => {
// Delay fitView to let CSS transition complete (timeline animation is 300ms)
const timeoutId = setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
}, 350); // Slightly longer than timeline animation duration
// Trigger fitView when timeline visibility changes to adjust ReactFlow viewport
useEffect(() => {
// Delay fitView to let CSS transition complete (timeline animation is 300ms)
const timeoutId = setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
}, 350); // Slightly longer than timeline animation duration
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timelineVisible]); // Only trigger when timelineVisible changes, not fitView reference
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timelineVisible]); // Only trigger when timelineVisible changes, not fitView reference
return null; // This component doesn't render anything
});
return null; // This component doesn't render anything
}
);
export const WorkflowFlow = memo(function WorkflowFlow({
workflowDump,
@@ -286,9 +303,14 @@ export const WorkflowFlow = memo(function WorkflowFlow({
isStreaming,
onNodeSelect,
className = "",
viewOptions = { showMinimap: false, showGrid: true, animateRun: true, consolidateBidirectionalEdges: true },
viewOptions = {
showMinimap: false,
showGrid: true,
animateRun: true,
consolidateBidirectionalEdges: true,
},
onToggleViewOption,
layoutDirection = "LR",
layoutDirection = "TB",
onLayoutDirectionChange,
timelineVisible = false,
}: WorkflowFlowProps) {
@@ -320,7 +342,12 @@ export const WorkflowFlow = memo(function WorkflowFlow({
initialNodes: layoutedNodes,
initialEdges: finalEdges,
};
}, [workflowDump, onNodeSelect, layoutDirection, viewOptions.consolidateBidirectionalEdges]);
}, [
workflowDump,
onNodeSelect,
layoutDirection,
viewOptions.consolidateBidirectionalEdges,
]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<ExecutorNodeData>>(initialNodes);
@@ -335,7 +362,7 @@ export const WorkflowFlow = memo(function WorkflowFlow({
useMemo(() => {
if (Object.keys(nodeUpdates).length > 0) {
setNodes((currentNodes) =>
updateNodesWithEvents(currentNodes, nodeUpdates)
updateNodesWithEvents(currentNodes, nodeUpdates, isStreaming)
);
} else if (events.length === 0) {
// Reset all nodes to pending state when events are cleared
@@ -347,11 +374,12 @@ export const WorkflowFlow = memo(function WorkflowFlow({
state: "pending" as const,
outputData: undefined,
error: undefined,
isStreaming: false,
},
}))
);
}
}, [nodeUpdates, setNodes, events.length]);
}, [nodeUpdates, setNodes, events.length, isStreaming]);
// Update edges with sequence-based analysis (separate from nodeUpdates)
useMemo(() => {
@@ -445,6 +473,7 @@ export const WorkflowFlow = memo(function WorkflowFlow({
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
@@ -34,29 +34,47 @@ interface FormFieldProps {
// Helper: Determine if field is likely a short metadata field (not multiline text)
function isShortField(fieldName: string): boolean {
const shortFieldNames = ['name', 'title', 'id', 'key', 'label', 'type', 'status', 'tag', 'category', 'code', 'username', 'password'];
const shortFieldNames = [
"name",
"title",
"id",
"key",
"label",
"type",
"status",
"tag",
"category",
"code",
"username",
"password",
];
return shortFieldNames.includes(fieldName.toLowerCase());
}
function FormField({ name, schema, value, onChange, isRequired = false }: FormFieldProps) {
function FormField({
name,
schema,
value,
onChange,
isRequired = false,
}: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
// Determine if this should be a textarea based on JSON Schema format field
// or heuristics (long descriptions, specific field types)
const shouldBeTextarea =
schema.format === "textarea" || // Explicit format from backend
(description && description.length > 100) || // Long description suggests multiline
(type === "string" && !enumValues && !isShortField(name)); // Default strings to textarea unless they're short metadata fields
schema.format === "textarea" || // Explicit format from backend
(description && description.length > 100) || // Long description suggests multiline
(type === "string" && !enumValues && !isShortField(name)); // Default strings to textarea unless they're short metadata fields
// Determine if this field should span full width
const shouldSpanFullWidth =
shouldBeTextarea ||
(description && description.length > 150);
shouldBeTextarea || (description && description.length > 150);
const shouldSpanTwoColumns =
shouldBeTextarea ||
(description && description.length > 80) ||
type === "array"; // Arrays might need more space for comma-separated values
type === "array"; // Arrays might need more space for comma-separated values
const fieldContent = (() => {
// Handle different field types based on JSON Schema
@@ -296,7 +314,7 @@ export function WorkflowInputForm({
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
// Check if we're in embedded mode (being used inside another modal)
const isEmbedded = className?.includes('embedded');
const isEmbedded = className?.includes("embedded");
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(false);
@@ -307,17 +325,22 @@ export function WorkflowInputForm({
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
// Plan D: Separate required and optional fields first
const allOptionalFieldNames = fieldNames.filter(name => !requiredFields.includes(name));
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';
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 requiredFieldNames = fieldNames.filter(
(name) =>
requiredFields.includes(name) && !(isChatMessageLike && name === "role")
);
const optionalFieldNames = allOptionalFieldNames;
@@ -325,7 +348,7 @@ export function WorkflowInputForm({
const sortedOptionalFields = isChatMessageLike
? [...optionalFieldNames].sort((a, b) => {
const priority = (name: string) =>
['text', 'message', 'content'].includes(name) ? 1 : 0;
["text", "message", "content"].includes(name) ? 1 : 0;
return priority(b) - priority(a);
})
: optionalFieldNames;
@@ -333,9 +356,16 @@ export function WorkflowInputForm({
// 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 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;
@@ -345,9 +375,13 @@ export function WorkflowInputForm({
const canSubmit = isSimpleInput
? formData.value !== undefined && formData.value !== ""
: requiredFields.length > 0
? requiredFields.every(fieldName => {
? requiredFields.every((fieldName) => {
// Auto-filled fields are always valid
if (isChatMessageLike && fieldName === 'role' && formData['role'] === 'user') {
if (
isChatMessageLike &&
fieldName === "role" &&
formData["role"] === "user"
) {
return true;
}
return formData[fieldName] !== undefined && formData[fieldName] !== "";
@@ -369,8 +403,8 @@ export function WorkflowInputForm({
});
// Auto-fill role="user" for ChatMessage-like inputs
if (isChatMessageLike && !initialData['role']) {
initialData['role'] = 'user';
if (isChatMessageLike && !initialData["role"]) {
initialData["role"] = "user";
}
setFormData(initialData);
@@ -394,10 +428,13 @@ export function WorkflowInputForm({
} else {
// Filter out empty optional fields before submission
const filteredData: Record<string, unknown> = {};
Object.keys(formData).forEach(key => {
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)) {
if (
requiredFields.includes(key) ||
(value !== undefined && value !== "" && value !== null)
) {
filteredData[key] = value;
}
});
@@ -479,42 +516,41 @@ export function WorkflowInputForm({
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' : ''}
</>
)}
{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}
/>
))}
{showAdvancedFields &&
collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
</>
)}
</div>
<div className="flex gap-2 mt-4 justify-end">
<Button
type="submit"
disabled={loading || !canSubmit}
size="default"
>
<Button type="submit" disabled={loading || !canSubmit} size="default">
<Send className="h-4 w-4" />
{loading ? "Running..." : "Run Workflow"}
</Button>
@@ -652,18 +688,24 @@ export function WorkflowInputForm({
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
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' : ''}
Hide {collapsedOptionalFields.length} optional
field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Show {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
Show {collapsedOptionalFields.length} optional
field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
)}
</Button>
@@ -671,16 +713,17 @@ export function WorkflowInputForm({
)}
{/* 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}
/>
))}
{showAdvancedFields &&
collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
</>
)}
</div>
@@ -42,13 +42,13 @@ export const WorkflowSessionManager: React.FC<WorkflowSessionManagerProps> = ({
if (response.data.length === 0) {
console.log("No workflow conversations found, creating default conversation");
const newSession = await apiClient.createWorkflowSession(workflowId, {
name: `Conversation ${new Date().toLocaleString()}`,
name: `Checkpoint Storage ${new Date().toLocaleString()}`,
});
setAvailableSessions([newSession]);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "Default conversation created",
message: "Default checkpoint storage created",
type: "success",
});
} else {
@@ -87,19 +87,19 @@ export const WorkflowSessionManager: React.FC<WorkflowSessionManagerProps> = ({
setCreatingSession(true);
try {
const newSession = await apiClient.createWorkflowSession(workflowId, {
name: `Conversation ${new Date().toLocaleString()}`,
name: `Checkpoint Storage ${new Date().toLocaleString()}`,
});
addSession(newSession);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "New conversation created",
message: "New checkpoint storage created",
type: "success",
});
} catch (error) {
console.error("Failed to create conversation:", error);
console.error("Failed to create checkpoint storage:", error);
addToast({
message: "Failed to create conversation",
message: "Failed to create checkpoint storage",
type: "error",
});
} finally {
File diff suppressed because it is too large Load Diff
@@ -1,55 +0,0 @@
/**
* About DevUI Modal - Shows information about the DevUI sample app
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react";
interface AboutModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AboutModal({ open, onOpenChange }: AboutModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader className="p-6 pb-4">
<DialogTitle>About DevUI</DialogTitle>
</DialogHeader>
<DialogClose onClose={() => onOpenChange(false)} />
<div className="px-6 pb-6 space-y-4">
<p className="text-sm text-muted-foreground">
DevUI is a sample app for getting started with Agent Framework.
</p>
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
"https://github.com/microsoft/agent-framework",
"_blank"
)
}
className="text-xs"
>
<ExternalLink className="h-3 w-3 mr-1" />
Learn More about Agent Framework
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -32,7 +32,7 @@ export function AppHeader({
isLoading = false,
onSettingsClick,
}: AppHeaderProps) {
const { oaiMode } = useDevUIStore();
const { oaiMode, serverVersion } = useDevUIStore();
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
@@ -64,6 +64,11 @@ export function AppHeader({
</defs>
</svg>
Dev UI
{serverVersion && (
<span className="text-xs text-muted-foreground ml-1">
v{serverVersion}
</span>
)}
{/* Mode Badge */}
{oaiMode.enabled && (
<Badge variant="secondary" className="gap-1 ml-2">
@@ -90,7 +95,14 @@ export function AppHeader({
<div className="flex items-center gap-2 ml-auto">
<ModeToggle />
<Button variant="ghost" size="sm" onClick={(e: React.MouseEvent) => { e.stopPropagation(); onSettingsClick?.(); }}>
<Button
variant="ghost"
size="sm"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onSettingsClick?.();
}}
>
<Settings className="h-4 w-4" />
</Button>
</div>
@@ -6,5 +6,4 @@ export { AppHeader } from "./app-header";
export { EntitySelector } from "./entity-selector";
export { DebugPanel } from "./debug-panel";
export { SettingsModal } from "./settings-modal";
export { AboutModal } from "./about-modal";
export { DeploymentModal } from "./deployment-modal";
@@ -41,8 +41,8 @@ export function SettingsModal({
}: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("general");
// OpenAI proxy mode, Azure deployment, auth status, and server capabilities from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities } = useDevUIStore();
// OpenAI proxy mode, Azure deployment, auth status, server capabilities, and version from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities, serverVersion, runtime, uiMode } = useDevUIStore();
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
@@ -608,6 +608,62 @@ export function SettingsModal({
DevUI is a sample app for getting started with Agent Framework.
</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Version:</span>
<span className="font-mono">{serverVersion || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Runtime:</span>
<span className="font-mono capitalize">{runtime || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">UI Mode:</span>
<span className="font-mono capitalize">{uiMode || 'Unknown'}</span>
</div>
</div>
{/* Capabilities section - only show if we have capability data */}
{(serverCapabilities || authRequired !== undefined) && (
<div className="space-y-2 pt-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Capabilities</p>
<div className="space-y-1 text-sm">
{serverCapabilities?.tracing !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Tracing:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.tracing ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.tracing ? 'Enabled' : 'Disabled'}
</span>
</div>
)}
{serverCapabilities?.openai_proxy !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">OpenAI Proxy:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.openai_proxy ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.openai_proxy ? 'Available' : 'Not Configured'}
</span>
</div>
)}
{serverCapabilities?.deployment !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Deployment:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.deployment ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.deployment ? 'Available' : 'Disabled'}
</span>
</div>
)}
{authRequired !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Authentication:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${authRequired ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400' : 'bg-muted text-muted-foreground'}`}>
{authRequired ? 'Required' : 'Not Required'}
</span>
</div>
)}
</div>
</div>
)}
<div className="flex justify-center pt-2">
<Button
variant="outline"
@@ -0,0 +1,471 @@
/**
* ChatMessageInput - Reusable chat input component with file upload and rich text support
* Features: Text input, file upload, drag & drop, paste handling, attachments
*/
import { useState, useRef, useCallback, useEffect } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { FileUpload } from "@/components/ui/file-upload";
import {
AttachmentGallery,
type AttachmentItem,
} from "@/components/ui/attachment-gallery";
import {
SendHorizontal,
Square,
FileText,
Paperclip,
} from "lucide-react";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { ResponseInputContent, ResponseInputTextParam, ResponseInputImageParam, ResponseInputFileParam } from "@/types/agent-framework";
export interface ChatMessageInputProps {
onSubmit: (content: ResponseInputContent[]) => Promise<void>;
isSubmitting?: boolean;
isStreaming?: boolean;
onCancel?: () => void;
isCancelling?: boolean;
placeholder?: string;
showFileUpload?: boolean;
maxAttachments?: number;
className?: string;
disabled?: boolean;
entityName?: string; // For placeholder text
/** Files dropped from parent (via useDragDrop hook) */
externalFiles?: File[];
/** Called after external files have been processed */
onExternalFilesProcessed?: () => void;
}
export function ChatMessageInput({
onSubmit,
isSubmitting = false,
isStreaming = false,
onCancel,
isCancelling = false,
placeholder,
showFileUpload = true,
maxAttachments = 10,
className = "",
disabled = false,
entityName = "assistant",
externalFiles,
onExternalFilesProcessed,
}: ChatMessageInputProps) {
const [inputValue, setInputValue] = useState("");
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Process external files from parent (via useDragDrop hook)
useEffect(() => {
if (externalFiles && externalFiles.length > 0) {
handleFilesSelected(externalFiles);
onExternalFilesProcessed?.();
}
}, [externalFiles, onExternalFilesProcessed]);
// Constants for text-to-file conversion
const TEXT_THRESHOLD = 10000; // 10KB threshold for converting to file
// Helper functions
const getFileType = (file: File): AttachmentItem["type"] => {
if (file.type.startsWith("image/")) return "image";
if (file.type === "application/pdf") return "pdf";
if (file.type.startsWith("audio/")) return "audio";
return "other";
};
const readFileAsDataURL = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
// Detect file extension from content
const detectFileExtension = (text: string): string => {
const trimmed = text.trim();
const lines = trimmed.split("\n");
// JSON detection
if (/^{[\s\S]*}$|^\[[\s\S]*\]$/.test(trimmed)) return ".json";
// XML/HTML detection
if (/^<\?xml|^<html|^<!DOCTYPE/i.test(trimmed)) return ".html";
// Markdown detection (code blocks)
if (/^```/.test(trimmed)) return ".md";
// TSV detection (tabs with multiple lines)
if (/\t/.test(text) && lines.length > 1) return ".tsv";
// CSV detection (more strict) - need multiple lines with consistent comma patterns
if (lines.length > 2) {
const commaLines = lines.filter((line) => line.includes(","));
const semicolonLines = lines.filter((line) => line.includes(";"));
// If >50% of lines have commas and it looks tabular
if (commaLines.length > lines.length * 0.5) {
const avgCommas =
commaLines.reduce(
(sum, line) => sum + (line.match(/,/g) || []).length,
0
) / commaLines.length;
if (avgCommas >= 2) return ".csv";
}
// If >50% of lines have semicolons and it looks tabular
if (semicolonLines.length > lines.length * 0.5) {
const avgSemicolons =
semicolonLines.reduce(
(sum, line) => sum + (line.match(/;/g) || []).length,
0
) / semicolonLines.length;
if (avgSemicolons >= 2) return ".csv";
}
}
return ".txt";
};
// Handle file selection
const handleFilesSelected = useCallback(
async (files: File[]) => {
if (attachments.length + files.length > maxAttachments) {
console.warn(`Cannot add more than ${maxAttachments} attachments`);
return;
}
const newAttachments: AttachmentItem[] = [];
for (const file of files) {
const attachment: AttachmentItem = {
id: `${Date.now()}-${Math.random()}`,
file,
type: getFileType(file),
};
// Generate preview for images
if (file.type.startsWith("image/")) {
try {
attachment.preview = await readFileAsDataURL(file);
} catch (error) {
console.error("Failed to generate preview:", error);
}
}
newAttachments.push(attachment);
}
setAttachments((prev) => [...prev, ...newAttachments]);
},
[attachments.length, maxAttachments]
);
// Handle attachment removal
const handleRemoveAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
// Handle drag and drop
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await handleFilesSelected(files);
}
};
// Handle paste events
const handlePaste = async (e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const files: File[] = [];
let hasProcessedText = false;
for (const item of items) {
// Handle images (including screenshots)
if (item.type.startsWith("image/")) {
e.preventDefault();
const blob = item.getAsFile();
if (blob) {
const timestamp = Date.now();
files.push(
new File([blob], `screenshot-${timestamp}.png`, { type: blob.type })
);
}
}
// Handle text - only process first text item (browsers often duplicate)
else if (item.type === "text/plain" && !hasProcessedText) {
hasProcessedText = true;
// We need to check the text synchronously to decide whether to prevent default
// Unfortunately, getAsString is async, so we'll prevent default for all text
// and then decide whether to actually create a file or manually insert the text
e.preventDefault();
await new Promise<void>((resolve) => {
item.getAsString((text) => {
// Check if text should be converted to file
const lineCount = (text.match(/\n/g) || []).length;
const shouldConvert =
text.length > TEXT_THRESHOLD ||
lineCount > 50 || // Many lines suggests logs/data
/^\s*[{[][\s\S]*[}\]]\s*$/.test(text) || // JSON-like
/^<\?xml|^<html|^<!DOCTYPE/i.test(text); // XML/HTML
if (shouldConvert) {
// Create file for large/complex text
const extension = detectFileExtension(text);
const timestamp = Date.now();
const blob = new Blob([text], { type: "text/plain" });
files.push(
new File([blob], `pasted-text-${timestamp}${extension}`, {
type: "text/plain",
})
);
} else {
// For small text, manually insert into textarea since we prevented default
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const currentValue = textarea.value;
const newValue =
currentValue.slice(0, start) + text + currentValue.slice(end);
setInputValue(newValue);
// Restore cursor position after the inserted text
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd =
start + text.length;
textarea.focus();
}, 0);
}
}
resolve();
});
});
}
}
// Process collected files
if (files.length > 0) {
await handleFilesSelected(files);
// Show notification with appropriate icon
const message =
files.length === 1
? files[0].name.includes("screenshot")
? "Screenshot added as attachment"
: "Large text converted to file"
: `${files.length} files added`;
setPasteNotification(message);
setTimeout(() => setPasteNotification(null), 3000);
}
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (
(!inputValue.trim() && attachments.length === 0) ||
isSubmitting ||
disabled
)
return;
const messageText = inputValue.trim();
const content: ResponseInputContent[] = [];
// Add text content if present
if (messageText) {
content.push({
text: messageText,
type: "input_text",
} as ResponseInputTextParam);
}
// Add attachments
for (const attachment of attachments) {
const dataUri = await readFileAsDataURL(attachment.file);
if (attachment.file.type.startsWith("image/")) {
// Image attachment
content.push({
detail: "auto",
type: "input_image",
image_url: dataUri,
} as ResponseInputImageParam);
} else if (
attachment.file.type === "text/plain" &&
(attachment.file.name.includes("pasted-text-") ||
attachment.file.name.endsWith(".txt") ||
attachment.file.name.endsWith(".csv") ||
attachment.file.name.endsWith(".json") ||
attachment.file.name.endsWith(".html") ||
attachment.file.name.endsWith(".md") ||
attachment.file.name.endsWith(".tsv"))
) {
// Convert text files back to input_text
const text = await attachment.file.text();
content.push({
text: text,
type: "input_text",
} as ResponseInputTextParam);
} else {
// Other file types
const base64Data = dataUri.split(",")[1]; // Extract base64 part
content.push({
type: "input_file",
file_data: base64Data,
file_url: dataUri,
filename: attachment.file.name,
} as ResponseInputFileParam);
}
}
// Call the onSubmit callback
await onSubmit(content);
// Clear input and attachments after successful submission
setInputValue("");
setAttachments([]);
};
const canSendMessage =
!disabled &&
!isSubmitting &&
!isStreaming &&
(inputValue.trim() || attachments.length > 0);
return (
<div
className={`relative ${className}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDragOver && (
<div className="absolute inset-2 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg bg-blue-50/80 dark:bg-blue-950/40 backdrop-blur-sm flex items-center justify-center transition-all duration-200 ease-in-out z-10">
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-sm font-medium mb-1">
Drop files here
</div>
<div className="text-blue-500 dark:text-blue-500 text-xs">
Images, PDFs, and other files
</div>
</div>
</div>
)}
{/* Attachment gallery */}
{attachments.length > 0 && (
<div className="mb-3">
<AttachmentGallery
attachments={attachments}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
)}
{/* Paste notification */}
{pasteNotification && (
<div
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20
bg-blue-500 text-white px-4 py-2 rounded-full text-sm
animate-in slide-in-from-bottom-2 fade-in duration-200
flex items-center gap-2 shadow-lg"
>
{pasteNotification.includes("screenshot") ? (
<Paperclip className="h-3 w-3" />
) : (
<FileText className="h-3 w-3" />
)}
{pasteNotification}
</div>
)}
{/* Input form */}
<form onSubmit={handleSubmit} className="flex gap-2 items-end">
<Textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
// Submit on Enter (without shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
placeholder={placeholder || `Message ${entityName}... (Shift+Enter for new line)`}
disabled={disabled || isSubmitting || isStreaming}
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
style={{ fieldSizing: "content" } as React.CSSProperties}
/>
{showFileUpload && (
<FileUpload
onFilesSelected={handleFilesSelected}
disabled={disabled || isSubmitting || isStreaming}
/>
)}
{isStreaming && onCancel ? (
<Button
type="button"
size="icon"
onClick={onCancel}
disabled={isCancelling}
className="shrink-0 h-10 transition-all"
title="Stop generating"
aria-label="Stop generating response"
>
{isCancelling ? (
<LoadingSpinner size="sm" />
) : (
<Square className="h-4 w-4 fill-current" />
)}
</Button>
) : (
<Button
type="submit"
size="icon"
disabled={!canSendMessage}
className="shrink-0 h-10 transition-all"
title="Send message"
aria-label="Send message"
>
{isSubmitting ? (
<LoadingSpinner size="sm" />
) : (
<SendHorizontal className="h-4 w-4" />
)}
</Button>
)}
</form>
</div>
);
}
@@ -0,0 +1,3 @@
export { useCancellableRequest, isAbortError } from './useCancellableRequest';
export { useDragDrop } from './use-drag-drop';
export type { UseDragDropOptions, UseDragDropReturn } from './use-drag-drop';
@@ -0,0 +1,98 @@
/**
* useDragDrop - Hook for handling drag and drop file uploads at parent level
* Provides drag state and handlers that can be spread on a container element
*/
import { useState, useCallback, useRef } from "react";
export interface UseDragDropOptions {
/** Called when files are dropped */
onDrop?: (files: File[]) => void;
/** Whether drag/drop is disabled */
disabled?: boolean;
}
export interface UseDragDropReturn {
/** Whether a drag is currently over the drop zone */
isDragOver: boolean;
/** Files that were dropped (cleared after processing) */
droppedFiles: File[];
/** Clear the dropped files after they've been processed */
clearDroppedFiles: () => void;
/** Event handlers to spread on the container element */
dragHandlers: {
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
};
}
export function useDragDrop(options: UseDragDropOptions = {}): UseDragDropReturn {
const { onDrop, disabled = false } = options;
const [isDragOver, setIsDragOver] = useState(false);
const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
const dragCounterRef = useRef(0);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragOver(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
}, [disabled]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
dragCounterRef.current = 0;
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
setDroppedFiles(files);
onDrop?.(files);
}
}, [disabled, onDrop]);
const clearDroppedFiles = useCallback(() => {
setDroppedFiles([]);
}, []);
return {
isDragOver,
droppedFiles,
clearDroppedFiles,
dragHandlers: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
},
};
}
@@ -0,0 +1,70 @@
/**
* Custom hook for managing cancellable requests with AbortController
* Reduces duplication across agent and workflow views
*/
import { useState, useRef, useCallback } from "react";
/**
* Hook for managing cancellable requests with AbortController
* @returns Object with cancellation state and methods
*/
export function useCancellableRequest() {
const [isCancelling, setIsCancelling] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
/**
* Creates a new AbortController and returns its signal
* Resets the cancelling state
*/
const createAbortSignal = useCallback((): AbortSignal => {
abortControllerRef.current = new AbortController();
setIsCancelling(false);
return abortControllerRef.current.signal;
}, []);
/**
* Cancels the current request if one exists
*/
const handleCancel = useCallback(() => {
if (abortControllerRef.current) {
setIsCancelling(true);
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
/**
* Resets the cancelling state - useful in error handlers
*/
const resetCancelling = useCallback(() => {
setIsCancelling(false);
}, []);
/**
* Cleanup function to be called when component unmounts
*/
const cleanup = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
return {
isCancelling,
createAbortSignal,
handleCancel,
resetCancelling,
cleanup,
};
}
/**
* Utility function to check if an error is an AbortError
* @param error - The error to check
* @returns true if the error is an AbortError
*/
export function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}
@@ -145,3 +145,40 @@
.workflow-chat-view .text-green-800 {
@apply text-emerald-800;
}
/* HIL Timeline Item Animations */
.highlight-attention {
animation: highlight-flash 1s ease-out;
}
@keyframes highlight-flash {
0% {
background-color: rgb(251 146 60 / 0.3);
transform: scale(1.02);
}
100% {
background-color: transparent;
transform: scale(1);
}
}
/* Pulsing glow effect for HIL waiting state */
.hil-waiting-glow {
box-shadow:
0 0 0 0 rgb(251 146 60 / 0.4),
inset 0 0 0 1px rgb(251 146 60 / 0.2);
animation: pulse-glow 2s infinite;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow:
0 0 0 0 rgb(251 146 60 / 0.4),
inset 0 0 0 1px rgb(251 146 60 / 0.2);
}
50% {
box-shadow:
0 0 20px 5px rgb(251 146 60 / 0.2),
inset 0 0 0 2px rgb(251 146 60 / 0.3);
}
}
@@ -21,6 +21,7 @@ import {
markStreamingCompleted,
clearStreamingState,
} from "./streaming-state";
import { isAbortError } from "@/hooks";
// Backend API response type - polymorphic entity that can be agent or workflow
// This matches the Python Pydantic EntityInfo model which has all fields optional
@@ -60,7 +61,7 @@ interface ConversationApiResponse {
id: string;
object: "conversation";
created_at: number;
metadata?: Record<string, string>;
metadata?: Record<string, unknown>;
}
const DEFAULT_API_BASE_URL =
@@ -411,6 +412,14 @@ class ApiClient {
return this.request<{ data: unknown[]; has_more: boolean }>(url);
}
async getConversationItem(
conversationId: string,
itemId: string
): Promise<unknown> {
const url = `/v1/conversations/${conversationId}/items/${itemId}`;
return this.request<unknown>(url);
}
async deleteConversationItem(
conversationId: string,
itemId: string
@@ -430,6 +439,7 @@ class ApiClient {
private async *streamOpenAIResponse(
openAIRequest: AgentFrameworkRequest,
conversationId?: string,
signal?: AbortSignal,
resumeResponseId?: string
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Check if OpenAI proxy mode is enabled
@@ -518,6 +528,7 @@ class ApiClient {
response = await fetch(url, {
method: "GET",
headers,
signal,
});
} else {
const url = `${this.baseUrl}/v1/responses`;
@@ -540,6 +551,7 @@ class ApiClient {
method: "POST",
headers,
body: JSON.stringify(openAIRequest),
signal,
});
}
@@ -591,6 +603,11 @@ class ApiClient {
try {
while (true) {
// Check if the request was aborted
if (signal?.aborted) {
throw new DOMException('Request aborted', 'AbortError');
}
const { done, value } = await reader.read();
if (done) {
@@ -702,6 +719,14 @@ class ApiClient {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Don't retry on abort
if (isAbortError(error)) {
if (conversationId) {
markStreamingCompleted(conversationId); // Clean up state
}
throw error; // Re-throw abort error without retrying
}
// Don't retry on auth errors or client errors
if (errorMessage === "UNAUTHORIZED" || errorMessage.startsWith("CLIENT_ERROR:")) {
throw error; // Re-throw without retrying
@@ -729,6 +754,7 @@ class ApiClient {
async *streamAgentExecutionOpenAI(
agentId: string,
request: RunAgentRequest,
signal?: AbortSignal,
resumeResponseId?: string
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
const openAIRequest: AgentFrameworkRequest = {
@@ -738,7 +764,7 @@ class ApiClient {
conversation: request.conversation_id, // OpenAI standard conversation param
};
return yield* this.streamAgentExecutionOpenAIDirect(agentId, openAIRequest, request.conversation_id, resumeResponseId);
return yield* this.streamAgentExecutionOpenAIDirect(agentId, openAIRequest, request.conversation_id, signal, resumeResponseId);
}
// Stream agent execution using direct OpenAI format
@@ -746,18 +772,21 @@ class ApiClient {
_agentId: string,
openAIRequest: AgentFrameworkRequest,
conversationId?: string,
signal?: AbortSignal,
resumeResponseId?: string
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Proxy mode handling is now inside streamOpenAIResponse
yield* this.streamOpenAIResponse(openAIRequest, conversationId, resumeResponseId);
yield* this.streamOpenAIResponse(openAIRequest, conversationId, signal, resumeResponseId);
}
// Stream workflow execution using OpenAI format
async *streamWorkflowExecutionOpenAI(
workflowId: string,
request: RunWorkflowRequest
request: RunWorkflowRequest,
signal?: AbortSignal
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Convert to OpenAI format - use metadata.entity_id for routing
// input_data is serialized as JSON string - backend will parse and detect format
const openAIRequest: AgentFrameworkRequest = {
metadata: { entity_id: workflowId }, // Entity ID in metadata for routing
input: JSON.stringify(request.input_data || {}), // Serialize workflow input as JSON string
@@ -768,7 +797,7 @@ class ApiClient {
: undefined, // Pass checkpoint_id if provided
};
yield* this.streamOpenAIResponse(openAIRequest, request.conversation_id);
yield* this.streamOpenAIResponse(openAIRequest, request.conversation_id, signal);
}
// REMOVED: Legacy streaming methods - use streamAgentExecutionOpenAI and streamWorkflowExecutionOpenAI instead
@@ -888,15 +917,16 @@ class ApiClient {
has_more: boolean;
}>(url);
// Transform conversations to WorkflowSession format (no checkpoint counting)
// Transform conversations to WorkflowSession format
const sessions = response.data.map((conv) => ({
conversation_id: conv.id,
entity_id: conv.metadata?.entity_id || entityId,
entity_id: (conv.metadata?.entity_id as string) || entityId,
created_at: conv.created_at,
metadata: {
name: conv.metadata?.name || `Session ${new Date(conv.created_at * 1000).toLocaleString()}`,
description: conv.metadata?.description,
name: (conv.metadata?.name as string) || `Session ${new Date(conv.created_at * 1000).toLocaleString()}`,
description: conv.metadata?.description as string | undefined,
type: "workflow_session" as const,
checkpoint_summary: conv.metadata?.checkpoint_summary as { count: number; latest_iteration: number; has_pending_hil: boolean; pending_hil_count: number } | undefined,
},
}));
@@ -87,6 +87,7 @@ interface DevUIState {
deployment: boolean;
};
authRequired: boolean;
serverVersion: string | null;
// Deployment Slice
isDeploying: boolean;
@@ -165,7 +166,7 @@ interface DevUIActions {
toggleOAIMode: () => void;
// Server Meta Actions
setServerMeta: (meta: { uiMode: "developer" | "user"; runtime: "python" | "dotnet"; capabilities: { tracing: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean }) => void;
setServerMeta: (meta: { uiMode: "developer" | "user"; runtime: "python" | "dotnet"; capabilities: { tracing: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean; version?: string }) => void;
// Deployment Actions
startDeployment: () => void;
@@ -252,6 +253,7 @@ export const useDevUIStore = create<DevUIStore>()(
deployment: false,
},
authRequired: false,
serverVersion: null,
// Deployment State
isDeploying: false,
@@ -516,6 +518,7 @@ export const useDevUIStore = create<DevUIStore>()(
runtime: meta.runtime,
serverCapabilities: meta.capabilities,
authRequired: meta.authRequired,
serverVersion: meta.version || null,
}),
// ========================================
@@ -82,7 +82,7 @@ export interface Conversation {
id: string;
object: "conversation";
created_at: number;
metadata?: Record<string, string>;
metadata?: Record<string, unknown>;
}
export interface RunAgentRequest {
@@ -248,6 +248,12 @@ export interface WorkflowSession {
name?: string;
description?: string;
type: "workflow_session";
checkpoint_summary?: {
count: number;
latest_iteration: number;
has_pending_hil: boolean;
pending_hil_count: number;
};
[key: string]: unknown;
};
}
@@ -259,3 +265,32 @@ export interface CheckpointInfo {
iteration_count: number;
metadata?: Record<string, unknown>;
}
// Checkpoint item from conversation items API
export interface CheckpointItem {
id: string;
type: "checkpoint";
checkpoint_id: string;
workflow_id: string;
timestamp: string;
status: "completed";
metadata: {
iteration_count: number;
pending_hil_count: number;
has_pending_hil: boolean;
message_count: number;
size_bytes?: number;
version: string;
full_checkpoint?: {
checkpoint_id: string;
workflow_id: string;
timestamp: string;
messages: Record<string, unknown[]>;
shared_state: Record<string, unknown>;
pending_request_info_events: Record<string, unknown>;
iteration_count: number;
metadata: Record<string, unknown>;
version: string;
};
};
}
@@ -538,7 +538,7 @@ export interface Conversation {
id: string;
object: "conversation";
created_at: number;
metadata?: Record<string, string>;
metadata?: Record<string, unknown>;
}
// List response
@@ -7,10 +7,55 @@ import type {
import type {
ExtendedResponseStreamEvent,
ResponseWorkflowEventComplete,
JSONSchemaProperty,
} from "@/types";
import type { Workflow } from "@/types/workflow";
import { getTypedWorkflow } from "@/types/workflow";
/**
* Detects if a JSON schema represents a ChatMessage input type.
* ChatMessage schemas typically have:
* - type: "object"
* - properties with "text" (required string) and "role" (optional string)
*
* This is used to determine whether to show the rich ChatMessageInput
* component for workflows that start with an AgentExecutor.
*
* @param schema - The JSON schema to check
* @returns true if the schema represents a ChatMessage-like input
*/
export function isChatMessageSchema(schema: JSONSchemaProperty | undefined): boolean {
if (!schema) return false;
// Must be an object type
if (schema.type !== "object") return false;
// Must have properties
if (!schema.properties) return false;
const props = schema.properties;
// ChatMessage has "text" property (the main content)
const hasText = "text" in props && props.text?.type === "string";
// ChatMessage has "role" property (user, assistant, system)
const hasRole = "role" in props && props.role?.type === "string";
// If it has both text and role, it's likely a ChatMessage
if (hasText && hasRole) {
return true;
}
// Also check for simpler chat-like schemas (just text field)
// This covers cases where the schema might be simplified
const propKeys = Object.keys(props);
if (propKeys.length === 1 && hasText) {
return true;
}
return false;
}
/**
* Truncates text that exceeds the maximum length and appends ellipsis
* @param text - The text to truncate
@@ -177,19 +222,22 @@ export function convertWorkflowDumpToEdges(
return [];
}
const edges = connections.map((connection) => ({
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
sourceHandle: "source",
targetHandle: "target",
type: "default",
animated: false,
style: {
stroke: "#6b7280",
strokeWidth: 2,
},
}));
const edges = connections.map((connection) => {
const isSelfLoop = connection.source === connection.target;
return {
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
sourceHandle: "source",
targetHandle: "target",
type: isSelfLoop ? "selfLoop" : "default",
animated: false,
style: {
stroke: "#6b7280",
strokeWidth: 2,
},
};
});
return edges;
}
@@ -478,7 +526,8 @@ export function processWorkflowEvents(
*/
export function updateNodesWithEvents(
nodes: Node<ExecutorNodeData>[],
nodeUpdates: Record<string, NodeUpdate>
nodeUpdates: Record<string, NodeUpdate>,
isStreaming: boolean = true
): Node<ExecutorNodeData>[] {
return nodes.map((node) => {
const update = nodeUpdates[node.id];
@@ -490,6 +539,8 @@ export function updateNodesWithEvents(
state: update.state,
outputData: update.data,
error: update.error,
// Add isStreaming to control spinning animation
isStreaming: isStreaming,
// Preserve layoutDirection
layoutDirection: node.data.layoutDirection,
},
@@ -670,6 +721,13 @@ export function consolidateBidirectionalEdges(edges: Edge[]): Edge[] {
const forwardKey = `${edge.source}-${edge.target}`;
const reverseKey = `${edge.target}-${edge.source}`;
// Self-loops (source === target) should always be preserved as-is
// They are not bidirectional edges, just a node pointing to itself
if (edge.source === edge.target) {
edgeMap.set(forwardKey, edge);
return;
}
// Check if we already have the reverse edge
if (edgeMap.has(reverseKey)) {
// Mark both keys as bidirectional
@@ -119,13 +119,13 @@ async def test_list_conversations_by_metadata():
conv3 = store.create_conversation(metadata={"agent_id": "agent1", "session_id": "sess_1"})
# Filter by agent_id
results = store.list_conversations_by_metadata({"agent_id": "agent1"})
results = await store.list_conversations_by_metadata({"agent_id": "agent1"})
assert len(results) == 2
assert all(cast(dict[str, str], c.metadata).get("agent_id") == "agent1" for c in results if c.metadata)
# Filter by agent_id and session_id
results = store.list_conversations_by_metadata({"agent_id": "agent1", "session_id": "sess_1"})
results = await store.list_conversations_by_metadata({"agent_id": "agent1", "session_id": "sess_1"})
assert len(results) == 1
assert results[0].id == conv3.id
+53 -2
View File
@@ -418,7 +418,7 @@ async def test_executor_action_events(mapper: MessageMapper, test_request: Agent
async def test_magentic_agent_delta_creates_message_container(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""Test that MagenticAgentDeltaEvent creates message containers (Option A implementation)."""
"""Test that MagenticAgentDeltaEvent creates message containers when no executor context (fallback)."""
# Create mock MagenticAgentDeltaEvent that mimics the real class
from dataclasses import dataclass
@@ -438,7 +438,7 @@ async def test_magentic_agent_delta_creates_message_container(
agent_id: str
text: str | None = None
# First delta should create message container
# First delta should create message container (no executor context = fallback behavior)
first_delta = MagenticAgentDeltaEvent(agent_id="test_agent", text="Hello ")
events = await mapper.convert_event(first_delta, test_request)
@@ -465,6 +465,57 @@ async def test_magentic_agent_delta_creates_message_container(
assert events[0].item_id == message_id
async def test_magentic_agent_delta_routes_to_executor_item(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""Test that MagenticAgentDeltaEvent routes to executor item when executor context is present."""
from dataclasses import dataclass
try:
from agent_framework import WorkflowEvent
@dataclass
class ExecutorInvokedEvent(WorkflowEvent):
executor_id: str
@dataclass
class MagenticAgentDeltaEvent(WorkflowEvent):
agent_id: str
text: str | None = None
except ImportError:
@dataclass
class ExecutorInvokedEvent:
executor_id: str
@dataclass
class MagenticAgentDeltaEvent:
agent_id: str
text: str | None = None
# First, simulate executor being invoked (sets current_executor_id in context)
executor_event = ExecutorInvokedEvent(executor_id="agent_writer")
executor_events = await mapper.convert_event(executor_event, test_request)
# Should create executor item
assert len(executor_events) == 1
assert executor_events[0].type == "response.output_item.added"
assert executor_events[0].item.type == "executor_action"
executor_item_id = executor_events[0].item.id
# Now send Magentic delta - should route to executor's item, NOT create new message
delta = MagenticAgentDeltaEvent(agent_id="writer", text="Hello world")
delta_events = await mapper.convert_event(delta, test_request)
# Should only emit 1 event: text delta routed to executor's item
assert len(delta_events) == 1
assert delta_events[0].type == "response.output_text.delta"
assert delta_events[0].item_id == executor_item_id # Routed to executor's item!
assert delta_events[0].delta == "Hello world"
if __name__ == "__main__":
# Simple test runner
async def run_all_tests() -> None:
@@ -0,0 +1,154 @@
# Copyright (c) Microsoft. All rights reserved.
"""Test multimodal input handling for workflows.
This test verifies that workflows with AgentExecutor nodes correctly receive
multimodal content (images, files) from the DevUI frontend.
"""
import json
from unittest.mock import MagicMock
from agent_framework_devui._discovery import EntityDiscovery
from agent_framework_devui._executor import AgentFrameworkExecutor
from agent_framework_devui._mapper import MessageMapper
# Create a small test image (1x1 red pixel PNG)
TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
TEST_IMAGE_DATA_URI = f"data:image/png;base64,{TEST_IMAGE_BASE64}"
class TestMultimodalWorkflowInput:
"""Test multimodal input handling for workflows."""
def test_is_openai_multimodal_format_detects_message_format(self):
"""Test that _is_openai_multimodal_format correctly detects OpenAI format."""
discovery = MagicMock(spec=EntityDiscovery)
mapper = MagicMock(spec=MessageMapper)
executor = AgentFrameworkExecutor(discovery, mapper)
# Valid OpenAI multimodal format
valid_format = [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "Describe this image"},
{"type": "input_image", "image_url": TEST_IMAGE_DATA_URI},
],
}
]
assert executor._is_openai_multimodal_format(valid_format) is True
# Invalid formats
assert executor._is_openai_multimodal_format({}) is False # dict, not list
assert executor._is_openai_multimodal_format([]) is False # empty list
assert executor._is_openai_multimodal_format("hello") is False # string
assert executor._is_openai_multimodal_format([{"type": "other"}]) is False # wrong type
assert executor._is_openai_multimodal_format([{"foo": "bar"}]) is False # no type field
def test_convert_openai_input_to_chat_message_with_image(self):
"""Test that OpenAI format with image is converted to ChatMessage with DataContent."""
from agent_framework import ChatMessage, DataContent, Role, TextContent
discovery = MagicMock(spec=EntityDiscovery)
mapper = MagicMock(spec=MessageMapper)
executor = AgentFrameworkExecutor(discovery, mapper)
# OpenAI format input with text and image (as sent by frontend)
openai_input = [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "Describe this image"},
{"type": "input_image", "image_url": TEST_IMAGE_DATA_URI},
],
}
]
# Convert to ChatMessage
result = executor._convert_input_to_chat_message(openai_input)
# Verify result is ChatMessage
assert isinstance(result, ChatMessage), f"Expected ChatMessage, got {type(result)}"
assert result.role == Role.USER
# Verify contents
assert len(result.contents) == 2, f"Expected 2 contents, got {len(result.contents)}"
# First content should be text
assert isinstance(result.contents[0], TextContent)
assert result.contents[0].text == "Describe this image"
# Second content should be image (DataContent)
assert isinstance(result.contents[1], DataContent)
assert result.contents[1].media_type == "image/png"
assert result.contents[1].uri == TEST_IMAGE_DATA_URI
def test_parse_workflow_input_handles_json_string_with_multimodal(self):
"""Test that _parse_workflow_input correctly handles JSON string with multimodal content."""
import asyncio
from agent_framework import ChatMessage, DataContent, TextContent
discovery = MagicMock(spec=EntityDiscovery)
mapper = MagicMock(spec=MessageMapper)
executor = AgentFrameworkExecutor(discovery, mapper)
# This is what the frontend sends: JSON stringified OpenAI format
openai_input = [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "What is in this image?"},
{"type": "input_image", "image_url": TEST_IMAGE_DATA_URI},
],
}
]
json_string_input = json.dumps(openai_input)
# Mock workflow
mock_workflow = MagicMock()
# Parse the input
result = asyncio.run(executor._parse_workflow_input(mock_workflow, json_string_input))
# Verify result is ChatMessage with multimodal content
assert isinstance(result, ChatMessage), f"Expected ChatMessage, got {type(result)}"
assert len(result.contents) == 2
# Verify text content
assert isinstance(result.contents[0], TextContent)
assert result.contents[0].text == "What is in this image?"
# Verify image content
assert isinstance(result.contents[1], DataContent)
assert result.contents[1].media_type == "image/png"
def test_parse_workflow_input_still_handles_simple_dict(self):
"""Test that simple dict input still works (backward compatibility)."""
import asyncio
from agent_framework import ChatMessage
discovery = MagicMock(spec=EntityDiscovery)
mapper = MagicMock(spec=MessageMapper)
executor = AgentFrameworkExecutor(discovery, mapper)
# Simple dict input (old format)
simple_input = {"text": "Hello world", "role": "user"}
json_string_input = json.dumps(simple_input)
# Mock workflow with ChatMessage input type
mock_workflow = MagicMock()
mock_executor = MagicMock()
mock_executor.input_types = [ChatMessage]
mock_workflow.get_start_executor.return_value = mock_executor
# Parse the input
result = asyncio.run(executor._parse_workflow_input(mock_workflow, json_string_input))
# Result should be ChatMessage (from _parse_structured_workflow_input)
assert isinstance(result, ChatMessage), f"Expected ChatMessage, got {type(result)}"