diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts index f19cb96205..358fd3ae7e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts @@ -48,17 +48,29 @@ export const useRunInputDialog = ({ }, onError: (error) => { if (error instanceof ApiError && error.isGraphValidationError()) { - const errorData = error.response?.detail; - Object.entries(errorData.node_errors).forEach( - ([nodeId, nodeErrors]) => { - useNodeStore - .getState() - .updateNodeErrors( - nodeId, - nodeErrors as { [key: string]: string }, - ); - }, - ); + const errorData = error.response?.detail || { + node_errors: {}, + message: undefined, + }; + const nodeErrors = errorData.node_errors || {}; + + if (Object.keys(nodeErrors).length > 0) { + Object.entries(nodeErrors).forEach( + ([nodeId, nodeErrorsForNode]) => { + useNodeStore + .getState() + .updateNodeErrors( + nodeId, + nodeErrorsForNode as { [key: string]: string }, + ); + }, + ); + } else { + useNodeStore.getState().nodes.forEach((node) => { + useNodeStore.getState().updateNodeErrors(node.id, {}); + }); + } + toast({ title: errorData?.message || "Graph validation failed", description: @@ -67,7 +79,7 @@ export const useRunInputDialog = ({ }); setIsOpen(false); - const firstBackendId = Object.keys(errorData.node_errors)[0]; + const firstBackendId = Object.keys(nodeErrors)[0]; if (firstBackendId) { const firstErrorNode = useNodeStore diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx index 29fd984b1d..87ae4300b8 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx @@ -55,14 +55,16 @@ export const Flow = () => { const edgeTypes = useMemo(() => ({ custom: CustomEdge }), []); const onNodeDragStop = useCallback(() => { + const currentNodes = useNodeStore.getState().nodes; setNodes( - resolveCollisions(nodes, { + resolveCollisions(currentNodes, { maxIterations: Infinity, overlapThreshold: 0.5, margin: 15, }), ); - }, [setNodes, nodes]); + }, [setNodes]); + const { edges, onConnect, onEdgesChange } = useCustomEdge(); // for loading purpose diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts index bf4ba3a418..d8571749d3 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts @@ -6,6 +6,7 @@ import { import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { useCallback } from "react"; import { useNodeStore } from "../../../stores/nodeStore"; +import { useHistoryStore } from "../../../stores/historyStore"; import { CustomEdge } from "./CustomEdge"; export const useCustomEdge = () => { @@ -51,7 +52,20 @@ export const useCustomEdge = () => { const onEdgesChange = useCallback( (changes: EdgeChange[]) => { + const hasRemoval = changes.some((change) => change.type === "remove"); + + const prevState = hasRemoval + ? { + nodes: useNodeStore.getState().nodes, + edges: edges, + } + : null; + setEdges(applyEdgeChanges(changes, edges)); + + if (prevState) { + useHistoryStore.getState().pushState(prevState); + } }, [edges, setEdges], ); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx index d9f3d108f4..c4659b8dcf 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx @@ -22,7 +22,7 @@ export const NodeHeader = ({ data, nodeId }: Props) => { const updateNodeData = useNodeStore((state) => state.updateNodeData); const title = (data.metadata?.customized_name as string) || - data.hardcodedValues.agent_name || + data.hardcodedValues?.agent_name || data.title; const [isEditingTitle, setIsEditingTitle] = useState(false); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts index 7b17eecfb3..6a45b9e1e2 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts @@ -5,6 +5,8 @@ import { customEdgeToLink, linkToCustomEdge } from "../components/helper"; import { MarkerType } from "@xyflow/react"; import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult"; import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers"; +import { useHistoryStore } from "./historyStore"; +import { useNodeStore } from "./nodeStore"; type EdgeStore = { edges: CustomEdge[]; @@ -53,25 +55,36 @@ export const useEdgeStore = create((set, get) => ({ id, }; - set((state) => { - const exists = state.edges.some( - (e) => - e.source === newEdge.source && - e.target === newEdge.target && - e.sourceHandle === newEdge.sourceHandle && - e.targetHandle === newEdge.targetHandle, - ); - if (exists) return state; - return { edges: [...state.edges, newEdge] }; - }); + const exists = get().edges.some( + (e) => + e.source === newEdge.source && + e.target === newEdge.target && + e.sourceHandle === newEdge.sourceHandle && + e.targetHandle === newEdge.targetHandle, + ); + if (exists) return newEdge; + const prevState = { + nodes: useNodeStore.getState().nodes, + edges: get().edges, + }; + + set((state) => ({ edges: [...state.edges, newEdge] })); + useHistoryStore.getState().pushState(prevState); return newEdge; }, - removeEdge: (edgeId) => + removeEdge: (edgeId) => { + const prevState = { + nodes: useNodeStore.getState().nodes, + edges: get().edges, + }; + set((state) => ({ edges: state.edges.filter((e) => e.id !== edgeId), - })), + })); + useHistoryStore.getState().pushState(prevState); + }, upsertMany: (edges) => set((state) => { diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/historyStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/historyStore.ts index 4eea5741a4..3a67bb8dcd 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/stores/historyStore.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/historyStore.ts @@ -37,6 +37,15 @@ export const useHistoryStore = create((set, get) => ({ return; } + const actualCurrentState = { + nodes: useNodeStore.getState().nodes, + edges: useEdgeStore.getState().edges, + }; + + if (isEqual(state, actualCurrentState)) { + return; + } + set((prev) => ({ past: [...prev.past.slice(-MAX_HISTORY + 1), state], future: [], @@ -55,18 +64,25 @@ export const useHistoryStore = create((set, get) => ({ undo: () => { const { past, future } = get(); - if (past.length <= 1) return; + if (past.length === 0) return; - const currentState = past[past.length - 1]; + const actualCurrentState = { + nodes: useNodeStore.getState().nodes, + edges: useEdgeStore.getState().edges, + }; - const previousState = past[past.length - 2]; + const previousState = past[past.length - 1]; + + if (isEqual(actualCurrentState, previousState)) { + return; + } useNodeStore.getState().setNodes(previousState.nodes); useEdgeStore.getState().setEdges(previousState.edges); set({ - past: past.slice(0, -1), - future: [currentState, ...future], + past: past.length > 1 ? past.slice(0, -1) : past, + future: [actualCurrentState, ...future], }); }, @@ -74,18 +90,36 @@ export const useHistoryStore = create((set, get) => ({ const { past, future } = get(); if (future.length === 0) return; + const actualCurrentState = { + nodes: useNodeStore.getState().nodes, + edges: useEdgeStore.getState().edges, + }; + const nextState = future[0]; useNodeStore.getState().setNodes(nextState.nodes); useEdgeStore.getState().setEdges(nextState.edges); + const lastPast = past[past.length - 1]; + const shouldPushToPast = + !lastPast || !isEqual(actualCurrentState, lastPast); + set({ - past: [...past, nextState], + past: shouldPushToPast ? [...past, actualCurrentState] : past, future: future.slice(1), }); }, - canUndo: () => get().past.length > 1, + canUndo: () => { + const { past } = get(); + if (past.length === 0) return false; + + const actualCurrentState = { + nodes: useNodeStore.getState().nodes, + edges: useEdgeStore.getState().edges, + }; + return !isEqual(actualCurrentState, past[past.length - 1]); + }, canRedo: () => get().future.length > 0, clear: () => set({ past: [{ nodes: [], edges: [] }], future: [] }), diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts index cb41da9463..5502a8780d 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { NodeChange, XYPosition, applyNodeChanges } from "@xyflow/react"; import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode"; +import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge"; import { BlockInfo } from "@/app/api/__generated__/models/blockInfo"; import { convertBlockInfoIntoCustomNodeData, @@ -44,6 +45,8 @@ const MINIMUM_MOVE_BEFORE_LOG = 50; // Track initial positions when drag starts (outside store to avoid re-renders) const dragStartPositions: Record = {}; +let dragStartState: { nodes: CustomNode[]; edges: CustomEdge[] } | null = null; + type NodeStore = { nodes: CustomNode[]; nodeCounter: number; @@ -124,14 +127,20 @@ export const useNodeStore = create((set, get) => ({ nodeCounter: state.nodeCounter + 1, })), onNodesChange: (changes) => { - const prevState = { - nodes: get().nodes, - edges: useEdgeStore.getState().edges, - }; - - // Track initial positions when drag starts changes.forEach((change) => { if (change.type === "position" && change.dragging === true) { + if (!dragStartState) { + const currentNodes = get().nodes; + const currentEdges = useEdgeStore.getState().edges; + dragStartState = { + nodes: currentNodes.map((n) => ({ + ...n, + position: { ...n.position }, + data: { ...n.data }, + })), + edges: currentEdges.map((e) => ({ ...e })), + }; + } if (!dragStartPositions[change.id]) { const node = get().nodes.find((n) => n.id === change.id); if (node) { @@ -141,12 +150,17 @@ export const useNodeStore = create((set, get) => ({ } }); - // Check if we should track this change in history - let shouldTrack = changes.some( - (change) => change.type === "remove" || change.type === "add", - ); + let shouldTrack = changes.some((change) => change.type === "remove"); + let stateToTrack: { nodes: CustomNode[]; edges: CustomEdge[] } | null = + null; + + if (shouldTrack) { + stateToTrack = { + nodes: get().nodes, + edges: useEdgeStore.getState().edges, + }; + } - // For position changes, only track if movement exceeds threshold if (!shouldTrack) { changes.forEach((change) => { if (change.type === "position" && change.dragging === false) { @@ -158,20 +172,23 @@ export const useNodeStore = create((set, get) => ({ ); if (distanceMoved > MINIMUM_MOVE_BEFORE_LOG) { shouldTrack = true; + stateToTrack = dragStartState; } } - // Clean up tracked position after drag ends delete dragStartPositions[change.id]; } }); + if (Object.keys(dragStartPositions).length === 0) { + dragStartState = null; + } } set((state) => ({ nodes: applyNodeChanges(changes, state.nodes), })); - if (shouldTrack) { - useHistoryStore.getState().pushState(prevState); + if (shouldTrack && stateToTrack) { + useHistoryStore.getState().pushState(stateToTrack); } }, @@ -185,6 +202,11 @@ export const useNodeStore = create((set, get) => ({ hardcodedValues?: Record, position?: XYPosition, ) => { + const prevState = { + nodes: get().nodes, + edges: useEdgeStore.getState().edges, + }; + const customNodeData = convertBlockInfoIntoCustomNodeData( block, hardcodedValues, @@ -218,21 +240,24 @@ export const useNodeStore = create((set, get) => ({ set((state) => ({ nodes: [...state.nodes, customNode], })); + + useHistoryStore.getState().pushState(prevState); + return customNode; }, updateNodeData: (nodeId, data) => { + const prevState = { + nodes: get().nodes, + edges: useEdgeStore.getState().edges, + }; + set((state) => ({ nodes: state.nodes.map((n) => n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n, ), })); - const newState = { - nodes: get().nodes, - edges: useEdgeStore.getState().edges, - }; - - useHistoryStore.getState().pushState(newState); + useHistoryStore.getState().pushState(prevState); }, toggleAdvanced: (nodeId: string) => set((state) => ({ @@ -391,6 +416,11 @@ export const useNodeStore = create((set, get) => ({ }, setCredentialsOptional: (nodeId: string, optional: boolean) => { + const prevState = { + nodes: get().nodes, + edges: useEdgeStore.getState().edges, + }; + set((state) => ({ nodes: state.nodes.map((n) => n.id === nodeId @@ -408,12 +438,7 @@ export const useNodeStore = create((set, get) => ({ ), })); - const newState = { - nodes: get().nodes, - edges: useEdgeStore.getState().edges, - }; - - useHistoryStore.getState().pushState(newState); + useHistoryStore.getState().pushState(prevState); }, // Sub-agent resolution mode state diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/FormRenderer.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/FormRenderer.tsx index fc388cc343..c3a20d8cd2 100644 --- a/autogpt_platform/frontend/src/components/renderers/InputRenderer/FormRenderer.tsx +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/FormRenderer.tsx @@ -30,8 +30,6 @@ export const FormRenderer = ({ return generateUiSchemaForCustomFields(preprocessedSchema, uiSchema); }, [preprocessedSchema, uiSchema]); - console.log("preprocessedSchema", preprocessedSchema); - return (