690 lines
22 KiB
TypeScript
690 lines
22 KiB
TypeScript
/*
|
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
│ @author: Davidson Gomes │
|
|
│ @file: /app/agents/workflows/Canva.tsx │
|
|
│ Developed by: Davidson Gomes │
|
|
│ Delay node integration developed by: Victor Calazans │
|
|
│ Creation date: May 13, 2025 |
|
|
│ Delay implementation date: May 17, 2025 │
|
|
│ Contact: contato@evolution-api.com │
|
|
├──────────────────────────────────────────────────────────────────────────────┤
|
|
│ @copyright © Evolution API 2025. All rights reserved. │
|
|
│ Licensed under the Apache License, Version 2.0 │
|
|
│ │
|
|
│ You may not use this file except in compliance with the License. │
|
|
│ You may obtain a copy of the License at │
|
|
│ │
|
|
│ http://www.apache.org/licenses/LICENSE-2.0 │
|
|
│ │
|
|
│ Unless required by applicable law or agreed to in writing, software │
|
|
│ distributed under the License is distributed on an "AS IS" BASIS, │
|
|
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
|
|
│ See the License for the specific language governing permissions and │
|
|
│ limitations under the License. │
|
|
├──────────────────────────────────────────────────────────────────────────────┤
|
|
│ @important │
|
|
│ For any future changes to the code in this file, it is recommended to │
|
|
│ include, together with the modification, the information of the developer │
|
|
│ who changed it and the date of modification. │
|
|
└──────────────────────────────────────────────────────────────────────────────┘
|
|
*/
|
|
"use client";
|
|
|
|
import {
|
|
useState,
|
|
useEffect,
|
|
useRef,
|
|
useCallback,
|
|
forwardRef,
|
|
useImperativeHandle,
|
|
} from "react";
|
|
|
|
import {
|
|
Controls,
|
|
ReactFlow,
|
|
addEdge,
|
|
useNodesState,
|
|
useEdgesState,
|
|
type OnConnect,
|
|
ConnectionMode,
|
|
ConnectionLineType,
|
|
useReactFlow,
|
|
ProOptions,
|
|
applyNodeChanges,
|
|
NodeChange,
|
|
OnNodesChange,
|
|
MiniMap,
|
|
Panel,
|
|
Background,
|
|
} from "@xyflow/react";
|
|
import { useDnD } from "@/contexts/DnDContext";
|
|
|
|
import { Edit, X, ChevronLeft, ChevronRight } from "lucide-react";
|
|
|
|
import "@xyflow/react/dist/style.css";
|
|
import "./canva.css";
|
|
|
|
import { getHelperLines } from "./utils";
|
|
|
|
import { NodePanel } from "./NodePanel";
|
|
import ContextMenu from "./ContextMenu";
|
|
import { initialEdges, edgeTypes } from "./edges";
|
|
import HelperLines from "./HelperLines";
|
|
import { initialNodes, nodeTypes } from "./nodes";
|
|
import { AgentForm } from "./nodes/components/agent/AgentForm";
|
|
import { ConditionForm } from "./nodes/components/condition/ConditionForm";
|
|
import { Agent, WorkflowData } from "@/types/agent";
|
|
import { updateAgent } from "@/services/agentService";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { MessageForm } from "./nodes/components/message/MessageForm";
|
|
import { DelayForm } from "./nodes/components/delay/DelayForm";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
const proOptions: ProOptions = { account: "paid-pro", hideAttribution: true };
|
|
|
|
const NodeFormWrapper = ({
|
|
selectedNode,
|
|
editingLabel,
|
|
setEditingLabel,
|
|
handleUpdateNode,
|
|
setSelectedNode,
|
|
children,
|
|
}: {
|
|
selectedNode: any;
|
|
editingLabel: boolean;
|
|
setEditingLabel: (value: boolean) => void;
|
|
handleUpdateNode: (node: any) => void;
|
|
setSelectedNode: (node: any) => void;
|
|
children: React.ReactNode;
|
|
}) => {
|
|
// Handle ESC key to close the panel
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape" && !editingLabel) {
|
|
setSelectedNode(null);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [setSelectedNode, editingLabel]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex-shrink-0 sticky top-0 z-20 bg-neutral-800 shadow-md border-b border-neutral-700">
|
|
<div className="p-4 text-center relative">
|
|
<button
|
|
className="absolute right-2 top-2 text-neutral-200 hover:text-white p-1 rounded-full hover:bg-neutral-700"
|
|
onClick={() => setSelectedNode(null)}
|
|
aria-label="Close panel"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
{!editingLabel ? (
|
|
<div className="flex items-center justify-center text-xl font-bold text-neutral-200">
|
|
<span>{selectedNode.data.label}</span>
|
|
{selectedNode.type !== "start-node" && (
|
|
<Edit
|
|
size={16}
|
|
className="ml-2 cursor-pointer hover:text-indigo-300"
|
|
onClick={() => setEditingLabel(true)}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={selectedNode.data.label}
|
|
className="w-full p-2 text-center text-xl font-bold bg-neutral-800 text-neutral-200 border border-neutral-600 rounded"
|
|
onChange={(e) => {
|
|
handleUpdateNode({
|
|
...selectedNode,
|
|
data: {
|
|
...selectedNode.data,
|
|
label: e.target.value,
|
|
},
|
|
});
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
setEditingLabel(false);
|
|
}
|
|
}}
|
|
onBlur={() => setEditingLabel(false)}
|
|
autoFocus
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-h-0 overflow-hidden">{children}</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Canva = forwardRef(({ agent }: { agent: Agent | null }, ref) => {
|
|
const { toast } = useToast();
|
|
const [nodes, setNodes] = useNodesState(initialNodes);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
|
const { screenToFlowPosition } = useReactFlow();
|
|
const { type, setPointerEvents } = useDnD();
|
|
const [menu, setMenu] = useState<any>(null);
|
|
const localRef = useRef<any>(null);
|
|
const [selectedNode, setSelectedNode] = useState<any>(null);
|
|
const [activeExecutionNodeId, setActiveExecutionNodeId] = useState<
|
|
string | null
|
|
>(null);
|
|
|
|
const [editingLabel, setEditingLabel] = useState(false);
|
|
|
|
const [hasChanges, setHasChanges] = useState(false);
|
|
|
|
const [nodePanelOpen, setNodePanelOpen] = useState(false);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
getFlowData: () => ({
|
|
nodes,
|
|
edges,
|
|
}),
|
|
setHasChanges,
|
|
setActiveExecutionNodeId,
|
|
}));
|
|
|
|
// Effect to clear the active node after a timeout
|
|
useEffect(() => {
|
|
if (activeExecutionNodeId) {
|
|
const timer = setTimeout(() => {
|
|
setActiveExecutionNodeId(null);
|
|
}, 5000); // Increase to 5 seconds to give more time to visualize
|
|
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [activeExecutionNodeId]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
agent?.config?.workflow &&
|
|
agent.config.workflow.nodes.length > 0 &&
|
|
agent.config.workflow.edges.length > 0
|
|
) {
|
|
setNodes(
|
|
(agent.config.workflow.nodes as typeof initialNodes) || initialNodes
|
|
);
|
|
setEdges(
|
|
(agent.config.workflow.edges as typeof initialEdges) || initialEdges
|
|
);
|
|
} else {
|
|
setNodes(initialNodes);
|
|
setEdges(initialEdges);
|
|
}
|
|
}, [agent, setNodes, setEdges]);
|
|
|
|
// Update nodes when the active node changes to add visual class
|
|
useEffect(() => {
|
|
if (nodes.length > 0) {
|
|
setNodes((nds: any) =>
|
|
nds.map((node: any) => {
|
|
if (node.id === activeExecutionNodeId) {
|
|
// Add a class to highlight the active node
|
|
return {
|
|
...node,
|
|
className: "active-execution-node",
|
|
data: {
|
|
...node.data,
|
|
isExecuting: true,
|
|
},
|
|
};
|
|
} else {
|
|
// Remove the highlight class
|
|
const { isExecuting, ...restData } = node.data || {};
|
|
return {
|
|
...node,
|
|
className: "",
|
|
data: restData,
|
|
};
|
|
}
|
|
})
|
|
);
|
|
}
|
|
}, [activeExecutionNodeId, setNodes]);
|
|
|
|
useEffect(() => {
|
|
if (agent?.config?.workflow) {
|
|
const initialNodes = agent.config.workflow.nodes || [];
|
|
const initialEdges = agent.config.workflow.edges || [];
|
|
|
|
if (
|
|
JSON.stringify(nodes) !== JSON.stringify(initialNodes) ||
|
|
JSON.stringify(edges) !== JSON.stringify(initialEdges)
|
|
) {
|
|
setHasChanges(true);
|
|
} else {
|
|
setHasChanges(false);
|
|
}
|
|
}
|
|
}, [nodes, edges, agent]);
|
|
|
|
const [helperLineHorizontal, setHelperLineHorizontal] = useState<
|
|
number | undefined
|
|
>(undefined);
|
|
const [helperLineVertical, setHelperLineVertical] = useState<
|
|
number | undefined
|
|
>(undefined);
|
|
|
|
const onConnect: OnConnect = useCallback(
|
|
(connection) => {
|
|
setEdges((currentEdges) => {
|
|
if (connection.source === connection.target) {
|
|
return currentEdges;
|
|
}
|
|
|
|
return addEdge(connection, currentEdges);
|
|
});
|
|
},
|
|
[setEdges]
|
|
);
|
|
|
|
const onConnectEnd = useCallback(
|
|
(_event: any, connectionState: any) => {
|
|
setPointerEvents("none");
|
|
|
|
if (connectionState.fromHandle?.type === "target") {
|
|
return;
|
|
}
|
|
|
|
if (!connectionState.isValid) {
|
|
// Since we're using NodePanel now, we don't need to do anything here
|
|
// The panel will handle node creation through drag and drop
|
|
}
|
|
},
|
|
[setPointerEvents]
|
|
);
|
|
|
|
const onConnectStart = useCallback(() => {
|
|
setPointerEvents("auto");
|
|
}, [setPointerEvents]);
|
|
|
|
const customApplyNodeChanges = useCallback(
|
|
(changes: NodeChange[], nodes: any): any => {
|
|
// reset the helper lines (clear existing lines, if any)
|
|
setHelperLineHorizontal(undefined);
|
|
setHelperLineVertical(undefined);
|
|
|
|
// this will be true if it's a single node being dragged
|
|
// inside we calculate the helper lines and snap position for the position where the node is being moved to
|
|
if (
|
|
changes.length === 1 &&
|
|
changes[0].type === "position" &&
|
|
changes[0].dragging &&
|
|
changes[0].position
|
|
) {
|
|
const helperLines = getHelperLines(changes[0], nodes);
|
|
|
|
// if we have a helper line, we snap the node to the helper line position
|
|
// this is being done by manipulating the node position inside the change object
|
|
changes[0].position.x =
|
|
helperLines.snapPosition.x ?? changes[0].position.x;
|
|
changes[0].position.y =
|
|
helperLines.snapPosition.y ?? changes[0].position.y;
|
|
|
|
// if helper lines are returned, we set them so that they can be displayed
|
|
setHelperLineHorizontal(helperLines.horizontal);
|
|
setHelperLineVertical(helperLines.vertical);
|
|
}
|
|
|
|
return applyNodeChanges(changes, nodes);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const onNodesChange: OnNodesChange = useCallback(
|
|
(changes) => {
|
|
setNodes((nodes) => customApplyNodeChanges(changes, nodes));
|
|
},
|
|
[setNodes, customApplyNodeChanges]
|
|
);
|
|
|
|
const getLabelFromNode = (type: string) => {
|
|
const order = nodes.length;
|
|
|
|
switch (type) {
|
|
case "start-node":
|
|
return "Start";
|
|
case "agent-node":
|
|
return `Agent #${order}`;
|
|
case "condition-node":
|
|
return `Condition #${order}`;
|
|
case "message-node":
|
|
return `Message #${order}`;
|
|
case "delay-node":
|
|
return `Delay #${order}`;
|
|
default:
|
|
return "Node";
|
|
}
|
|
};
|
|
|
|
const handleAddNode = useCallback(
|
|
(type: any, node: any) => {
|
|
const newNode: any = {
|
|
id: String(Date.now()),
|
|
type,
|
|
position: node.position,
|
|
data: {
|
|
label: getLabelFromNode(type),
|
|
},
|
|
};
|
|
|
|
setNodes((nodes) => [...nodes, newNode]);
|
|
|
|
if (node.targetId) {
|
|
const newEdge: any = {
|
|
source: node.targetId,
|
|
sourceHandle: node.handleId,
|
|
target: newNode.id,
|
|
type: "default",
|
|
};
|
|
|
|
const newsEdges: any = [...edges, newEdge];
|
|
|
|
setEdges(newsEdges);
|
|
}
|
|
},
|
|
[nodes, setNodes, edges, setEdges]
|
|
);
|
|
|
|
const handleUpdateNode = useCallback(
|
|
(node: any) => {
|
|
setNodes((nodes) => {
|
|
const index = nodes.findIndex((n) => n.id === node.id);
|
|
if (index !== -1) {
|
|
nodes[index] = node;
|
|
}
|
|
return [...nodes];
|
|
});
|
|
|
|
if (selectedNode && selectedNode.id === node.id) {
|
|
setSelectedNode(node);
|
|
}
|
|
},
|
|
[setNodes, selectedNode]
|
|
);
|
|
|
|
const handleDeleteEdge = useCallback(
|
|
(id: any) => {
|
|
setEdges((edges) => {
|
|
const left = edges.filter((edge: any) => edge.id !== id);
|
|
return left;
|
|
});
|
|
},
|
|
[setEdges]
|
|
);
|
|
|
|
const onDragOver = useCallback((event: any) => {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
}, []);
|
|
|
|
const onDrop = useCallback(
|
|
(event: any) => {
|
|
event.preventDefault();
|
|
|
|
if (!type) {
|
|
return;
|
|
}
|
|
|
|
const position = screenToFlowPosition({
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
});
|
|
const newNode: any = {
|
|
id: String(Date.now()),
|
|
type,
|
|
position,
|
|
data: {
|
|
label: getLabelFromNode(type),
|
|
},
|
|
};
|
|
|
|
setNodes((nodes) => [...nodes, newNode]);
|
|
},
|
|
[screenToFlowPosition, setNodes, type, getLabelFromNode]
|
|
);
|
|
|
|
const onNodeContextMenu = useCallback(
|
|
(event: any, node: any) => {
|
|
event.preventDefault();
|
|
|
|
if (node.id === "start-node") {
|
|
return;
|
|
}
|
|
|
|
if (!localRef.current) {
|
|
return;
|
|
}
|
|
|
|
const paneBounds = localRef.current.getBoundingClientRect();
|
|
|
|
const x = event.clientX - paneBounds.left;
|
|
const y = event.clientY - paneBounds.top;
|
|
|
|
const menuWidth = 200;
|
|
const menuHeight = 200;
|
|
|
|
const left = x + menuWidth > paneBounds.width ? undefined : x;
|
|
const top = y + menuHeight > paneBounds.height ? undefined : y;
|
|
const right =
|
|
x + menuWidth > paneBounds.width ? paneBounds.width - x : undefined;
|
|
const bottom =
|
|
y + menuHeight > paneBounds.height ? paneBounds.height - y : undefined;
|
|
|
|
setMenu({
|
|
id: node.id,
|
|
left,
|
|
top,
|
|
right,
|
|
bottom,
|
|
});
|
|
},
|
|
[setMenu]
|
|
);
|
|
|
|
const onNodeClick = useCallback((event: any, node: any) => {
|
|
event.preventDefault();
|
|
|
|
if (node.type === "start-node") {
|
|
return;
|
|
}
|
|
|
|
setSelectedNode(node);
|
|
}, []);
|
|
|
|
const onPaneClick = useCallback(() => {
|
|
setMenu(null);
|
|
setSelectedNode(null);
|
|
setNodePanelOpen(false);
|
|
}, [setMenu, setSelectedNode]);
|
|
|
|
return (
|
|
<div className="h-full w-full bg-[#121212]">
|
|
<div
|
|
style={{ position: "relative", height: "100%", width: "100%" }}
|
|
ref={localRef}
|
|
className="overflow-hidden"
|
|
>
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
nodeTypes={nodeTypes}
|
|
onNodesChange={onNodesChange}
|
|
edges={edges}
|
|
edgeTypes={edgeTypes}
|
|
onEdgesChange={onEdgesChange}
|
|
onConnect={onConnect}
|
|
onConnectStart={onConnectStart}
|
|
onConnectEnd={onConnectEnd}
|
|
connectionMode={ConnectionMode.Strict}
|
|
connectionLineType={ConnectionLineType.Bezier}
|
|
onDrop={onDrop}
|
|
onDragOver={onDragOver}
|
|
onPaneClick={onPaneClick}
|
|
onNodeClick={onNodeClick}
|
|
onNodeContextMenu={onNodeContextMenu}
|
|
colorMode="dark"
|
|
minZoom={0.1}
|
|
maxZoom={10}
|
|
fitView={false}
|
|
defaultViewport={{
|
|
x: 0,
|
|
y: 0,
|
|
zoom: 1,
|
|
}}
|
|
elevateEdgesOnSelect
|
|
elevateNodesOnSelect
|
|
proOptions={proOptions}
|
|
connectionLineStyle={{
|
|
stroke: "gray",
|
|
strokeWidth: 2,
|
|
strokeDashoffset: 5,
|
|
strokeDasharray: 5,
|
|
}}
|
|
defaultEdgeOptions={{
|
|
type: "default",
|
|
style: {
|
|
strokeWidth: 3,
|
|
},
|
|
data: {
|
|
handleDeleteEdge,
|
|
},
|
|
}}
|
|
>
|
|
<Background color="#334155" gap={24} size={1.5} />
|
|
<MiniMap
|
|
className="bg-neutral-800/80 border border-neutral-700 rounded-lg shadow-lg"
|
|
nodeColor={(node) => {
|
|
switch (node.type) {
|
|
case "start-node":
|
|
return "#10b981";
|
|
case "agent-node":
|
|
return "#3b82f6";
|
|
case "message-node":
|
|
return "#f97316";
|
|
case "condition-node":
|
|
return "#3b82f6";
|
|
case "delay-node":
|
|
return "#eab308";
|
|
default:
|
|
return "#64748b";
|
|
}
|
|
}}
|
|
maskColor="rgba(15, 23, 42, 0.6)"
|
|
/>
|
|
|
|
<Controls
|
|
showInteractive={true}
|
|
showFitView={true}
|
|
orientation="vertical"
|
|
position="bottom-left"
|
|
/>
|
|
<HelperLines
|
|
horizontal={helperLineHorizontal}
|
|
vertical={helperLineVertical}
|
|
/>
|
|
{menu && <ContextMenu onClick={onPaneClick} {...menu} />}
|
|
|
|
{nodePanelOpen ? (
|
|
<Panel position="top-right">
|
|
<div className="flex items-start">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setNodePanelOpen(false)}
|
|
className="mr-2 h-8 w-8 rounded-full bg-slate-800 border-slate-700 text-slate-400 hover:text-white hover:bg-slate-700"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
<NodePanel />
|
|
</div>
|
|
</Panel>
|
|
) : (
|
|
<Panel position="top-right">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setNodePanelOpen(true)}
|
|
className="h-8 w-8 rounded-full bg-slate-800 border-slate-700 text-slate-400 hover:text-white hover:bg-slate-700"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
</Panel>
|
|
)}
|
|
</ReactFlow>
|
|
|
|
{/* Overlay when form is open on smaller screens */}
|
|
{selectedNode && (
|
|
<div
|
|
className="md:hidden fixed inset-0 bg-black bg-opacity-50 z-[5] transition-opacity duration-300"
|
|
onClick={() => setSelectedNode(null)}
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
className="absolute left-0 top-0 z-10 h-full w-[350px] bg-neutral-900 shadow-lg transition-all duration-300 ease-in-out border-r border-neutral-700 flex flex-col"
|
|
style={{
|
|
transform: selectedNode ? "translateX(0)" : "translateX(-100%)",
|
|
opacity: selectedNode ? 1 : 0,
|
|
}}
|
|
>
|
|
{selectedNode ? (
|
|
<NodeFormWrapper
|
|
selectedNode={selectedNode}
|
|
editingLabel={editingLabel}
|
|
setEditingLabel={setEditingLabel}
|
|
handleUpdateNode={handleUpdateNode}
|
|
setSelectedNode={setSelectedNode}
|
|
>
|
|
{selectedNode.type === "agent-node" && (
|
|
<AgentForm
|
|
selectedNode={selectedNode}
|
|
handleUpdateNode={handleUpdateNode}
|
|
setEdges={setEdges}
|
|
setIsOpen={() => {}}
|
|
setSelectedNode={setSelectedNode}
|
|
/>
|
|
)}
|
|
{selectedNode.type === "condition-node" && (
|
|
<ConditionForm
|
|
selectedNode={selectedNode}
|
|
handleUpdateNode={handleUpdateNode}
|
|
setEdges={setEdges}
|
|
setIsOpen={() => {}}
|
|
setSelectedNode={setSelectedNode}
|
|
/>
|
|
)}
|
|
{selectedNode.type === "message-node" && (
|
|
<MessageForm
|
|
selectedNode={selectedNode}
|
|
handleUpdateNode={handleUpdateNode}
|
|
setEdges={setEdges}
|
|
setIsOpen={() => {}}
|
|
setSelectedNode={setSelectedNode}
|
|
/>
|
|
)}
|
|
{selectedNode.type === "delay-node" && (
|
|
<DelayForm
|
|
selectedNode={selectedNode}
|
|
handleUpdateNode={handleUpdateNode}
|
|
setEdges={setEdges}
|
|
setIsOpen={() => {}}
|
|
setSelectedNode={setSelectedNode}
|
|
/>
|
|
)}
|
|
</NodeFormWrapper>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
Canva.displayName = "Canva";
|
|
|
|
export default Canva;
|