mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add DevUI to AgentFramework (#781)
* add initial backend service code for devui * add tests * add frontendcode * ui updates * update readme * ui updates and tweaks * update ui bundle * improve ui, add react flow base * add react flow ui, fix background * update ui, fix introspection bug * update readme * update ui build * add support for multimodal input - both backend and frontend * update ui build * refactor as main framework package * backend and tests refactor * ui build update * ui build update and refactor * update pyproject.toml, update uv.lock * update ui build * ui update to fit oai responses types * add backend updat and readme update * mypy and other fixes * add intial dev guide * update ui and fix workflow bug * update ui build, add thread support * type fixes * update workflow view * update uv.lock * fix workflow iport errors * lint and other fixes * mypy fixes * minor update * update ui build * refactor to use oai dependencies directly, update examples to samples, improve typing * readme update * update ui and ui build * fix workflow pyright error * update ui, fix issues with run workflow placement, miniamp menu, etc * make samples integrate serve --------- Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
adb6dcd2af
commit
1ef24d3e91
@@ -0,0 +1,277 @@
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Play,
|
||||
Flag,
|
||||
} 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;
|
||||
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
}
|
||||
|
||||
const getExecutorStateConfig = (state: ExecutorState) => {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return {
|
||||
icon: Loader2,
|
||||
text: "Running",
|
||||
borderColor: "border-blue-500 dark:border-blue-400",
|
||||
iconColor: "text-blue-600 dark:text-blue-400",
|
||||
statusColor: "bg-blue-500 dark:bg-blue-400",
|
||||
animate: "animate-spin",
|
||||
glow: "shadow-lg shadow-blue-500/20",
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
text: "Completed",
|
||||
borderColor: "border-green-500 dark:border-green-400",
|
||||
iconColor: "text-green-600 dark:text-green-400",
|
||||
statusColor: "bg-green-500 dark:bg-green-400",
|
||||
animate: "",
|
||||
glow: "shadow-lg shadow-green-500/20",
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
icon: XCircle,
|
||||
text: "Failed",
|
||||
borderColor: "border-red-500 dark:border-red-400",
|
||||
iconColor: "text-red-600 dark:text-red-400",
|
||||
statusColor: "bg-red-500 dark:bg-red-400",
|
||||
animate: "",
|
||||
glow: "shadow-lg shadow-red-500/20",
|
||||
};
|
||||
case "cancelled":
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
text: "Cancelled",
|
||||
borderColor: "border-orange-500 dark:border-orange-400",
|
||||
iconColor: "text-orange-600 dark:text-orange-400",
|
||||
statusColor: "bg-orange-500 dark:bg-orange-400",
|
||||
animate: "",
|
||||
glow: "shadow-lg shadow-orange-500/20",
|
||||
};
|
||||
case "pending":
|
||||
default:
|
||||
return {
|
||||
icon: Clock,
|
||||
text: "Pending",
|
||||
borderColor: "border-gray-300 dark:border-gray-600",
|
||||
iconColor: "text-gray-500 dark:text-gray-400",
|
||||
statusColor: "bg-gray-400 dark:bg-gray-500",
|
||||
animate: "",
|
||||
glow: "",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
const nodeData = data as ExecutorNodeData;
|
||||
const config = getExecutorStateConfig(nodeData.state);
|
||||
const IconComponent = config.icon;
|
||||
|
||||
const hasData = nodeData.inputData || nodeData.outputData || nodeData.error;
|
||||
const isRunning = nodeData.state === "running";
|
||||
|
||||
// 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",
|
||||
)}
|
||||
>
|
||||
{/* Start/End Badge */}
|
||||
{(nodeData.isStartNode || nodeData.isEndNode) && (
|
||||
<div className={cn(
|
||||
"absolute -top-6 left-2 px-2 py-1 rounded-t text-xs font-medium text-white flex items-center gap-1 z-10 shadow-sm",
|
||||
nodeData.isStartNode ? "bg-green-600" : "bg-red-600"
|
||||
)}>
|
||||
{nodeData.isStartNode ? (
|
||||
<>
|
||||
<Play className="w-3 h-3" />
|
||||
START
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Flag className="w-3 h-3" />
|
||||
END
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Only show target handle if not a start node */}
|
||||
{!nodeData.isStartNode && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!w-2 !h-5 !rounded-r-sm !-ml-1 !border-0 transition-colors"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#3b82f6" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#9ca3af"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Only show source handle if not an end node */}
|
||||
{!nodeData.isEndNode && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!w-2 !h-5 !rounded-l-sm !-mr-1 !border-0 transition-colors"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#3b82f6" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#9ca3af"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-4">
|
||||
{/* Header with icon and title */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<IconComponent
|
||||
className={cn("w-5 h-5", config.iconColor, config.animate)}
|
||||
/>
|
||||
</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">
|
||||
{nodeData.executorType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* State indicator */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
config.statusColor,
|
||||
config.animate
|
||||
)}
|
||||
/>
|
||||
<span className={cn("text-xs font-medium", config.iconColor)}>
|
||||
{config.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Data details */}
|
||||
{hasData && (
|
||||
<div className="mt-3">
|
||||
{renderDataDetails()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Running animation overlay */}
|
||||
{isRunning && (
|
||||
<div className="absolute inset-0 rounded border-2 border-blue-500/30 dark:border-blue-400/30 animate-pulse pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ExecutorNode.displayName = "ExecutorNode";
|
||||
@@ -0,0 +1,463 @@
|
||||
import { useMemo, useCallback, useEffect } from "react";
|
||||
import {
|
||||
MoreVertical,
|
||||
Map,
|
||||
Grid3X3,
|
||||
RotateCcw,
|
||||
Maximize,
|
||||
Shuffle,
|
||||
Zap,
|
||||
} 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,
|
||||
}: {
|
||||
workflowDump?: Workflow;
|
||||
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
|
||||
onToggleViewOption?: (key: keyof typeof viewOptions) => 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);
|
||||
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
|
||||
const layoutedNodes = applyDagreLayout(currentNodes, currentEdges, "LR");
|
||||
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 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;
|
||||
}
|
||||
|
||||
// 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 function WorkflowFlow({
|
||||
workflowDump,
|
||||
events,
|
||||
isStreaming,
|
||||
onNodeSelect,
|
||||
className = "",
|
||||
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
|
||||
onToggleViewOption,
|
||||
}: WorkflowFlowProps) {
|
||||
// Create initial nodes and edges from workflow dump
|
||||
const { initialNodes, initialEdges } = useMemo(() => {
|
||||
if (!workflowDump) {
|
||||
return { initialNodes: [], initialEdges: [] };
|
||||
}
|
||||
|
||||
const nodes = convertWorkflowDumpToNodes(workflowDump, onNodeSelect);
|
||||
const edges = convertWorkflowDumpToEdges(workflowDump);
|
||||
|
||||
// Apply auto-layout if we have nodes and edges
|
||||
const layoutedNodes =
|
||||
nodes.length > 0 ? applyDagreLayout(nodes, edges, "LR") : nodes;
|
||||
|
||||
return {
|
||||
initialNodes: layoutedNodes,
|
||||
initialEdges: edges,
|
||||
};
|
||||
}, [workflowDump, onNodeSelect]);
|
||||
|
||||
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);
|
||||
}, [events]);
|
||||
|
||||
// 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 "#3b82f6";
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
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 } 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;
|
||||
}
|
||||
|
||||
function FormField({ name, schema, value, onChange }: FormFieldProps) {
|
||||
const { type, description, enum: enumValues, default: defaultValue } = schema;
|
||||
|
||||
// Determine if this field should span full width
|
||||
const shouldSpanFullWidth =
|
||||
schema.format === "textarea" ||
|
||||
(description && description.length > 100) ||
|
||||
type === "object" ||
|
||||
type === "array";
|
||||
|
||||
const shouldSpanTwoColumns =
|
||||
type === "object" ||
|
||||
schema.format === "textarea" ||
|
||||
(description && description.length > 50);
|
||||
|
||||
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}</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" ||
|
||||
(description && description.length > 100)
|
||||
) {
|
||||
// Multi-line text
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>{name}</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
typeof defaultValue === "string"
|
||||
? defaultValue
|
||||
: `Enter ${name}`
|
||||
}
|
||||
rows={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}</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}</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}</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}</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}</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-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);
|
||||
|
||||
// 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 isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
|
||||
const primaryField = isSimpleInput ? "value" : fieldNames[0];
|
||||
const canSubmit = primaryField
|
||||
? formData[primaryField] !== undefined && formData[primaryField] !== ""
|
||||
: 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];
|
||||
}
|
||||
});
|
||||
setFormData(initialData);
|
||||
}
|
||||
}, [inputSchema]);
|
||||
|
||||
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 {
|
||||
onSubmit(formData);
|
||||
}
|
||||
} 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 gap-4">
|
||||
{/* Simple input */}
|
||||
{isSimpleInput && primaryField && (
|
||||
<FormField
|
||||
name="Input"
|
||||
schema={inputSchema}
|
||||
value={formData.value}
|
||||
onChange={(value) => updateField("value", value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Complex form fields */}
|
||||
{!isSimpleInput && (
|
||||
<>
|
||||
{fieldNames.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</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-3 xl:grid-cols-4 gap-8 max-w-none">
|
||||
{/* Simple input */}
|
||||
{isSimpleInput && primaryField && (
|
||||
<div className="md:col-span-3 xl:col-span-4">
|
||||
<FormField
|
||||
name="Input"
|
||||
schema={inputSchema}
|
||||
value={formData.value}
|
||||
onChange={(value) => updateField("value", value)}
|
||||
/>
|
||||
{inputSchema.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{inputSchema.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Complex form fields - Show all */}
|
||||
{!isSimpleInput && (
|
||||
<>
|
||||
{fieldNames.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={formData[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
/**
|
||||
* WorkflowView - Complete workflow execution interface
|
||||
* Features: Workflow visualization, input forms, execution monitoring
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Play,
|
||||
Settings,
|
||||
RotateCcw,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { LoadingState } from "@/components/ui/loading-state";
|
||||
import { WorkflowInputForm } from "@/components/workflow/workflow-input-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { WorkflowFlow } from "@/components/workflow/workflow-flow";
|
||||
import { useWorkflowEventCorrelation } from "@/hooks/useWorkflowEventCorrelation";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type {
|
||||
WorkflowInfo,
|
||||
ExtendedResponseStreamEvent,
|
||||
JSONSchemaProperty,
|
||||
} from "@/types";
|
||||
import type { ExecutorNodeData } from "@/components/workflow/executor-node";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
|
||||
|
||||
// Smart Run Workflow Button Component
|
||||
interface RunWorkflowButtonProps {
|
||||
inputSchema: JSONSchemaProperty;
|
||||
onRun: (data: Record<string, unknown>) => void;
|
||||
isSubmitting: boolean;
|
||||
workflowState: "ready" | "running" | "completed" | "error";
|
||||
executorHistory: Array<{
|
||||
executorId: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
status: string;
|
||||
}>;
|
||||
workflowError?: string;
|
||||
}
|
||||
|
||||
function RunWorkflowButton({
|
||||
inputSchema,
|
||||
onRun,
|
||||
isSubmitting,
|
||||
workflowState,
|
||||
}: 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(() => {
|
||||
if (!inputSchema)
|
||||
return { needsInput: false, hasDefaults: false, fieldCount: 0 };
|
||||
|
||||
if (inputSchema.type === "string") {
|
||||
return {
|
||||
needsInput: !inputSchema.default,
|
||||
hasDefaults: !!inputSchema.default,
|
||||
fieldCount: 1,
|
||||
canRunDirectly: !!inputSchema.default,
|
||||
};
|
||||
}
|
||||
|
||||
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: fieldsWithDefaults.length === fields.length, // All fields have defaults
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
needsInput: false,
|
||||
hasDefaults: false,
|
||||
fieldCount: 0,
|
||||
canRunDirectly: true,
|
||||
};
|
||||
}, [inputSchema]);
|
||||
|
||||
const handleDirectRun = () => {
|
||||
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 getButtonText = () => {
|
||||
if (workflowState === "running") return "Running...";
|
||||
if (workflowState === "completed") return "Run Again";
|
||||
if (workflowState === "error") return "Retry";
|
||||
if (inputAnalysis.fieldCount === 0) return "Run Workflow";
|
||||
if (inputAnalysis.canRunDirectly) return "Run Workflow";
|
||||
return "Configure & Run";
|
||||
};
|
||||
|
||||
const getButtonIcon = () => {
|
||||
if (workflowState === "running")
|
||||
return <Loader2 className="w-4 h-4 animate-spin" />;
|
||||
if (workflowState === "error") return <RotateCcw className="w-4 h-4" />;
|
||||
if (inputAnalysis.needsInput && !inputAnalysis.canRunDirectly)
|
||||
return <Settings className="w-4 h-4" />;
|
||||
return <Play className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
const isButtonDisabled = workflowState === "running";
|
||||
const buttonVariant = workflowState === "error" ? "destructive" : "primary";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
{/* Split button group using proper Button components */}
|
||||
<div className="flex">
|
||||
{/* Main button */}
|
||||
<Button
|
||||
onClick={handleDirectRun}
|
||||
disabled={isButtonDisabled}
|
||||
variant={
|
||||
buttonVariant === "destructive" ? "destructive" : "default"
|
||||
}
|
||||
size="default"
|
||||
className={inputAnalysis.needsInput ? "rounded-r-none" : ""}
|
||||
>
|
||||
{getButtonIcon()}
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
|
||||
{/* Dropdown button - only show if inputs are available */}
|
||||
{inputAnalysis.needsInput && (
|
||||
<Button
|
||||
onClick={() => setShowModal(true)}
|
||||
disabled={isButtonDisabled}
|
||||
variant={
|
||||
buttonVariant === "destructive" ? "destructive" : "default"
|
||||
}
|
||||
size="icon"
|
||||
className="rounded-l-none border-l-0 w-9"
|
||||
title="Configure inputs"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal with proper Dialog component - matching WorkflowInputForm structure */}
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<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>Configure Workflow Inputs</DialogTitle>
|
||||
<DialogClose onClose={() => setShowModal(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
{/* Form Info - matching the structure from WorkflowInputForm */}
|
||||
<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">
|
||||
{inputAnalysis.fieldCount === 0
|
||||
? "No Input"
|
||||
: inputSchema.type === "string"
|
||||
? "String"
|
||||
: "Object"}
|
||||
</code>
|
||||
{inputSchema.type === "object" && inputSchema.properties && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{Object.keys(inputSchema.properties).length} field
|
||||
{Object.keys(inputSchema.properties).length !== 1
|
||||
? "s"
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Form Content - matching padding and structure */}
|
||||
<div className="px-8 py-6 overflow-y-auto flex-1 min-h-0">
|
||||
<WorkflowInputForm
|
||||
inputSchema={inputSchema}
|
||||
inputTypeName="Input"
|
||||
onSubmit={(data) => {
|
||||
onRun(data as Record<string, unknown>);
|
||||
setShowModal(false);
|
||||
}}
|
||||
isSubmitting={isSubmitting}
|
||||
className="embedded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer - no additional buttons needed since WorkflowInputForm embedded mode has its own */}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkflowViewProps {
|
||||
selectedWorkflow: WorkflowInfo;
|
||||
onDebugEvent: DebugEventHandler;
|
||||
}
|
||||
|
||||
export function WorkflowView({
|
||||
selectedWorkflow,
|
||||
onDebugEvent,
|
||||
}: WorkflowViewProps) {
|
||||
const [workflowInfo, setWorkflowInfo] = useState<WorkflowInfo | null>(null);
|
||||
const [workflowLoading, setWorkflowLoading] = useState(false);
|
||||
const [openAIEvents, setOpenAIEvents] = useState<
|
||||
ExtendedResponseStreamEvent[]
|
||||
>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [selectedExecutor, setSelectedExecutor] =
|
||||
useState<ExecutorNodeData | null>(null);
|
||||
const [workflowResult, setWorkflowResult] = useState<string>("");
|
||||
const [workflowError, setWorkflowError] = useState<string>("");
|
||||
const accumulatedText = useRef<string>("");
|
||||
|
||||
// Panel resize state
|
||||
const [bottomPanelHeight, setBottomPanelHeight] = useState(() => {
|
||||
const savedHeight = localStorage.getItem("workflowBottomPanelHeight");
|
||||
return savedHeight ? parseInt(savedHeight, 10) : 300;
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
// View options state
|
||||
const [viewOptions, setViewOptions] = useState(() => {
|
||||
const saved = localStorage.getItem("workflowViewOptions");
|
||||
return saved
|
||||
? JSON.parse(saved)
|
||||
: {
|
||||
showMinimap: false,
|
||||
showGrid: true,
|
||||
animateRun: false,
|
||||
};
|
||||
});
|
||||
|
||||
const { selectExecutor, getExecutorData } = useWorkflowEventCorrelation(
|
||||
openAIEvents,
|
||||
isStreaming
|
||||
);
|
||||
|
||||
// Save view options to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("workflowViewOptions", JSON.stringify(viewOptions));
|
||||
}, [viewOptions]);
|
||||
|
||||
// View option handlers
|
||||
const toggleViewOption = (key: keyof typeof viewOptions) => {
|
||||
setViewOptions((prev: typeof viewOptions) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
// Load workflow info when selectedWorkflow changes
|
||||
useEffect(() => {
|
||||
const loadWorkflowInfo = async () => {
|
||||
if (selectedWorkflow.type !== "workflow") return;
|
||||
|
||||
setWorkflowLoading(true);
|
||||
try {
|
||||
const info = await apiClient.getWorkflowInfo(selectedWorkflow.id);
|
||||
setWorkflowInfo(info);
|
||||
} catch (error) {
|
||||
console.error("Failed to load workflow info:", error);
|
||||
setWorkflowInfo(null);
|
||||
} finally {
|
||||
setWorkflowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear state when workflow changes
|
||||
setOpenAIEvents([]);
|
||||
setIsStreaming(false);
|
||||
setSelectedExecutor(null);
|
||||
setWorkflowResult("");
|
||||
setWorkflowError("");
|
||||
accumulatedText.current = "";
|
||||
|
||||
loadWorkflowInfo();
|
||||
}, [selectedWorkflow.id, selectedWorkflow.type]);
|
||||
|
||||
const handleNodeSelect = (executorId: string, data: ExecutorNodeData) => {
|
||||
setSelectedExecutor(data);
|
||||
selectExecutor(executorId);
|
||||
};
|
||||
|
||||
// Extract workflow events from OpenAI events for executor tracking
|
||||
const workflowEvents = useMemo(() => {
|
||||
return openAIEvents.filter(
|
||||
(event) => event.type === "response.workflow_event.complete"
|
||||
);
|
||||
}, [openAIEvents]);
|
||||
|
||||
// Extract executor history from workflow events
|
||||
const executorHistory = useMemo(() => {
|
||||
return workflowEvents.map((event) => {
|
||||
if ("data" in event && event.data && typeof event.data === "object") {
|
||||
const data = event.data as Record<string, unknown>;
|
||||
return {
|
||||
executorId: String(data.executor_id || "unknown"),
|
||||
message: String(data.event_type || "Processing"),
|
||||
timestamp: String(data.timestamp || new Date().toISOString()),
|
||||
status: String(data.event_type || "").includes("Completed")
|
||||
? ("completed" as const)
|
||||
: String(data.event_type || "").includes("Error")
|
||||
? ("error" as const)
|
||||
: ("running" as const),
|
||||
};
|
||||
}
|
||||
return {
|
||||
executorId: "unknown",
|
||||
message: "Processing",
|
||||
timestamp: new Date().toISOString(),
|
||||
status: "running" as const,
|
||||
};
|
||||
});
|
||||
}, [workflowEvents]);
|
||||
|
||||
// Track active executors
|
||||
const activeExecutors = useMemo(() => {
|
||||
if (!isStreaming) return [];
|
||||
const recent = executorHistory
|
||||
.filter((h) => h.status === "running")
|
||||
.slice(-2);
|
||||
return recent.map((h) => h.executorId);
|
||||
}, [executorHistory, isStreaming]);
|
||||
|
||||
// Save panel height to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem(
|
||||
"workflowBottomPanelHeight",
|
||||
bottomPanelHeight.toString()
|
||||
);
|
||||
}, [bottomPanelHeight]);
|
||||
|
||||
// Handle resize drag
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
|
||||
const startY = e.clientY;
|
||||
const startHeight = bottomPanelHeight;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaY = startY - e.clientY;
|
||||
const newHeight = Math.max(
|
||||
200,
|
||||
Math.min(window.innerHeight * 0.6, startHeight + deltaY)
|
||||
);
|
||||
setBottomPanelHeight(newHeight);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[bottomPanelHeight]
|
||||
);
|
||||
|
||||
// Handle workflow data sending (structured input)
|
||||
const handleSendWorkflowData = useCallback(
|
||||
async (inputData: Record<string, unknown>) => {
|
||||
if (!selectedWorkflow || selectedWorkflow.type !== "workflow") return;
|
||||
|
||||
setIsStreaming(true);
|
||||
setOpenAIEvents([]); // Clear previous OpenAI events for new execution
|
||||
setWorkflowResult("");
|
||||
setWorkflowError("");
|
||||
accumulatedText.current = "";
|
||||
|
||||
// Clear debug panel events for new workflow run
|
||||
onDebugEvent("clear");
|
||||
|
||||
try {
|
||||
const request = { input_data: inputData };
|
||||
|
||||
// Use OpenAI-compatible API streaming - direct event handling
|
||||
const streamGenerator = apiClient.streamWorkflowExecutionOpenAI(
|
||||
selectedWorkflow.id,
|
||||
request
|
||||
);
|
||||
|
||||
for await (const openAIEvent of streamGenerator) {
|
||||
// Store all events for processing
|
||||
setOpenAIEvents((prev) => [...prev, openAIEvent]);
|
||||
|
||||
// Pass to debug panel
|
||||
onDebugEvent(openAIEvent);
|
||||
|
||||
// Handle text output for workflow result
|
||||
if (
|
||||
openAIEvent.type === "response.output_text.delta" &&
|
||||
"delta" in openAIEvent &&
|
||||
openAIEvent.delta
|
||||
) {
|
||||
accumulatedText.current += openAIEvent.delta;
|
||||
setWorkflowResult(accumulatedText.current);
|
||||
}
|
||||
|
||||
// Handle workflow completion with final result
|
||||
if (
|
||||
openAIEvent.type === "response.workflow_event.complete" &&
|
||||
"data" in openAIEvent &&
|
||||
openAIEvent.data
|
||||
) {
|
||||
const data = openAIEvent.data as {
|
||||
event_type?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
if (data.event_type === "WorkflowCompletedEvent" && data.data) {
|
||||
setWorkflowResult(String(data.data));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (openAIEvent.type === "error") {
|
||||
setWorkflowError(
|
||||
"error" in openAIEvent
|
||||
? String(openAIEvent.error)
|
||||
: "Unknown error"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended
|
||||
setIsStreaming(false);
|
||||
} catch (error) {
|
||||
console.error("Workflow execution failed:", error);
|
||||
setWorkflowError(
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
[selectedWorkflow, onDebugEvent]
|
||||
);
|
||||
|
||||
// Show loading state when workflow is being loaded
|
||||
if (workflowLoading) {
|
||||
return (
|
||||
<LoadingState
|
||||
message="Loading workflow..."
|
||||
description="Fetching workflow structure and configuration"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workflowInfo?.workflow_dump && !executorHistory.length) {
|
||||
return (
|
||||
<LoadingState
|
||||
message="Initializing workflow..."
|
||||
description="Setting up workflow execution environment"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workflow-view flex flex-col h-full">
|
||||
{/* Top Panel - Workflow Visualization */}
|
||||
<div className="flex-1 min-h-0 p-4">
|
||||
{/* Workflow Diagram Section */}
|
||||
{workflowInfo?.workflow_dump && (
|
||||
<div className="border border-border rounded bg-card shadow-sm h-full flex flex-col">
|
||||
<div className="border-b border-border px-4 py-3 bg-muted rounded-t flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
Workflow Visualization
|
||||
</h3>
|
||||
|
||||
{/* Smart Run Workflow CTA - Show for all states */}
|
||||
{workflowInfo && (
|
||||
<div className="flex items-center gap-3">
|
||||
<RunWorkflowButton
|
||||
inputSchema={workflowInfo.input_schema}
|
||||
onRun={handleSendWorkflowData}
|
||||
isSubmitting={isStreaming}
|
||||
workflowState={
|
||||
isStreaming
|
||||
? "running"
|
||||
: workflowError
|
||||
? "error"
|
||||
: executorHistory.length > 0
|
||||
? "completed"
|
||||
: "ready"
|
||||
}
|
||||
executorHistory={executorHistory}
|
||||
workflowError={workflowError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status is now handled by the RunWorkflowButton component */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<WorkflowFlow
|
||||
workflowDump={workflowInfo.workflow_dump}
|
||||
events={openAIEvents}
|
||||
isStreaming={isStreaming}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
className="h-full"
|
||||
viewOptions={viewOptions}
|
||||
onToggleViewOption={toggleViewOption}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className={`h-1 cursor-row-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
|
||||
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
|
||||
}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="absolute inset-x-0 -top-2 -bottom-2 flex items-center justify-center">
|
||||
<div
|
||||
className={`w-12 h-1 rounded-full transition-all duration-200 ease-in-out ${
|
||||
isResizing
|
||||
? "bg-primary shadow-lg shadow-primary/25"
|
||||
: "bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Panel - Execution Details */}
|
||||
<div
|
||||
className="flex-shrink-0 border-t"
|
||||
style={{ height: `${bottomPanelHeight}px` }}
|
||||
>
|
||||
{/* Full Width - Execution Details */}
|
||||
<div className="flex-1 min-w-0 p-4 overflow-auto">
|
||||
{selectedExecutor ||
|
||||
activeExecutors.length > 0 ||
|
||||
executorHistory.length > 0 ||
|
||||
workflowResult ||
|
||||
workflowError ? (
|
||||
<div className="h-full space-y-4">
|
||||
{/* Current/Last Executor Panel */}
|
||||
{(selectedExecutor ||
|
||||
activeExecutors.length > 0 ||
|
||||
executorHistory.length > 0) && (
|
||||
<div className="border border-border rounded bg-card shadow-sm">
|
||||
<div className="border-b border-border px-4 py-3 bg-muted rounded-t">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{selectedExecutor
|
||||
? `Executor: ${
|
||||
selectedExecutor.name || selectedExecutor.executorId
|
||||
}`
|
||||
: isStreaming && activeExecutors.length > 0
|
||||
? "Current Executor"
|
||||
: "Last Executor"}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{selectedExecutor ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
selectedExecutor.state === "running"
|
||||
? "bg-blue-500 dark:bg-blue-400 animate-pulse"
|
||||
: selectedExecutor.state === "completed"
|
||||
? "bg-green-500 dark:bg-green-400"
|
||||
: selectedExecutor.state === "failed"
|
||||
? "bg-red-500 dark:bg-red-400"
|
||||
: selectedExecutor.state === "cancelled"
|
||||
? "bg-orange-500 dark:bg-orange-400"
|
||||
: "bg-gray-400 dark:bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium capitalize text-foreground">
|
||||
{selectedExecutor.state}
|
||||
</span>
|
||||
{selectedExecutor.executorType && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({selectedExecutor.executorType})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedExecutor.inputData !== undefined &&
|
||||
selectedExecutor.inputData !== null && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-foreground mb-1">
|
||||
Input Data:
|
||||
</h5>
|
||||
<pre className="text-xs bg-muted p-2 rounded overflow-x-auto max-h-24">
|
||||
{String(
|
||||
typeof selectedExecutor.inputData === "string"
|
||||
? selectedExecutor.inputData
|
||||
: (() => {
|
||||
try {
|
||||
return JSON.stringify(
|
||||
selectedExecutor.inputData,
|
||||
null,
|
||||
2
|
||||
);
|
||||
} catch {
|
||||
return "[Unable to display data]";
|
||||
}
|
||||
})()
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedExecutor.outputData !== undefined &&
|
||||
selectedExecutor.outputData !== null && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-foreground mb-1">
|
||||
Output Data:
|
||||
</h5>
|
||||
<pre className="text-xs bg-muted p-2 rounded overflow-x-auto max-h-24">
|
||||
{String(
|
||||
typeof selectedExecutor.outputData ===
|
||||
"string"
|
||||
? selectedExecutor.outputData
|
||||
: (() => {
|
||||
try {
|
||||
return JSON.stringify(
|
||||
selectedExecutor.outputData,
|
||||
null,
|
||||
2
|
||||
);
|
||||
} catch {
|
||||
return "[Unable to display data]";
|
||||
}
|
||||
})()
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedExecutor.error && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-destructive mb-1">
|
||||
Error:
|
||||
</h5>
|
||||
<pre className="text-xs bg-destructive/10 text-destructive p-2 rounded overflow-x-auto">
|
||||
{selectedExecutor.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedExecutor(null)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Back to current executor
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const currentExecutorId =
|
||||
isStreaming && activeExecutors.length > 0
|
||||
? activeExecutors[activeExecutors.length - 1]
|
||||
: executorHistory.length > 0
|
||||
? executorHistory[executorHistory.length - 1]
|
||||
.executorId
|
||||
: null;
|
||||
|
||||
if (!currentExecutorId) return null;
|
||||
|
||||
const executorData = getExecutorData(currentExecutorId);
|
||||
const historyItem = executorHistory.find(
|
||||
(h) => h.executorId === currentExecutorId
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-3 cursor-pointer hover:bg-muted/30 p-2 rounded transition-colors"
|
||||
onClick={() => {
|
||||
if (executorData) {
|
||||
setSelectedExecutor({
|
||||
executorId: executorData.executorId,
|
||||
state: executorData.state,
|
||||
inputData: executorData.inputData,
|
||||
outputData: executorData.outputData,
|
||||
error: executorData.error,
|
||||
name: undefined,
|
||||
executorType: undefined,
|
||||
isSelected: true,
|
||||
isStartNode: false,
|
||||
onNodeClick: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
isStreaming
|
||||
? "bg-blue-500 dark:bg-blue-400 animate-pulse"
|
||||
: historyItem?.status === "completed"
|
||||
? "bg-green-500 dark:bg-green-400"
|
||||
: historyItem?.status === "error"
|
||||
? "bg-red-500 dark:bg-red-400"
|
||||
: "bg-gray-400 dark:bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{currentExecutorId}
|
||||
</span>
|
||||
{historyItem && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(
|
||||
historyItem.timestamp
|
||||
).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{historyItem && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isStreaming
|
||||
? "Processing..."
|
||||
: historyItem.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enhanced Result Display */}
|
||||
{workflowResult && (
|
||||
<div className="border-2 border-emerald-300 dark:border-emerald-600 rounded bg-emerald-50 dark:bg-emerald-950/50 shadow">
|
||||
<div className="border-b border-emerald-300 dark:border-emerald-600 px-4 py-3 bg-emerald-100 dark:bg-emerald-900/50 rounded-t">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<h4 className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
|
||||
Workflow Complete
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-emerald-700 dark:text-emerald-300 whitespace-pre-wrap break-words text-sm">
|
||||
{workflowResult}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enhanced Error Display */}
|
||||
{workflowError && (
|
||||
<div className="border-2 border-destructive/70 rounded bg-destructive/5 shadow">
|
||||
<div className="border-b border-destructive/70 px-4 py-3 bg-destructive/10 rounded-t">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||
<h4 className="text-sm font-semibold text-destructive">
|
||||
Workflow Failed
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-destructive whitespace-pre-wrap break-words text-sm">
|
||||
{workflowError}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<p>Select a workflow to see execution details</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user