mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
fix(frontend/builder): Generate unique IDs for copied blocks (#11344)
## Changes 🏗️ - Clear backend_id when pasting blocks to prevent duplicate ID errors - Add copy/paste functionality to new FlowEditor - Ensure pasted blocks use newly generated UUIDs when saving Fixes issue where copying and pasting blocks would fail with 'Unique constraint failed' error because the old backend_id was being reused instead of the new node ID. ## Checklist 📋 ### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Add any block to the builder, save and run the graph and wait for it to finish, then copy and paste the first block and paste it, try to use it and it should now work and not have any issues/errors https://github.com/user-attachments/assets/c24f9a9a-8e4f-4988-8731-cddc34a0da13 --------- Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
This commit is contained in:
@@ -4,7 +4,7 @@ import CustomEdge from "../edges/CustomEdge";
|
||||
import { useFlow } from "./useFlow";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useEffect } from "react";
|
||||
import { CustomNode } from "../nodes/CustomNode/CustomNode";
|
||||
import { useCustomEdge } from "../edges/useCustomEdge";
|
||||
import { useFlowRealtime } from "./useFlowRealtime";
|
||||
@@ -12,7 +12,7 @@ import { GraphLoadingBox } from "./components/GraphLoadingBox";
|
||||
import { BuilderActions } from "../../BuilderActions/BuilderActions";
|
||||
import { RunningBackground } from "./components/RunningBackground";
|
||||
import { useGraphStore } from "../../../stores/graphStore";
|
||||
import { useCopyPasteKeyboard } from "../../../hooks/useCopyPasteKeyboard";
|
||||
import { useCopyPaste } from "./useCopyPaste";
|
||||
|
||||
export const Flow = () => {
|
||||
const nodes = useNodeStore(useShallow((state) => state.nodes));
|
||||
@@ -28,7 +28,19 @@ export const Flow = () => {
|
||||
// This hook is used for websocket realtime updates.
|
||||
useFlowRealtime();
|
||||
|
||||
useCopyPasteKeyboard();
|
||||
// Copy/paste functionality
|
||||
const handleCopyPaste = useCopyPaste();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
handleCopyPaste(event);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleCopyPaste]);
|
||||
|
||||
const { isFlowContentLoading } = useFlow();
|
||||
const { isGraphRunning } = useGraphStore();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useCallback } from "react";
|
||||
import { Node, Edge, useReactFlow } from "@xyflow/react";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface CopyableData {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
export function useCopyPaste() {
|
||||
const { setNodes, addEdges, getNodes, getEdges, getViewport } =
|
||||
useReactFlow();
|
||||
|
||||
const handleCopyPaste = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Prevent copy/paste if any modal is open or if the focus is on an input element
|
||||
const activeElement = document.activeElement;
|
||||
const isInputField =
|
||||
activeElement?.tagName === "INPUT" ||
|
||||
activeElement?.tagName === "TEXTAREA" ||
|
||||
activeElement?.getAttribute("contenteditable") === "true";
|
||||
|
||||
if (isInputField) return;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// COPY: Ctrl+C or Cmd+C
|
||||
if (event.key === "c" || event.key === "C") {
|
||||
const selectedNodes = getNodes().filter((node) => node.selected);
|
||||
const selectedNodeIds = new Set(selectedNodes.map((node) => node.id));
|
||||
|
||||
// Only copy edges where both source and target nodes are selected
|
||||
const selectedEdges = getEdges().filter(
|
||||
(edge) =>
|
||||
edge.selected &&
|
||||
selectedNodeIds.has(edge.source) &&
|
||||
selectedNodeIds.has(edge.target),
|
||||
);
|
||||
|
||||
const copiedData: CopyableData = {
|
||||
nodes: selectedNodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
},
|
||||
})),
|
||||
edges: selectedEdges,
|
||||
};
|
||||
|
||||
storage.set(Key.COPIED_FLOW_DATA, JSON.stringify(copiedData));
|
||||
}
|
||||
|
||||
// PASTE: Ctrl+V or Cmd+V
|
||||
if (event.key === "v" || event.key === "V") {
|
||||
const copiedDataString = storage.get(Key.COPIED_FLOW_DATA);
|
||||
if (copiedDataString) {
|
||||
const copiedData = JSON.parse(copiedDataString) as CopyableData;
|
||||
const oldToNewIdMap: Record<string, string> = {};
|
||||
|
||||
// Get fresh viewport values at paste time to ensure correct positioning
|
||||
const { x, y, zoom } = getViewport();
|
||||
const viewportCenter = {
|
||||
x: (window.innerWidth / 2 - x) / zoom,
|
||||
y: (window.innerHeight / 2 - y) / zoom,
|
||||
};
|
||||
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
copiedData.nodes.forEach((node: Node) => {
|
||||
minX = Math.min(minX, node.position.x);
|
||||
minY = Math.min(minY, node.position.y);
|
||||
maxX = Math.max(maxX, node.position.x);
|
||||
maxY = Math.max(maxY, node.position.y);
|
||||
});
|
||||
|
||||
const offsetX = viewportCenter.x - (minX + maxX) / 2;
|
||||
const offsetY = viewportCenter.y - (minY + maxY) / 2;
|
||||
|
||||
// Create new nodes with UNIQUE IDs using UUID
|
||||
const pastedNodes = copiedData.nodes.map((node: Node) => {
|
||||
const newNodeId = uuidv4(); // Generate unique UUID for each node
|
||||
oldToNewIdMap[node.id] = newNodeId;
|
||||
return {
|
||||
...node,
|
||||
id: newNodeId, // Assign the new unique ID
|
||||
selected: true, // Select the pasted nodes
|
||||
position: {
|
||||
x: node.position.x + offsetX,
|
||||
y: node.position.y + offsetY,
|
||||
},
|
||||
data: {
|
||||
...node.data,
|
||||
backend_id: undefined, // Clear backend_id so the new node.id is used when saving
|
||||
status: undefined, // Clear execution status
|
||||
nodeExecutionResult: undefined, // Clear execution results
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Create new edges with updated source/target IDs
|
||||
const pastedEdges = copiedData.edges.map((edge) => {
|
||||
const newSourceId = oldToNewIdMap[edge.source] ?? edge.source;
|
||||
const newTargetId = oldToNewIdMap[edge.target] ?? edge.target;
|
||||
return {
|
||||
...edge,
|
||||
id: `${newSourceId}_${edge.sourceHandle}_${newTargetId}_${edge.targetHandle}_${Date.now()}`,
|
||||
source: newSourceId,
|
||||
target: newTargetId,
|
||||
};
|
||||
});
|
||||
|
||||
// Deselect existing nodes and add pasted nodes
|
||||
setNodes((existingNodes) => [
|
||||
...existingNodes.map((node) => ({ ...node, selected: false })),
|
||||
...pastedNodes,
|
||||
]);
|
||||
addEdges(pastedEdges);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[setNodes, addEdges, getNodes, getEdges, getViewport],
|
||||
);
|
||||
|
||||
return handleCopyPaste;
|
||||
}
|
||||
@@ -10,7 +10,6 @@ interface CopyableData {
|
||||
export function useCopyPaste(getNextNodeId: () => string) {
|
||||
const { setNodes, addEdges, getNodes, getEdges, getViewport } =
|
||||
useReactFlow();
|
||||
const { x, y, zoom } = getViewport();
|
||||
|
||||
const handleCopyPaste = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
@@ -46,6 +45,8 @@ export function useCopyPaste(getNextNodeId: () => string) {
|
||||
const copiedData = JSON.parse(copiedDataString) as CopyableData;
|
||||
const oldToNewIdMap: Record<string, string> = {};
|
||||
|
||||
// Get fresh viewport values at paste time to ensure correct positioning
|
||||
const { x, y, zoom } = getViewport();
|
||||
const viewportCenter = {
|
||||
x: (window.innerWidth / 2 - x) / zoom,
|
||||
y: (window.innerHeight / 2 - y) / zoom,
|
||||
@@ -70,13 +71,15 @@ export function useCopyPaste(getNextNodeId: () => string) {
|
||||
oldToNewIdMap[node.id] = newNodeId;
|
||||
return {
|
||||
...node,
|
||||
id: newNodeId,
|
||||
id: newNodeId, // Generate unique ID for the pasted node
|
||||
selected: true, // Select the pasted nodes so they're visible
|
||||
position: {
|
||||
x: node.position.x + offsetX,
|
||||
y: node.position.y + offsetY,
|
||||
},
|
||||
data: {
|
||||
...node.data,
|
||||
backend_id: undefined, // Clear backend_id so the new node.id is used when saving
|
||||
connections: node.data.connections || [], // Preserve connections
|
||||
status: undefined,
|
||||
executionResults: undefined,
|
||||
@@ -129,7 +132,7 @@ export function useCopyPaste(getNextNodeId: () => string) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[setNodes, addEdges, getNodes, getEdges, getNextNodeId, x, y, zoom],
|
||||
[setNodes, addEdges, getNodes, getEdges, getNextNodeId, getViewport],
|
||||
);
|
||||
|
||||
return handleCopyPaste;
|
||||
|
||||
Reference in New Issue
Block a user