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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user