mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
6835161f2d
commit
411ee7a60f
@@ -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>
|
||||
|
||||
|
||||
+395
@@ -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>
|
||||
);
|
||||
}
|
||||
+144
-29
@@ -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>
|
||||
);
|
||||
}
|
||||
+153
@@ -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";
|
||||
|
||||
+427
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -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}
|
||||
|
||||
+107
-64
@@ -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>
|
||||
|
||||
+6
-6
@@ -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 {
|
||||
|
||||
+391
-503
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
|
||||
|
||||
@@ -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)}"
|
||||
Reference in New Issue
Block a user