mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: DevUI - Internal Refactor, Conversations API support, and per… (#1235)
* Python: DevUI - Internal Refactor, Conversations API support, and performance improvements Comprehensive refactor of DevUI package including samples relocation, frontend reorganization, OpenAI Conversations API support, and critical performance and code quality improvements. Key Changes: Architecture & Organization - Moved DevUI samples to python/samples/getting_started/devui/ - Consolidated with other framework samples for better discoverability - Added .env.example files and comprehensive README - Restructured frontend components into feature-based folders (agent, workflow, gallery, layout) - Created new OpenAI-compliant message renderers (devui should render oai responses types primarily) New Features - Added _conversations.py (467 lines) - Full conversation storage abstraction, replaces the /threads endpoint to better match oai conversations api - Implements OpenAI Conversations API for thread management, Supports in-memory and extensible storage backends API Simplification - Use 'model' field as entity_id (agent/workflow name) instead of extra_body - Use standard OpenAI 'conversation' field for conversation context. Performance & Quality Improvements - Improved context management in MessageMapper with bounded memory (~500KB max) - Implemented hybrid LRU + cleanup approach to prevent unbounded memory growth - General QOL improvement - Eliminated ~150 lines of dead/duplicate code, Consolidated helper functions into _utils.py, Extracted magic numbers to module-level constants, Optimized conversation item lookups with index-based approach Testing - Added test_conversations.py (13 tests) - Added test_performance_fixes.py (9 tests) - Updated existing tests for code consolidation - 53 tests passing Impact: 76 files changed: +4,106 insertions, -2,373 deletions All linting and formatting checks passing. No breaking changes - backward compatible. Migration: Samples moved to python/samples/getting_started/devui/ * readme lint fixes * initial support for function approval and minor ui fixes
This commit is contained in:
committed by
GitHub
Unverified
parent
f5abbc67ae
commit
c341ee7ed2
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* AgentDetailsModal - Responsive grid-based modal for displaying agent metadata
|
||||
*/
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Bot,
|
||||
Package,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Database,
|
||||
Globe,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { AgentInfo } from "@/types";
|
||||
|
||||
interface AgentDetailsModalProps {
|
||||
agent: AgentInfo;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface DetailCardProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 bg-card ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentDetailsModal({
|
||||
agent,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AgentDetailsModalProps) {
|
||||
const sourceIcon =
|
||||
agent.source === "directory" ? (
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : agent.source === "in_memory" ? (
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
);
|
||||
|
||||
const sourceLabel =
|
||||
agent.source === "directory"
|
||||
? "Local"
|
||||
: agent.source === "in_memory"
|
||||
? "In-Memory"
|
||||
: "Gallery";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 flex-shrink-0">
|
||||
<DialogTitle>Agent Details</DialogTitle>
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1">
|
||||
{/* Header Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Bot className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{agent.name || agent.id}
|
||||
</h2>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p className="text-muted-foreground">{agent.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border mb-6" />
|
||||
|
||||
{/* Grid Layout for Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Model & Client */}
|
||||
{(agent.model || agent.chat_client_type) && (
|
||||
<DetailCard
|
||||
title="Model & Client"
|
||||
icon={<Bot className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{agent.model && (
|
||||
<div className="font-mono text-foreground">{agent.model}</div>
|
||||
)}
|
||||
{agent.chat_client_type && (
|
||||
<div className="text-xs">({agent.chat_client_type})</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailCard>
|
||||
)}
|
||||
|
||||
{/* Source */}
|
||||
<DetailCard title="Source" icon={sourceIcon}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-foreground">{sourceLabel}</div>
|
||||
{agent.module_path && (
|
||||
<div className="font-mono text-xs break-all">
|
||||
{agent.module_path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailCard>
|
||||
|
||||
{/* Environment */}
|
||||
<DetailCard
|
||||
title="Environment"
|
||||
icon={
|
||||
agent.has_env ? (
|
||||
<XCircle className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
)
|
||||
}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
agent.has_env ? "text-orange-600 dark:text-orange-400" : "text-green-600 dark:text-green-400"
|
||||
}
|
||||
>
|
||||
{agent.has_env
|
||||
? "Requires environment variables"
|
||||
: "No environment variables required"}
|
||||
</div>
|
||||
</DetailCard>
|
||||
</div>
|
||||
|
||||
{/* Full Width Sections */}
|
||||
{agent.instructions && (
|
||||
<DetailCard
|
||||
title="Instructions"
|
||||
icon={<FileText className="h-4 w-4 text-muted-foreground" />}
|
||||
className="mb-4"
|
||||
>
|
||||
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{agent.instructions}
|
||||
</div>
|
||||
</DetailCard>
|
||||
)}
|
||||
|
||||
{/* Tools and Middleware Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Tools */}
|
||||
<DetailCard
|
||||
title={`Tools (${agent.tools.length})`}
|
||||
icon={<Package className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
{agent.tools.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{agent.tools.map((tool, index) => (
|
||||
<li key={index} className="font-mono text-xs text-foreground">
|
||||
• {tool}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-muted-foreground">No tools configured</div>
|
||||
)}
|
||||
</DetailCard>
|
||||
|
||||
{/* Middleware */}
|
||||
{agent.middleware && agent.middleware.length > 0 && (
|
||||
<DetailCard
|
||||
title={`Middleware (${agent.middleware.length})`}
|
||||
icon={<Package className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{agent.middleware.map((mw, index) => (
|
||||
<li key={index} className="font-mono text-xs text-foreground">
|
||||
• {mw}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailCard>
|
||||
)}
|
||||
|
||||
{/* Context Providers */}
|
||||
{agent.context_providers && agent.context_providers.length > 0 && (
|
||||
<DetailCard
|
||||
title={`Context Providers (${agent.context_providers.length})`}
|
||||
icon={<Database className="h-4 w-4 text-muted-foreground" />}
|
||||
className={!agent.middleware || agent.middleware.length === 0 ? "md:col-start-2" : ""}
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{agent.context_providers.map((cp, index) => (
|
||||
<li key={index} className="font-mono text-xs text-foreground">
|
||||
• {cp}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Agent Feature - Exports
|
||||
*/
|
||||
|
||||
export { AgentView } from "./agent-view";
|
||||
export { AgentDetailsModal } from "./agent-details-modal";
|
||||
export * from "./message-renderers";
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* OpenAI Content Renderer - Renders OpenAI Conversations API content types
|
||||
* This is the CORRECT implementation that works with OpenAI types only
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Download,
|
||||
FileText,
|
||||
Code,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Music,
|
||||
} from "lucide-react";
|
||||
import type { MessageContent } from "@/types/openai";
|
||||
|
||||
interface ContentRendererProps {
|
||||
content: MessageContent;
|
||||
className?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
// Text content renderer
|
||||
function TextContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "text") return null;
|
||||
|
||||
const text = content.text;
|
||||
const TRUNCATE_LENGTH = 1600;
|
||||
const shouldTruncate = text.length > TRUNCATE_LENGTH;
|
||||
const displayText =
|
||||
shouldTruncate && !isExpanded
|
||||
? text.slice(0, TRUNCATE_LENGTH) + "..."
|
||||
: text;
|
||||
|
||||
return (
|
||||
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
|
||||
<div
|
||||
className={
|
||||
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
|
||||
}
|
||||
>
|
||||
{displayText}
|
||||
{isStreaming && text.length > 0 && (
|
||||
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
</div>
|
||||
{shouldTruncate && (
|
||||
<div className="flex justify-end mt-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="inline-flex items-center gap-1 text-xs
|
||||
bg-background/80 hover:bg-background border border-border/50 hover:border-border
|
||||
text-muted-foreground hover:text-foreground
|
||||
transition-colors cursor-pointer px-2 py-1 rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
less <ChevronUp className="h-3 w-3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{(text.length - TRUNCATE_LENGTH).toLocaleString()} more{" "}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image content renderer
|
||||
function ImageContentRenderer({ content, className }: ContentRendererProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "input_image") return null;
|
||||
|
||||
const imageUrl = content.image_url;
|
||||
|
||||
if (imageError) {
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>Image could not be loaded</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 ${className || ""}`}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Uploaded image"
|
||||
className={`rounded-lg border max-w-full transition-all cursor-pointer ${
|
||||
isExpanded ? "max-h-none" : "max-h-64"
|
||||
}`}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
{isExpanded && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Click to collapse
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// File content renderer
|
||||
function FileContentRenderer({ content, className }: ContentRendererProps) {
|
||||
if (content.type !== "input_file") return null;
|
||||
|
||||
const fileUrl = content.file_url || content.file_data;
|
||||
const filename = content.filename || "file";
|
||||
|
||||
// Determine file type from filename or data URI
|
||||
const isPdf = filename?.toLowerCase().endsWith(".pdf") || fileUrl?.includes("application/pdf");
|
||||
const isAudio = filename?.toLowerCase().match(/\.(mp3|wav|m4a|ogg|flac|aac)$/);
|
||||
|
||||
// For PDFs, try to embed
|
||||
if (isPdf && fileUrl) {
|
||||
return (
|
||||
<div className={`my-2 ${className || ""}`}>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
src={fileUrl}
|
||||
className="w-full h-96"
|
||||
title={filename}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{filename}</span>
|
||||
{fileUrl && (
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={filename}
|
||||
className="ml-auto text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For audio files
|
||||
if (isAudio && fileUrl) {
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg ${className || ""}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Music className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{filename}</span>
|
||||
</div>
|
||||
<audio controls className="w-full">
|
||||
<source src={fileUrl} />
|
||||
Your browser does not support audio playback.
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic file display
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{filename}</span>
|
||||
</div>
|
||||
{fileUrl && (
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={filename}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main content renderer that delegates to specific renderers
|
||||
export function OpenAIContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
return <TextContentRenderer content={content} className={className} isStreaming={isStreaming} />;
|
||||
case "input_image":
|
||||
return <ImageContentRenderer content={content} className={className} />;
|
||||
case "input_file":
|
||||
return <FileContentRenderer content={content} className={className} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Function call renderer (for displaying function calls in chat)
|
||||
interface FunctionCallRendererProps {
|
||||
name: string;
|
||||
arguments: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FunctionCallRenderer({ name, arguments: args, className }: FunctionCallRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
let parsedArgs;
|
||||
try {
|
||||
parsedArgs = typeof args === "string" ? JSON.parse(args) : args;
|
||||
} catch {
|
||||
parsedArgs = args;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
Function Call: {name}
|
||||
</span>
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400">{isExpanded ? "▼" : "▶"}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
<div className="text-blue-600 dark:text-blue-400 mb-1">Arguments:</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedArgs, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Function result renderer
|
||||
interface FunctionResultRendererProps {
|
||||
output: string;
|
||||
call_id: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FunctionResultRenderer({ output, call_id, className }: FunctionResultRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
let parsedOutput;
|
||||
try {
|
||||
parsedOutput = typeof output === "string" ? JSON.parse(output) : output;
|
||||
} catch {
|
||||
parsedOutput = output;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Code className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-300">
|
||||
Function Result
|
||||
</span>
|
||||
<span className="text-xs text-green-600 dark:text-green-400">{isExpanded ? "▼" : "▶"}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
<div className="text-green-600 dark:text-green-400 mb-1">Output:</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedOutput, null, 2)}
|
||||
</pre>
|
||||
<div className="text-gray-500 text-[10px] mt-2">Call ID: {call_id}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* OpenAI Message Renderer - Renders OpenAI ConversationItem types
|
||||
* This replaces the legacy AgentFramework-based renderer
|
||||
*/
|
||||
|
||||
import type { ConversationItem } from "@/types/openai";
|
||||
import {
|
||||
OpenAIContentRenderer,
|
||||
FunctionCallRenderer,
|
||||
FunctionResultRenderer,
|
||||
} from "./OpenAIContentRenderer";
|
||||
|
||||
interface OpenAIMessageRendererProps {
|
||||
item: ConversationItem;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OpenAIMessageRenderer({
|
||||
item,
|
||||
className,
|
||||
}: OpenAIMessageRendererProps) {
|
||||
// Handle message items (user/assistant with content)
|
||||
if (item.type === "message") {
|
||||
// Determine if message is actively streaming
|
||||
const isStreaming = item.status === "in_progress";
|
||||
const hasContent = item.content.length > 0;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{item.content.map((content, index) => (
|
||||
<OpenAIContentRenderer
|
||||
key={index}
|
||||
content={content}
|
||||
className={index > 0 ? "mt-2" : ""}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Show typing indicator when streaming with no content yet */}
|
||||
{isStreaming && !hasContent && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="flex space-x-1">
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle function call items
|
||||
if (item.type === "function_call") {
|
||||
return (
|
||||
<FunctionCallRenderer
|
||||
name={item.name}
|
||||
arguments={item.arguments}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle function result items
|
||||
if (item.type === "function_call_output") {
|
||||
return (
|
||||
<FunctionResultRenderer
|
||||
output={item.output}
|
||||
call_id={item.call_id}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown item type
|
||||
return null;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Message Renderer - Exports
|
||||
* Uses OpenAI Responses API types exclusively
|
||||
*/
|
||||
|
||||
export { OpenAIMessageRenderer } from "./OpenAIMessageRenderer";
|
||||
export { OpenAIContentRenderer, FunctionCallRenderer, FunctionResultRenderer } from "./OpenAIContentRenderer";
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* GalleryView - Consolidated gallery component with card and grid logic
|
||||
* Supports inline (empty state) and modal variants
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Bot,
|
||||
Workflow,
|
||||
Plus,
|
||||
Loader2,
|
||||
User,
|
||||
TriangleAlert,
|
||||
AlertCircle,
|
||||
X,
|
||||
Key,
|
||||
ChevronDown,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
SAMPLE_ENTITIES,
|
||||
type SampleEntity,
|
||||
getDifficultyColor,
|
||||
} from "@/data/gallery";
|
||||
|
||||
interface GalleryViewProps {
|
||||
onAdd: (sample: SampleEntity) => Promise<void>;
|
||||
addingEntityId?: string | null;
|
||||
errorEntityId?: string | null;
|
||||
errorMessage?: string | null;
|
||||
onClearError?: (sampleId: string) => void;
|
||||
onClose?: () => void;
|
||||
variant?: "inline" | "route" | "modal";
|
||||
hasExistingEntities?: boolean;
|
||||
}
|
||||
|
||||
// Internal: Sample Entity Card Component
|
||||
function SampleEntityCard({
|
||||
sample,
|
||||
onAdd,
|
||||
isAdding = false,
|
||||
hasError = false,
|
||||
errorMessage,
|
||||
onClearError,
|
||||
}: {
|
||||
sample: SampleEntity;
|
||||
onAdd: (sample: SampleEntity) => Promise<void>;
|
||||
isAdding?: boolean;
|
||||
hasError?: boolean;
|
||||
errorMessage?: string | null;
|
||||
onClearError?: (sampleId: string) => void;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (isLoading || isAdding) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onAdd(sample);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const TypeIcon = sample.type === "workflow" ? Workflow : Bot;
|
||||
const isDisabled = isLoading || isAdding;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full",
|
||||
hasError && "border-destructive"
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-3 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="h-5 w-5" />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sample.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs border",
|
||||
getDifficultyColor(sample.difficulty)
|
||||
)}
|
||||
>
|
||||
{sample.difficulty}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<CardTitle className="text-lg leading-tight">{sample.name}</CardTitle>
|
||||
<CardDescription className="text-sm line-clamp-3">
|
||||
{sample.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
|
||||
{/* Error Banner */}
|
||||
{hasError && errorMessage && (
|
||||
<div className="mb-3 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-destructive font-medium mb-1">
|
||||
Failed to add
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{errorMessage}</p>
|
||||
</div>
|
||||
{onClearError && (
|
||||
<button
|
||||
onClick={() => onClearError(sample.id)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 min-w-0">
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sample.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{sample.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{sample.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Environment Variables Required - Collapsible */}
|
||||
{sample.requiredEnvVars && sample.requiredEnvVars.length > 0 && (
|
||||
<details className="group min-w-0 max-w-full overflow-hidden">
|
||||
<summary className="cursor-pointer list-none p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md hover:bg-amber-100 dark:hover:bg-amber-950/30 transition-colors flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Key className="h-3.5 w-3.5 text-amber-600 dark:text-amber-500 flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-amber-900 dark:text-amber-100 truncate">
|
||||
Requires {sample.requiredEnvVars.length} env var
|
||||
{sample.requiredEnvVars.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-3 w-3 text-amber-600 dark:text-amber-500 flex-shrink-0 group-open:rotate-180 transition-transform" />
|
||||
</summary>
|
||||
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md space-y-2 min-w-0 max-w-full overflow-hidden">
|
||||
{sample.requiredEnvVars.map((envVar) => (
|
||||
<div key={envVar.name} className="text-xs min-w-0 max-w-full overflow-hidden">
|
||||
<div className="font-mono font-medium text-amber-900 dark:text-amber-100 break-words">
|
||||
{envVar.name}
|
||||
</div>
|
||||
<div className="text-amber-700 dark:text-amber-300 mt-0.5 break-words">
|
||||
{envVar.description}
|
||||
</div>
|
||||
{envVar.example && (
|
||||
<div className="font-mono text-amber-600 dark:text-amber-400 mt-0.5 break-all">
|
||||
{envVar.example}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Features */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Key Features:
|
||||
</div>
|
||||
<ul className="text-xs space-y-1">
|
||||
{sample.features.slice(0, 3).map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-1">
|
||||
<div className="w-1 h-1 rounded-full bg-current opacity-50" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-3 flex-col gap-3">
|
||||
{/* Metadata */}
|
||||
<div className="w-full flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{sample.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Button - Full width on its own line */}
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={isDisabled}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
variant={hasError ? "outline" : "default"}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Adding...
|
||||
</>
|
||||
) : hasError ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Sample
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal: Sample Entity Grid Component
|
||||
function SampleEntityGrid({
|
||||
samples,
|
||||
onAdd,
|
||||
addingEntityId,
|
||||
errorEntityId,
|
||||
errorMessage,
|
||||
onClearError,
|
||||
}: {
|
||||
samples: SampleEntity[];
|
||||
onAdd: (sample: SampleEntity) => Promise<void>;
|
||||
addingEntityId?: string | null;
|
||||
errorEntityId?: string | null;
|
||||
errorMessage?: string | null;
|
||||
onClearError?: (sampleId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{samples.map((sample) => (
|
||||
<div key={sample.id} className="min-w-0">
|
||||
<SampleEntityCard
|
||||
sample={sample}
|
||||
onAdd={onAdd}
|
||||
isAdding={addingEntityId === sample.id}
|
||||
hasError={errorEntityId === sample.id}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main: Gallery View Component
|
||||
export function GalleryView({
|
||||
onAdd,
|
||||
addingEntityId,
|
||||
errorEntityId,
|
||||
errorMessage,
|
||||
onClearError,
|
||||
onClose,
|
||||
variant = "inline",
|
||||
hasExistingEntities = false,
|
||||
}: GalleryViewProps) {
|
||||
// Inline variant - for empty state in main app
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{/* Info Banner */}
|
||||
<div className="mb-8 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<TriangleAlert className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">
|
||||
No agents or workflows configured yet!
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
You can configure agents or workflows by running{" "}
|
||||
<code className="px-1.5 py-0.5 bg-background rounded text-xs">
|
||||
devui
|
||||
</code>{" "}
|
||||
in a directory containing them.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can also import any of the sample agents and workflows
|
||||
below to get started quickly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sample Gallery */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Sample Gallery</h3>
|
||||
<SampleEntityGrid
|
||||
samples={SAMPLE_ENTITIES}
|
||||
onAdd={onAdd}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-12 pt-8 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Want to create your own agents or workflows? Check out the{" "}
|
||||
<a
|
||||
href="https://github.com/microsoft/agent-framework"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Route variant - for /gallery page
|
||||
if (variant === "route") {
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
{hasExistingEntities && (
|
||||
<div className="mb-4">
|
||||
<Button variant="ghost" onClick={onClose} className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold mb-2">Sample Gallery</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Browse and add sample agents and workflows to learn the Agent
|
||||
Framework. These are curated examples ranging from beginner to
|
||||
advanced.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sample Gallery */}
|
||||
<SampleEntityGrid
|
||||
samples={SAMPLE_ENTITIES}
|
||||
onAdd={onAdd}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-12 pt-8 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Want to create your own agents or workflows? Check out the{" "}
|
||||
<a
|
||||
href="https://github.com/microsoft/agent-framework"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal variant - for dropdown trigger (simplified, just the grid)
|
||||
return (
|
||||
<SampleEntityGrid
|
||||
samples={SAMPLE_ENTITIES}
|
||||
onAdd={onAdd}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Gallery component exports
|
||||
*/
|
||||
|
||||
export { GalleryView } from './gallery-view';
|
||||
@@ -0,0 +1,235 @@
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
Workflow,
|
||||
Home,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ExecutorState =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface ExecutorNodeData extends Record<string, unknown> {
|
||||
executorId: string;
|
||||
executorType?: string;
|
||||
name?: string;
|
||||
state: ExecutorState;
|
||||
inputData?: unknown;
|
||||
outputData?: unknown;
|
||||
error?: string;
|
||||
isSelected?: boolean;
|
||||
isStartNode?: boolean;
|
||||
isEndNode?: boolean;
|
||||
layoutDirection?: "LR" | "TB";
|
||||
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
}
|
||||
|
||||
const getExecutorStateConfig = (state: ExecutorState) => {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return {
|
||||
borderColor: "border-[#643FB2] dark:border-[#8B5CF6]",
|
||||
glow: "shadow-lg shadow-[#643FB2]/20",
|
||||
badgeColor: "bg-[#643FB2] dark:bg-[#8B5CF6]",
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
borderColor: "border-green-500 dark:border-green-400",
|
||||
glow: "shadow-lg shadow-green-500/20",
|
||||
badgeColor: "bg-green-500 dark:bg-green-400",
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
borderColor: "border-red-500 dark:border-red-400",
|
||||
glow: "shadow-lg shadow-red-500/20",
|
||||
badgeColor: "bg-red-500 dark:bg-red-400",
|
||||
};
|
||||
case "cancelled":
|
||||
return {
|
||||
borderColor: "border-orange-500 dark:border-orange-400",
|
||||
glow: "shadow-lg shadow-orange-500/20",
|
||||
badgeColor: "bg-orange-500 dark:bg-orange-400",
|
||||
};
|
||||
case "pending":
|
||||
default:
|
||||
return {
|
||||
borderColor: "border-gray-300 dark:border-gray-600",
|
||||
glow: "",
|
||||
badgeColor: "bg-gray-400 dark:bg-gray-500",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
const nodeData = data as ExecutorNodeData;
|
||||
const config = getExecutorStateConfig(nodeData.state);
|
||||
|
||||
const hasData = nodeData.inputData || nodeData.outputData || nodeData.error;
|
||||
const isRunning = nodeData.state === "running";
|
||||
|
||||
// Determine handle positions based on layout direction
|
||||
const isVertical = nodeData.layoutDirection === "TB";
|
||||
const targetPosition = isVertical ? Position.Top : Position.Left;
|
||||
const sourcePosition = isVertical ? Position.Bottom : Position.Right;
|
||||
|
||||
// Helper to safely render data with full details
|
||||
const renderDataDetails = () => {
|
||||
const details = [];
|
||||
|
||||
if (nodeData.error && typeof nodeData.error === "string") {
|
||||
details.push(
|
||||
<div key="error" className="mb-2">
|
||||
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">Error:</div>
|
||||
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800">
|
||||
{nodeData.error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodeData.outputData) {
|
||||
try {
|
||||
const outputStr =
|
||||
typeof nodeData.outputData === "string"
|
||||
? nodeData.outputData
|
||||
: JSON.stringify(nodeData.outputData, null, 2);
|
||||
details.push(
|
||||
<div key="output" className="mb-2">
|
||||
<div className="text-xs font-medium text-green-600 dark:text-green-400 mb-1">Output:</div>
|
||||
<div className="text-xs text-gray-700 dark:text-gray-300 bg-green-50 dark:bg-green-950/20 p-2 rounded border border-green-200 dark:border-green-800 max-h-20 overflow-auto">
|
||||
<pre className="whitespace-pre-wrap font-mono">{outputStr}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
details.push(
|
||||
<div key="output" className="mb-2">
|
||||
<div className="text-xs font-medium text-green-600 dark:text-green-400 mb-1">Output:</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 bg-green-50 dark:bg-green-950/20 p-2 rounded border border-green-200 dark:border-green-800">
|
||||
[Unable to display output data]
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeData.inputData) {
|
||||
try {
|
||||
const inputStr =
|
||||
typeof nodeData.inputData === "string"
|
||||
? nodeData.inputData
|
||||
: JSON.stringify(nodeData.inputData, null, 2);
|
||||
details.push(
|
||||
<div key="input" className="mb-2">
|
||||
<div className="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1">Input:</div>
|
||||
<div className="text-xs text-gray-700 dark:text-gray-300 bg-blue-50 dark:bg-blue-950/20 p-2 rounded border border-blue-200 dark:border-blue-800 max-h-20 overflow-auto">
|
||||
<pre className="whitespace-pre-wrap font-mono">{inputStr}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
details.push(
|
||||
<div key="input" className="mb-2">
|
||||
<div className="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1">Input:</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 bg-blue-50 dark:bg-blue-950/20 p-2 rounded border border-blue-200 dark:border-blue-800">
|
||||
[Unable to display input data]
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return details.length > 0 ? details : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-64 bg-card dark:bg-card rounded border-2 transition-all duration-200",
|
||||
config.borderColor,
|
||||
selected ? "ring-2 ring-blue-500 ring-offset-2" : "",
|
||||
isRunning ? config.glow : "shadow-sm",
|
||||
)}
|
||||
>
|
||||
{/* Small circular handles */}
|
||||
{!nodeData.isStartNode && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={targetPosition}
|
||||
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#643FB2" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!nodeData.isEndNode && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={sourcePosition}
|
||||
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#643FB2" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-3">
|
||||
{/* Header with icon and title */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 relative">
|
||||
{/* Icon container with dark background */}
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-900/90 dark:bg-gray-800/90 flex items-center justify-center">
|
||||
{nodeData.isStartNode ? (
|
||||
<Home className="w-5 h-5 text-[#643FB2] dark:text-[#8B5CF6]" />
|
||||
) : (
|
||||
<Workflow className="w-5 h-5 text-gray-300 dark:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
{/* Small status badge for running state */}
|
||||
{isRunning && (
|
||||
<div className={cn(
|
||||
"absolute -top-1 -right-1 w-3 h-3 rounded-full animate-pulse",
|
||||
config.badgeColor
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
|
||||
{nodeData.name || nodeData.executorId}
|
||||
</h3>
|
||||
{nodeData.executorType && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{nodeData.executorType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data details */}
|
||||
{hasData && (
|
||||
<div className="mt-3">
|
||||
{renderDataDetails()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Running animation overlay */}
|
||||
{isRunning && (
|
||||
<div className="absolute inset-0 rounded border-2 border-[#643FB2]/30 dark:border-[#8B5CF6]/30 animate-pulse pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ExecutorNode.displayName = "ExecutorNode";
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Workflow Feature - Exports
|
||||
*/
|
||||
|
||||
export { WorkflowView } from "./workflow-view";
|
||||
export { WorkflowDetailsModal } from "./workflow-details-modal";
|
||||
export { WorkflowFlow } from "./workflow-flow";
|
||||
export { WorkflowInputForm } from "./workflow-input-form";
|
||||
export { ExecutorNode } from "./executor-node";
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* WorkflowDetailsModal - Responsive grid-based modal for displaying workflow metadata
|
||||
*/
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Workflow as WorkflowIcon,
|
||||
Package,
|
||||
FolderOpen,
|
||||
Database,
|
||||
Globe,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
PlayCircle,
|
||||
} from "lucide-react";
|
||||
import type { WorkflowInfo } from "@/types";
|
||||
|
||||
interface WorkflowDetailsModalProps {
|
||||
workflow: WorkflowInfo;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface DetailCardProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 bg-card ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowDetailsModal({
|
||||
workflow,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: WorkflowDetailsModalProps) {
|
||||
const sourceIcon =
|
||||
workflow.source === "directory" ? (
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : workflow.source === "in_memory" ? (
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
);
|
||||
|
||||
const sourceLabel =
|
||||
workflow.source === "directory"
|
||||
? "Local"
|
||||
: workflow.source === "in_memory"
|
||||
? "In-Memory"
|
||||
: "Gallery";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 flex-shrink-0">
|
||||
<DialogTitle>Workflow Details</DialogTitle>
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1">
|
||||
{/* Header Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<WorkflowIcon className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{workflow.name || workflow.id}
|
||||
</h2>
|
||||
</div>
|
||||
{workflow.description && (
|
||||
<p className="text-muted-foreground">{workflow.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border mb-6" />
|
||||
|
||||
{/* Grid Layout for Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Start Executor */}
|
||||
<DetailCard
|
||||
title="Start Executor"
|
||||
icon={<PlayCircle className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
<div className="font-mono text-foreground">
|
||||
{workflow.start_executor_id}
|
||||
</div>
|
||||
</DetailCard>
|
||||
|
||||
{/* Source */}
|
||||
<DetailCard title="Source" icon={sourceIcon}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-foreground">{sourceLabel}</div>
|
||||
{workflow.module_path && (
|
||||
<div className="font-mono text-xs break-all">
|
||||
{workflow.module_path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailCard>
|
||||
|
||||
{/* Environment */}
|
||||
<DetailCard
|
||||
title="Environment"
|
||||
icon={
|
||||
workflow.has_env ? (
|
||||
<XCircle className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
)
|
||||
}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
workflow.has_env
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-green-600 dark:text-green-400"
|
||||
}
|
||||
>
|
||||
{workflow.has_env
|
||||
? "Requires environment variables"
|
||||
: "No environment variables required"}
|
||||
</div>
|
||||
</DetailCard>
|
||||
</div>
|
||||
|
||||
{/* Executors */}
|
||||
<DetailCard
|
||||
title={`Executors (${workflow.executors.length})`}
|
||||
icon={<Package className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
{workflow.executors.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{workflow.executors.map((executor, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded"
|
||||
>
|
||||
{executor}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">No executors configured</div>
|
||||
)}
|
||||
</DetailCard>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { useMemo, useCallback, useEffect, memo } from "react";
|
||||
import {
|
||||
MoreVertical,
|
||||
Map,
|
||||
Grid3X3,
|
||||
RotateCcw,
|
||||
Maximize,
|
||||
Shuffle,
|
||||
Zap,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
BackgroundVariant,
|
||||
type NodeTypes,
|
||||
type Node,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { ExecutorNode, type ExecutorNodeData } from "./executor-node";
|
||||
import {
|
||||
convertWorkflowDumpToNodes,
|
||||
convertWorkflowDumpToEdges,
|
||||
applyDagreLayout,
|
||||
processWorkflowEvents,
|
||||
updateNodesWithEvents,
|
||||
updateEdgesWithSequenceAnalysis,
|
||||
type NodeUpdate,
|
||||
} from "@/utils/workflow-utils";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
import type { Workflow } from "@/types/workflow";
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
executor: ExecutorNode,
|
||||
};
|
||||
|
||||
// ViewOptions panel component that renders inside ReactFlow
|
||||
function ViewOptionsPanel({
|
||||
workflowDump,
|
||||
onNodeSelect,
|
||||
viewOptions,
|
||||
onToggleViewOption,
|
||||
layoutDirection,
|
||||
onLayoutDirectionChange,
|
||||
}: {
|
||||
workflowDump?: Workflow;
|
||||
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
|
||||
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
|
||||
layoutDirection: "LR" | "TB";
|
||||
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
|
||||
}) {
|
||||
const { fitView, setViewport, setNodes } = useReactFlow();
|
||||
|
||||
const handleResetZoom = () => {
|
||||
setViewport({ x: 0, y: 0, zoom: 1 });
|
||||
};
|
||||
|
||||
const handleFitToScreen = () => {
|
||||
fitView({ padding: 0.2 });
|
||||
};
|
||||
|
||||
const handleAutoArrange = () => {
|
||||
if (!workflowDump) return;
|
||||
const currentNodes = convertWorkflowDumpToNodes(
|
||||
workflowDump,
|
||||
onNodeSelect,
|
||||
layoutDirection
|
||||
);
|
||||
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
|
||||
const layoutedNodes = applyDagreLayout(
|
||||
currentNodes,
|
||||
currentEdges,
|
||||
layoutDirection
|
||||
);
|
||||
setNodes(layoutedNodes);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 bg-white/90 backdrop-blur-sm border-gray-200 shadow-sm hover:bg-white dark:bg-gray-800/90 dark:border-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">View options</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => onToggleViewOption?.("showMinimap")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Map className="mr-2 h-4 w-4" />
|
||||
Show Minimap
|
||||
</div>
|
||||
<Checkbox checked={viewOptions.showMinimap} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => onToggleViewOption?.("showGrid")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Grid3X3 className="mr-2 h-4 w-4" />
|
||||
Show Grid
|
||||
</div>
|
||||
<Checkbox checked={viewOptions.showGrid} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => onToggleViewOption?.("animateRun")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Animate Run
|
||||
</div>
|
||||
<Checkbox checked={viewOptions.animateRun} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => {
|
||||
const newDirection = layoutDirection === "LR" ? "TB" : "LR";
|
||||
onLayoutDirectionChange?.(newDirection);
|
||||
// Re-apply layout with new direction
|
||||
if (workflowDump) {
|
||||
const currentNodes = convertWorkflowDumpToNodes(
|
||||
workflowDump,
|
||||
onNodeSelect,
|
||||
newDirection
|
||||
);
|
||||
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
|
||||
const layoutedNodes = applyDagreLayout(
|
||||
currentNodes,
|
||||
currentEdges,
|
||||
newDirection
|
||||
);
|
||||
setNodes(layoutedNodes);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<ArrowDown className="mr-2 h-4 w-4" />
|
||||
Vertical Layout
|
||||
</div>
|
||||
<Checkbox checked={layoutDirection === "TB"} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleResetZoom}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset Zoom
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleFitToScreen}>
|
||||
<Maximize className="mr-2 h-4 w-4" />
|
||||
Fit to Screen
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleAutoArrange}>
|
||||
<Shuffle className="mr-2 h-4 w-4" />
|
||||
Auto-arrange
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkflowFlowProps {
|
||||
workflowDump?: Workflow;
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
isStreaming: boolean;
|
||||
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
className?: string;
|
||||
viewOptions?: {
|
||||
showMinimap: boolean;
|
||||
showGrid: boolean;
|
||||
animateRun: boolean;
|
||||
};
|
||||
onToggleViewOption?: (
|
||||
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
|
||||
) => void;
|
||||
layoutDirection?: "LR" | "TB";
|
||||
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
|
||||
}
|
||||
|
||||
// Animation handler component that runs inside ReactFlow context
|
||||
function WorkflowAnimationHandler({
|
||||
nodes,
|
||||
nodeUpdates,
|
||||
isStreaming,
|
||||
animateRun,
|
||||
}: {
|
||||
nodes: Node<ExecutorNodeData>[];
|
||||
nodeUpdates: Record<string, NodeUpdate>;
|
||||
isStreaming: boolean;
|
||||
animateRun: boolean;
|
||||
}) {
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Smooth animation to center on running node when workflow starts/progresses
|
||||
useEffect(() => {
|
||||
if (!animateRun) return;
|
||||
|
||||
if (isStreaming) {
|
||||
// Zoom in on running nodes during execution
|
||||
const runningNodes = nodes.filter(
|
||||
(node) => node.data.state === "running"
|
||||
);
|
||||
if (runningNodes.length > 0) {
|
||||
const targetNode = runningNodes[0];
|
||||
|
||||
// Use fitView to smoothly focus on the running node with animation
|
||||
fitView({
|
||||
nodes: [targetNode],
|
||||
duration: 800,
|
||||
padding: 0.3,
|
||||
minZoom: 0.8,
|
||||
maxZoom: 1.5,
|
||||
});
|
||||
}
|
||||
} else if (nodes.length > 0) {
|
||||
// Zoom back out to show full workflow when execution completes
|
||||
fitView({
|
||||
duration: 1000,
|
||||
padding: 0.2,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodeUpdates, isStreaming, animateRun, nodes]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
|
||||
export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
workflowDump,
|
||||
events,
|
||||
isStreaming,
|
||||
onNodeSelect,
|
||||
className = "",
|
||||
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
|
||||
onToggleViewOption,
|
||||
layoutDirection = "LR",
|
||||
onLayoutDirectionChange,
|
||||
}: WorkflowFlowProps) {
|
||||
// Create initial nodes and edges from workflow dump
|
||||
const { initialNodes, initialEdges } = useMemo(() => {
|
||||
if (!workflowDump) {
|
||||
return { initialNodes: [], initialEdges: [] };
|
||||
}
|
||||
|
||||
const nodes = convertWorkflowDumpToNodes(
|
||||
workflowDump,
|
||||
onNodeSelect,
|
||||
layoutDirection
|
||||
);
|
||||
const edges = convertWorkflowDumpToEdges(workflowDump);
|
||||
|
||||
// Apply auto-layout if we have nodes and edges
|
||||
const layoutedNodes =
|
||||
nodes.length > 0
|
||||
? applyDagreLayout(nodes, edges, layoutDirection)
|
||||
: nodes;
|
||||
|
||||
return {
|
||||
initialNodes: layoutedNodes,
|
||||
initialEdges: edges,
|
||||
};
|
||||
}, [workflowDump, onNodeSelect, layoutDirection]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] =
|
||||
useNodesState<Node<ExecutorNodeData>>(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
|
||||
// Process events and update node/edge states
|
||||
const nodeUpdates = useMemo(() => {
|
||||
return processWorkflowEvents(events, workflowDump?.start_executor_id);
|
||||
}, [events, workflowDump?.start_executor_id]);
|
||||
|
||||
// Update nodes and edges with real-time state from events
|
||||
useMemo(() => {
|
||||
if (Object.keys(nodeUpdates).length > 0) {
|
||||
setNodes((currentNodes) =>
|
||||
updateNodesWithEvents(currentNodes, nodeUpdates)
|
||||
);
|
||||
} else if (events.length === 0) {
|
||||
// Reset all nodes to pending state when events are cleared
|
||||
setNodes((currentNodes) =>
|
||||
currentNodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
state: "pending" as const,
|
||||
outputData: undefined,
|
||||
error: undefined,
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [nodeUpdates, setNodes, events.length]);
|
||||
|
||||
// Update edges with sequence-based analysis (separate from nodeUpdates)
|
||||
useMemo(() => {
|
||||
if (events.length > 0) {
|
||||
setEdges((currentEdges) => {
|
||||
const updatedEdges = updateEdgesWithSequenceAnalysis(
|
||||
currentEdges,
|
||||
events
|
||||
);
|
||||
return updatedEdges;
|
||||
});
|
||||
} else {
|
||||
// Reset all edges to default state when events are cleared
|
||||
setEdges((currentEdges) =>
|
||||
currentEdges.map((edge) => ({
|
||||
...edge,
|
||||
animated: false,
|
||||
style: {
|
||||
stroke: "#6b7280", // Gray
|
||||
strokeWidth: 2,
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [events, setEdges]);
|
||||
|
||||
// Initialize nodes only when workflow structure changes (not on state updates)
|
||||
useEffect(() => {
|
||||
if (initialNodes.length > 0) {
|
||||
setNodes(initialNodes);
|
||||
setEdges(initialEdges);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workflowDump]); // Only re-initialize when workflowDump changes
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
|
||||
event.stopPropagation();
|
||||
onNodeSelect?.(node.data.executorId, node.data);
|
||||
},
|
||||
[onNodeSelect]
|
||||
);
|
||||
|
||||
if (!workflowDump) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${className}`}
|
||||
>
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="text-lg font-medium mb-2">No Workflow Data</div>
|
||||
<div className="text-sm">Workflow dump is not available.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (initialNodes.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${className}`}
|
||||
>
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="text-lg font-medium mb-2">No Executors Found</div>
|
||||
<div className="text-sm">
|
||||
Could not extract executors from workflow dump.
|
||||
</div>
|
||||
<details className="mt-2 text-xs">
|
||||
<summary className="cursor-pointer">Debug Info</summary>
|
||||
<pre className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-left overflow-auto">
|
||||
{JSON.stringify(workflowDump, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full w-full ${className}`}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={1.5}
|
||||
defaultEdgeOptions={{
|
||||
type: "default",
|
||||
animated: false,
|
||||
style: { stroke: "#6b7280", strokeWidth: 2 },
|
||||
}}
|
||||
nodesDraggable={!isStreaming} // Disable dragging during execution
|
||||
nodesConnectable={false} // Disable connecting nodes
|
||||
elementsSelectable={true}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
{viewOptions.showGrid && (
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={20}
|
||||
size={1}
|
||||
color="#e5e7eb"
|
||||
className="dark:opacity-30"
|
||||
/>
|
||||
)}
|
||||
<Controls
|
||||
position="bottom-left"
|
||||
showInteractive={false}
|
||||
style={{
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "3px",
|
||||
}}
|
||||
className="dark:!bg-gray-800/90 dark:!border-gray-600"
|
||||
/>
|
||||
{viewOptions.showMinimap && (
|
||||
<MiniMap
|
||||
nodeColor={(node: Node) => {
|
||||
const data = node.data as ExecutorNodeData;
|
||||
const state = data?.state;
|
||||
switch (state) {
|
||||
case "running":
|
||||
return "#643FB2";
|
||||
case "completed":
|
||||
return "#10b981";
|
||||
case "failed":
|
||||
return "#ef4444";
|
||||
case "cancelled":
|
||||
return "#f97316";
|
||||
default:
|
||||
return "#6b7280";
|
||||
}
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
position="bottom-right"
|
||||
style={{
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
className="dark:!bg-gray-800/90 dark:!border-gray-600"
|
||||
/>
|
||||
)}
|
||||
<WorkflowAnimationHandler
|
||||
nodes={nodes}
|
||||
nodeUpdates={nodeUpdates}
|
||||
isStreaming={isStreaming}
|
||||
animateRun={viewOptions.animateRun}
|
||||
/>
|
||||
<ViewOptionsPanel
|
||||
workflowDump={workflowDump}
|
||||
onNodeSelect={onNodeSelect}
|
||||
viewOptions={viewOptions}
|
||||
onToggleViewOption={onToggleViewOption}
|
||||
layoutDirection={layoutDirection}
|
||||
onLayoutDirectionChange={onLayoutDirectionChange}
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* CSS for custom edge animations and dark theme controls */}
|
||||
<style>{`
|
||||
.react-flow__edge-path {
|
||||
transition: stroke 0.3s ease, stroke-width 0.3s ease;
|
||||
}
|
||||
.react-flow__edge.animated .react-flow__edge-path {
|
||||
stroke-dasharray: 5 5;
|
||||
animation: dash 1s linear infinite;
|
||||
}
|
||||
@keyframes dash {
|
||||
0% { stroke-dashoffset: 0; }
|
||||
100% { stroke-dashoffset: -10; }
|
||||
}
|
||||
|
||||
/* Dark theme styles for React Flow controls */
|
||||
.dark .react-flow__controls {
|
||||
background-color: rgba(31, 41, 55, 0.9) !important;
|
||||
border-color: rgb(75, 85, 99) !important;
|
||||
}
|
||||
.dark .react-flow__controls-button {
|
||||
background-color: rgba(31, 41, 55, 0.9) !important;
|
||||
border-color: rgb(75, 85, 99) !important;
|
||||
color: rgb(229, 231, 235) !important;
|
||||
}
|
||||
.dark .react-flow__controls-button:hover {
|
||||
background-color: rgba(55, 65, 81, 0.9) !important;
|
||||
color: rgb(255, 255, 255) !important;
|
||||
}
|
||||
.dark .react-flow__controls-button svg {
|
||||
fill: rgb(229, 231, 235) !important;
|
||||
}
|
||||
.dark .react-flow__controls-button:hover svg {
|
||||
fill: rgb(255, 255, 255) !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
+707
@@ -0,0 +1,707 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Send, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { JSONSchemaProperty } from "@/types";
|
||||
|
||||
interface FormFieldProps {
|
||||
name: string;
|
||||
schema: JSONSchemaProperty;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
function FormField({ name, schema, value, onChange, isRequired = false }: FormFieldProps) {
|
||||
const { type, description, enum: enumValues, default: defaultValue } = schema;
|
||||
|
||||
// For text/message/content fields, treat as textarea for better UX
|
||||
const isTextContentField = ['text', 'message', 'content', 'query', 'prompt'].includes(name.toLowerCase());
|
||||
|
||||
// Determine if this field should span full width
|
||||
// Only span full if it's a textarea or has very long description
|
||||
const shouldSpanFullWidth =
|
||||
schema.format === "textarea" ||
|
||||
isTextContentField || // text/message fields span full width
|
||||
(description && description.length > 150);
|
||||
|
||||
const shouldSpanTwoColumns =
|
||||
schema.format === "textarea" ||
|
||||
isTextContentField ||
|
||||
(description && description.length > 80) ||
|
||||
type === "array"; // Arrays might need more space for comma-separated values
|
||||
|
||||
const fieldContent = (() => {
|
||||
// Handle different field types based on JSON Schema
|
||||
switch (type) {
|
||||
case "string":
|
||||
if (enumValues) {
|
||||
// Enum select
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Select
|
||||
value={
|
||||
typeof value === "string" && value
|
||||
? value
|
||||
: typeof defaultValue === "string"
|
||||
? defaultValue
|
||||
: enumValues[0]
|
||||
}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select ${name}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enumValues.map((option: string) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (
|
||||
schema.format === "textarea" ||
|
||||
isTextContentField ||
|
||||
(description && description.length > 100)
|
||||
) {
|
||||
// Multi-line text (including text/message/content fields)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
typeof defaultValue === "string"
|
||||
? defaultValue
|
||||
: `Enter ${name}`
|
||||
}
|
||||
rows={isTextContentField ? 4 : 2}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Single-line text
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type="text"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
typeof defaultValue === "string"
|
||||
? defaultValue
|
||||
: `Enter ${name}`
|
||||
}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type="number"
|
||||
value={typeof value === "number" ? value : ""}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
onChange(isNaN(val) ? "" : val);
|
||||
}}
|
||||
placeholder={
|
||||
typeof defaultValue === "number"
|
||||
? defaultValue.toString()
|
||||
: `Enter ${name}`
|
||||
}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={name}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
/>
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "array":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={
|
||||
Array.isArray(value)
|
||||
? value.join(", ")
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const arrayValue = e.target.value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
onChange(arrayValue);
|
||||
}}
|
||||
placeholder="Enter items separated by commas"
|
||||
rows={2}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "object":
|
||||
default:
|
||||
// For complex objects or unknown types, use JSON textarea
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={
|
||||
typeof value === "object" && value !== null
|
||||
? JSON.stringify(value, null, 2)
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
onChange(parsed);
|
||||
} catch {
|
||||
// Keep raw string value if not valid JSON
|
||||
onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
// Return the field with appropriate grid column spanning
|
||||
const getColumnSpan = () => {
|
||||
if (shouldSpanFullWidth) return "md:col-span-2 lg:col-span-3 xl:col-span-4";
|
||||
if (shouldSpanTwoColumns) return "xl:col-span-2";
|
||||
return "";
|
||||
};
|
||||
|
||||
return <div className={getColumnSpan()}>{fieldContent}</div>;
|
||||
}
|
||||
|
||||
interface WorkflowInputFormProps {
|
||||
inputSchema: JSONSchemaProperty;
|
||||
inputTypeName: string;
|
||||
onSubmit: (formData: unknown) => void;
|
||||
isSubmitting?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WorkflowInputForm({
|
||||
inputSchema,
|
||||
inputTypeName,
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
className,
|
||||
}: WorkflowInputFormProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
|
||||
|
||||
// Check if we're in embedded mode (being used inside another modal)
|
||||
const isEmbedded = className?.includes('embedded');
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Determine field info
|
||||
const properties = inputSchema.properties || {};
|
||||
const fieldNames = Object.keys(properties);
|
||||
const requiredFields = inputSchema.required || [];
|
||||
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
|
||||
|
||||
// Plan D: Separate required and optional fields first
|
||||
const allOptionalFieldNames = fieldNames.filter(name => !requiredFields.includes(name));
|
||||
|
||||
// Detect ChatMessage-like pattern
|
||||
const isChatMessageLike =
|
||||
requiredFields.includes('role') &&
|
||||
allOptionalFieldNames.some(f => ['text', 'message', 'content'].includes(f)) &&
|
||||
properties['role']?.type === 'string';
|
||||
|
||||
// For ChatMessage: hide 'role' field (will be auto-filled)
|
||||
const requiredFieldNames = fieldNames.filter(name =>
|
||||
requiredFields.includes(name) && !(isChatMessageLike && name === 'role')
|
||||
);
|
||||
const optionalFieldNames = allOptionalFieldNames;
|
||||
|
||||
// For ChatMessage: prioritize text/message/content field to show first
|
||||
const sortedOptionalFields = isChatMessageLike
|
||||
? [...optionalFieldNames].sort((a, b) => {
|
||||
const priority = (name: string) =>
|
||||
['text', 'message', 'content'].includes(name) ? 1 : 0;
|
||||
return priority(b) - priority(a);
|
||||
})
|
||||
: optionalFieldNames;
|
||||
|
||||
// Always show ALL required fields + fill to minimum visible with optional fields
|
||||
// For ChatMessage: show only 1 optional field (text)
|
||||
const MIN_VISIBLE_FIELDS = isChatMessageLike ? 1 : 6;
|
||||
const visibleOptionalCount = Math.max(0, MIN_VISIBLE_FIELDS - requiredFieldNames.length);
|
||||
const visibleOptionalFields = sortedOptionalFields.slice(0, visibleOptionalCount);
|
||||
const collapsedOptionalFields = sortedOptionalFields.slice(visibleOptionalCount);
|
||||
|
||||
const hasCollapsedFields = collapsedOptionalFields.length > 0;
|
||||
const hasRequiredFields = requiredFieldNames.length > 0;
|
||||
|
||||
// Update canSubmit to check required fields properly
|
||||
// For ChatMessage: role is auto-filled, so it's always valid
|
||||
const canSubmit = isSimpleInput
|
||||
? formData.value !== undefined && formData.value !== ""
|
||||
: requiredFields.length > 0
|
||||
? requiredFields.every(fieldName => {
|
||||
// Auto-filled fields are always valid
|
||||
if (isChatMessageLike && fieldName === 'role' && formData['role'] === 'user') {
|
||||
return true;
|
||||
}
|
||||
return formData[fieldName] !== undefined && formData[fieldName] !== "";
|
||||
})
|
||||
: Object.keys(formData).length > 0;
|
||||
|
||||
// Initialize form data
|
||||
useEffect(() => {
|
||||
if (inputSchema.type === "string") {
|
||||
setFormData({ value: inputSchema.default || "" });
|
||||
} else if (inputSchema.type === "object" && inputSchema.properties) {
|
||||
const initialData: Record<string, unknown> = {};
|
||||
Object.entries(inputSchema.properties).forEach(([key, fieldSchema]) => {
|
||||
if (fieldSchema.default !== undefined) {
|
||||
initialData[key] = fieldSchema.default;
|
||||
} else if (fieldSchema.enum && fieldSchema.enum.length > 0) {
|
||||
initialData[key] = fieldSchema.enum[0];
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-fill role="user" for ChatMessage-like inputs
|
||||
if (isChatMessageLike && !initialData['role']) {
|
||||
initialData['role'] = 'user';
|
||||
}
|
||||
|
||||
setFormData(initialData);
|
||||
}
|
||||
}, [inputSchema, isChatMessageLike]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
// Simplified submission logic
|
||||
if (inputSchema.type === "string") {
|
||||
onSubmit({ input: formData.value || "" });
|
||||
} else if (inputSchema.type === "object") {
|
||||
const properties = inputSchema.properties || {};
|
||||
const fieldNames = Object.keys(properties);
|
||||
|
||||
if (fieldNames.length === 1) {
|
||||
const fieldName = fieldNames[0];
|
||||
onSubmit({ [fieldName]: formData[fieldName] || "" });
|
||||
} else {
|
||||
// Filter out empty optional fields before submission
|
||||
const filteredData: Record<string, unknown> = {};
|
||||
Object.keys(formData).forEach(key => {
|
||||
const value = formData[key];
|
||||
// Include if: 1) required field, OR 2) has non-empty value
|
||||
if (requiredFields.includes(key) || (value !== undefined && value !== "" && value !== null)) {
|
||||
filteredData[key] = value;
|
||||
}
|
||||
});
|
||||
onSubmit(filteredData);
|
||||
}
|
||||
} else {
|
||||
onSubmit(formData);
|
||||
}
|
||||
|
||||
// Only close modal if not embedded
|
||||
if (!isEmbedded) {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const updateField = (fieldName: string, value: unknown) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// If embedded, just show the form directly
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Simple input */}
|
||||
{isSimpleInput && (
|
||||
<FormField
|
||||
name="Input"
|
||||
schema={inputSchema}
|
||||
value={formData.value}
|
||||
onChange={(value) => updateField("value", value)}
|
||||
isRequired={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Complex form fields - Plan D: Required + Optional separation */}
|
||||
{!isSimpleInput && (
|
||||
<>
|
||||
{/* Required fields section */}
|
||||
{requiredFieldNames.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={true}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Separator between required and optional (only if both exist) */}
|
||||
{hasRequiredFields && optionalFieldNames.length > 0 && (
|
||||
<div className="sm:col-span-2 border-t border-border my-2"></div>
|
||||
)}
|
||||
|
||||
{/* Visible optional fields */}
|
||||
{visibleOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Collapsed optional fields toggle */}
|
||||
{hasCollapsedFields && (
|
||||
<div className="sm:col-span-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
|
||||
className="w-full justify-center gap-2"
|
||||
>
|
||||
{showAdvancedFields ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
Hide {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
Show {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed optional fields - only show when toggled */}
|
||||
{showAdvancedFields && collapsedOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4 justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !canSubmit}
|
||||
size="default"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{loading ? "Running..." : "Run Workflow"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sidebar Form Component */}
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
{/* Header with Run Button */}
|
||||
<div className="border-b border-border px-4 py-3 bg-muted">
|
||||
<CardTitle className="text-sm mb-3">Run Workflow</CardTitle>
|
||||
|
||||
{/* Run Button - Opens Modal */}
|
||||
<Button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
disabled={isSubmitting}
|
||||
className="w-full"
|
||||
size="default"
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{isSubmitting ? "Running..." : "Run Workflow"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<strong>Input Type:</strong>{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded">
|
||||
{inputTypeName}
|
||||
</code>
|
||||
{inputSchema.type === "object" && inputSchema.properties && (
|
||||
<span className="ml-2">
|
||||
({Object.keys(inputSchema.properties).length} field
|
||||
{Object.keys(inputSchema.properties).length !== 1 ? "s" : ""})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Click "Run Workflow" to configure inputs and execute
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal with the actual form */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="w-full max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Run Workflow</DialogTitle>
|
||||
<DialogClose onClose={() => setIsModalOpen(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
{/* Form Info */}
|
||||
<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>
|
||||
<code className="bg-muted px-3 py-1 text-xs font-mono">
|
||||
{inputTypeName}
|
||||
</code>
|
||||
{inputSchema.type === "object" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fieldNames.length} field
|
||||
{fieldNames.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Form Content */}
|
||||
<div className="px-8 py-6 overflow-y-auto flex-1 min-h-0">
|
||||
<form id="workflow-modal-form" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 md:gap-8 max-w-none">
|
||||
{/* Simple input */}
|
||||
{isSimpleInput && (
|
||||
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||
<FormField
|
||||
name="Input"
|
||||
schema={inputSchema}
|
||||
value={formData.value}
|
||||
onChange={(value) => updateField("value", value)}
|
||||
isRequired={false}
|
||||
/>
|
||||
{inputSchema.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{inputSchema.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Complex form fields - Plan D: Required + Optional separation */}
|
||||
{!isSimpleInput && (
|
||||
<>
|
||||
{/* Required fields section */}
|
||||
{requiredFieldNames.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={true}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Separator between required and optional (only if both exist) */}
|
||||
{hasRequiredFields && optionalFieldNames.length > 0 && (
|
||||
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||
<div className="border-t border-border"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visible optional fields */}
|
||||
{visibleOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Collapsed optional fields toggle */}
|
||||
{hasCollapsedFields && (
|
||||
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
|
||||
className="w-full justify-center gap-2"
|
||||
>
|
||||
{showAdvancedFields ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
Hide {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
Show {collapsedOptionalFields.length} optional field{collapsedOptionalFields.length !== 1 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed optional fields - only show when toggled */}
|
||||
{showAdvancedFields && collapsedOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-8 py-4 border-t flex-shrink-0">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="workflow-modal-form"
|
||||
disabled={loading || !canSubmit}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{loading ? "Running..." : "Run Workflow"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user