evo-ai/frontend/app/agents/workflows/Canva.tsx

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;