/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @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 (
{!editingLabel ? (
{selectedNode.data.label}
{selectedNode.type !== "start-node" && (
setEditingLabel(true)}
/>
)}
) : (
{
handleUpdateNode({
...selectedNode,
data: {
...selectedNode.data,
label: e.target.value,
},
});
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
setEditingLabel(false);
}
}}
onBlur={() => setEditingLabel(false)}
autoFocus
/>
)}
{children}
);
};
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(null);
const localRef = useRef(null);
const [selectedNode, setSelectedNode] = useState(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 (
{
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)"
/>
{menu && }
{nodePanelOpen ? (
) : (
)}
{/* Overlay when form is open on smaller screens */}
{selectedNode && (
setSelectedNode(null)}
/>
)}
{selectedNode ? (
{selectedNode.type === "agent-node" && (
{}}
setSelectedNode={setSelectedNode}
/>
)}
{selectedNode.type === "condition-node" && (
{}}
setSelectedNode={setSelectedNode}
/>
)}
{selectedNode.type === "message-node" && (
{}}
setSelectedNode={setSelectedNode}
/>
)}
{selectedNode.type === "delay-node" && (
{}}
setSelectedNode={setSelectedNode}
/>
)}
) : null}
);
});
Canva.displayName = "Canva";
export default Canva;