feat(frontend): add static style and beads in custom edge (#11364)

<!-- Clearly explain the need for these changes: -->
This PR enhances the visual feedback in the flow editor by adding
animated "beads" that travel along edges during execution. This provides
users with clear, real-time visualization of data flow and execution
progress through the graph, making it easier to understand which
connections are active and track execution state.


https://github.com/user-attachments/assets/df4a4650-8192-403f-a200-15f6af95e384

### Changes 🏗️

<!-- Concisely describe all of the changes made in this pull request:
-->
- **Added new edge data types and structure:**
- Added `CustomEdgeData` type with `isStatic`, `beadUp`, `beadDown`, and
`beadData` properties
  - Created `CustomEdge` type extending XYEdge with custom data
  
- **Implemented bead animation components:**
- Added `JSBeads.tsx` - JavaScript-based animation component with
real-time updates
- Added `SVGBeads.tsx` - SVG-based animation component (for future
consideration)
  - Added helper functions for path calculations and bead positioning
  
- **Updated edge rendering:**
  - Modified `CustomEdge` component to display beads during execution
  - Added static edge styling with dashed lines (`stroke-dasharray: 6`)
- Improved visual hierarchy with different stroke styles for
selected/unselected states
  
- **Refactored edge management:**
- Converted `edgeStore` from using `Connection` type to `CustomEdge`
type
- Added `updateEdgeBeads` and `resetEdgeBeads` methods for bead state
management
  - Updated `copyPasteStore` to work with new edge structure
  
- **Added support for static outputs:**
  - Added `staticOutput` property to `CustomNodeData`
- Static edges show continuous bead animation while regular edges show
one-time animation

### 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] Create a flow with multiple blocks and verify beads animate along
edges during execution
- [x] Test that beads increment when execution starts (`beadUp`) and
decrement when completed (`beadDown`)
- [x] Verify static edges display with dashed lines and continuous
animation
  - [x] Confirm copy/paste operations preserve edge data and bead states
  - [x] Test edge animations performance with complex graphs (10+ nodes)
  - [x] Verify bead animations complete properly before disappearing
- [x] Test that multiple beads can animate on the same edge for
concurrent executions
- [x] Verify edge selection/deletion still works with new visualization
- [x] Test that bead state resets properly when starting new executions
This commit is contained in:
Abhimanyu Yadav
2025-11-13 21:06:40 +05:30
committed by GitHub
parent 4d43570552
commit f78a6df96c
15 changed files with 560 additions and 146 deletions

View File

@@ -20,6 +20,7 @@ export const Flow = () => {
useShallow((state) => state.onNodesChange),
);
const nodeTypes = useMemo(() => ({ custom: CustomNode }), []);
const edgeTypes = useMemo(() => ({ custom: CustomEdge }), []);
const { edges, onConnect, onEdgesChange } = useCustomEdge();
// We use this hook to load the graph and convert them into custom nodes and edges.
@@ -51,10 +52,10 @@ export const Flow = () => {
nodes={nodes}
onNodesChange={onNodesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
edges={edges}
onConnect={onConnect}
onEdgesChange={onEdgesChange}
edgeTypes={{ custom: CustomEdge }}
maxZoom={2}
minZoom={0.1}
onDragOver={onDragOver}

View File

@@ -32,6 +32,9 @@ export const useFlow = () => {
const setGraphSchemas = useGraphStore(
useShallow((state) => state.setGraphSchemas),
);
const updateEdgeBeads = useEdgeStore(
useShallow((state) => state.updateEdgeBeads),
);
const { screenToFlowPosition } = useReactFlow();
const addBlock = useNodeStore(useShallow((state) => state.addBlock));
const setBlockMenuOpen = useControlPanelStore(
@@ -109,7 +112,7 @@ export const useFlow = () => {
// adding links
if (graph?.links) {
useEdgeStore.getState().setConnections([]);
useEdgeStore.getState().setEdges([]);
addLinks(graph.links);
}
@@ -130,7 +133,7 @@ export const useFlow = () => {
});
}
// update node execution results in nodes
// update node execution results in nodes, also update edge beads
if (
executionDetails &&
"node_executions" in executionDetails &&
@@ -138,6 +141,7 @@ export const useFlow = () => {
) {
executionDetails.node_executions.forEach((nodeExecution) => {
updateNodeExecutionResult(nodeExecution.node_id, nodeExecution);
updateEdgeBeads(nodeExecution.node_id, nodeExecution);
});
}
}, [customNodes, addNodes, graph?.links, executionDetails, updateNodeStatus]);
@@ -145,8 +149,9 @@ export const useFlow = () => {
useEffect(() => {
return () => {
useNodeStore.getState().setNodes([]);
useEdgeStore.getState().setConnections([]);
useEdgeStore.getState().setEdges([]);
useGraphStore.getState().reset();
useEdgeStore.getState().resetEdgeBeads();
setIsGraphRunning(false);
};
}, []);

View File

@@ -9,6 +9,7 @@ import { useShallow } from "zustand/react/shallow";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { useGraphStore } from "../../../stores/graphStore";
import { useEdgeStore } from "../../../stores/edgeStore";
export const useFlowRealtime = () => {
const api = useBackendAPI();
@@ -21,6 +22,12 @@ export const useFlowRealtime = () => {
const setIsGraphRunning = useGraphStore(
useShallow((state) => state.setIsGraphRunning),
);
const updateEdgeBeads = useEdgeStore(
useShallow((state) => state.updateEdgeBeads),
);
const resetEdgeBeads = useEdgeStore(
useShallow((state) => state.resetEdgeBeads),
);
const [{ flowExecutionID, flowID }] = useQueryStates({
flowExecutionID: parseAsString,
@@ -34,12 +41,12 @@ export const useFlowRealtime = () => {
if (data.graph_exec_id != flowExecutionID) {
return;
}
// TODO: Update the states of nodes
updateNodeExecutionResult(
data.node_id,
data as unknown as NodeExecutionResult,
);
updateStatus(data.node_id, data.status);
updateEdgeBeads(data.node_id, data as unknown as NodeExecutionResult);
},
);
@@ -82,8 +89,9 @@ export const useFlowRealtime = () => {
deregisterNodeExecutionEvent();
deregisterGraphExecutionSubscription();
deregisterGraphExecutionStatusEvent();
resetEdgeBeads();
};
}, [api, flowExecutionID]);
}, [api, flowExecutionID, resetEdgeBeads]);
return {};
};

View File

@@ -1,17 +1,30 @@
import { Button } from "@/components/atoms/Button/Button";
import {
BaseEdge,
Edge as XYEdge,
EdgeLabelRenderer,
EdgeProps,
getBezierPath,
} from "@xyflow/react";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { XIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { NodeExecutionResult } from "@/lib/autogpt-server-api";
import { JSBeads } from "./components/JSBeads";
export type CustomEdgeData = {
isStatic?: boolean;
beadUp?: number;
beadDown?: number;
beadData?: Map<string, NodeExecutionResult["status"]>;
};
export type CustomEdge = XYEdge<CustomEdgeData, "custom">;
import { memo } from "react";
const CustomEdge = ({
id,
data,
sourceX,
sourceY,
targetX,
@@ -20,8 +33,8 @@ const CustomEdge = ({
targetPosition,
markerEnd,
selected,
}: EdgeProps) => {
const removeConnection = useEdgeStore((state) => state.removeConnection);
}: EdgeProps<CustomEdge>) => {
const removeConnection = useEdgeStore((state) => state.removeEdge);
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
@@ -31,14 +44,27 @@ const CustomEdge = ({
targetPosition,
});
const isStatic = data?.isStatic ?? false;
const beadUp = data?.beadUp ?? 0;
const beadDown = data?.beadDown ?? 0;
return (
<>
<BaseEdge
path={edgePath}
markerEnd={markerEnd}
className={
selected ? "[stroke:#555]" : "[stroke:#555]80 hover:[stroke:#555]"
}
className={cn(
isStatic && "!stroke-[1.5px] [stroke-dasharray:6]",
selected
? "stroke-zinc-800"
: "stroke-zinc-500/50 hover:stroke-zinc-500",
)}
/>
<JSBeads
beadUp={beadUp}
beadDown={beadDown}
edgePath={edgePath}
beadsKey={`beads-${id}-${sourceX}-${sourceY}-${targetX}-${targetY}`}
/>
<EdgeLabelRenderer>
<Button

View File

@@ -0,0 +1,167 @@
// This component uses JS animation [It's replica of legacy builder]
// Problem - It lags at real time updates, because of state change
import { useCallback, useEffect, useRef, useState } from "react";
import {
getLengthOfPathInPixels,
getPointAtT,
getTForDistance,
setTargetPositions,
} from "../helpers";
const BEAD_DIAMETER = 10;
const ANIMATION_DURATION = 500;
interface Bead {
t: number;
targetT: number;
startTime: number;
}
interface BeadsProps {
beadUp: number;
beadDown: number;
edgePath: string;
beadsKey: string;
isStatic?: boolean;
}
export const JSBeads = ({
beadUp,
beadDown,
edgePath,
beadsKey,
}: BeadsProps) => {
const [beads, setBeads] = useState<{
beads: Bead[];
created: number;
destroyed: number;
}>({ beads: [], created: 0, destroyed: 0 });
const beadsRef = useRef(beads);
const totalLength = getLengthOfPathInPixels(edgePath);
const animationFrameRef = useRef<number | null>(null);
const lastFrameTimeRef = useRef<number>(0);
const pathRef = useRef<SVGPathElement | null>(null);
const getPointAtTWrapper = (t: number) => {
return getPointAtT(t, edgePath, pathRef);
};
const getTForDistanceWrapper = (distanceFromEnd: number) => {
return getTForDistance(distanceFromEnd, totalLength);
};
const setTargetPositionsWrapper = useCallback(
(beads: Bead[]) => {
return setTargetPositions(beads, BEAD_DIAMETER, getTForDistanceWrapper);
},
[getTForDistanceWrapper],
);
beadsRef.current = beads;
useEffect(() => {
pathRef.current = null;
}, [edgePath]);
useEffect(() => {
if (
beadUp === 0 &&
beadDown === 0 &&
(beads.created > 0 || beads.destroyed > 0)
) {
setBeads({ beads: [], created: 0, destroyed: 0 });
return;
}
// Adding beads
if (beadUp > beads.created) {
setBeads(({ beads, created, destroyed }) => {
const newBeads = [];
for (let i = 0; i < beadUp - created; i++) {
newBeads.push({ t: 0, targetT: 0, startTime: Date.now() });
}
const b = setTargetPositionsWrapper([...beads, ...newBeads]);
return { beads: b, created: beadUp, destroyed };
});
}
const animate = (currentTime: number) => {
const beads = beadsRef.current;
if (
(beadUp === beads.created && beads.created === beads.destroyed) ||
beads.beads.every((bead) => bead.t >= bead.targetT)
) {
animationFrameRef.current = null;
return;
}
const deltaTime = lastFrameTimeRef.current
? currentTime - lastFrameTimeRef.current
: 16;
lastFrameTimeRef.current = currentTime;
setBeads(({ beads, created, destroyed }) => {
let destroyedCount = 0;
const newBeads = beads
.map((bead) => {
const progressIncrement = deltaTime / ANIMATION_DURATION;
const t = Math.min(
bead.t + bead.targetT * progressIncrement,
bead.targetT,
);
return { ...bead, t };
})
.filter((bead, index) => {
const removeCount = beadDown - destroyed;
if (bead.t >= bead.targetT && index < removeCount) {
destroyedCount++;
return false;
}
return true;
});
return {
beads: setTargetPositionsWrapper(newBeads),
created,
destroyed: destroyed + destroyedCount,
};
});
animationFrameRef.current = requestAnimationFrame(animate);
};
lastFrameTimeRef.current = 0;
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
}, [beadUp, beadDown, setTargetPositionsWrapper]);
return (
<>
{beads.beads.map((bead, index) => {
const pos = getPointAtTWrapper(bead.t);
return (
<circle
key={`${beadsKey}-${index}`}
cx={pos.x}
cy={pos.y}
r={BEAD_DIAMETER / 2}
fill="#8d8d95"
/>
);
})}
</>
);
};

View File

@@ -0,0 +1,85 @@
// This component uses SVG animation [Will see in future if we can make it work]
// Problem - it doesn't work with real time updates
import { useEffect, useMemo, useRef, useState } from "react";
import { getLengthOfPathInPixels } from "../helpers";
const BEAD_SPACING = 12;
const BASE_STOP_DISTANCE = 15;
const ANIMATION_DURATION = 0.5;
const ANIMATION_DELAY_PER_BEAD = 0.05;
interface BeadsProps {
beadUp: number;
beadDown: number;
edgePath: string;
beadsKey: string;
}
export const SVGBeads = ({
beadUp,
beadDown,
edgePath,
beadsKey,
}: BeadsProps) => {
const [removedBeads, setRemovedBeads] = useState<Set<number>>(new Set());
const animateRef = useRef<SVGAElement | null>(null);
const visibleBeads = useMemo(() => {
return Array.from({ length: Math.max(0, beadUp) }, (_, i) => i).filter(
(index) => !removedBeads.has(index),
);
}, [beadUp, removedBeads]);
const totalLength = getLengthOfPathInPixels(edgePath);
useEffect(() => {
setRemovedBeads(new Set());
}, [beadUp]);
useEffect(() => {
const elem = animateRef.current;
if (elem) {
const handleEnd = () => {
if (beadDown > 0) {
const beadsToRemove = Array.from(
{ length: beadDown },
(_, i) => beadUp - beadDown + i,
);
beadsToRemove.forEach((beadIndex) => {
setRemovedBeads((prev) => new Set(prev).add(beadIndex));
});
}
};
elem.addEventListener("endEvent", handleEnd);
return () => elem.removeEventListener("endEvent", handleEnd);
}
}, [beadUp, beadDown]);
return (
<>
{visibleBeads.map((index) => {
const stopDistance = BASE_STOP_DISTANCE + index * BEAD_SPACING;
const beadStopPoint =
Math.max(0, totalLength - stopDistance) / totalLength;
return (
<circle key={`${beadsKey}-${index}`} r="5" fill="#8d8d95">
<animateMotion
ref={animateRef}
dur={`${ANIMATION_DURATION}s`}
repeatCount="1"
fill="freeze"
path={edgePath}
begin={`${index * ANIMATION_DELAY_PER_BEAD}s`}
keyPoints={`0;${beadStopPoint}`}
keyTimes="0;1"
calcMode="linear"
/>
</circle>
);
})}
</>
);
};

View File

@@ -10,3 +10,53 @@ export const convertConnectionsToBackendLinks = (
source_name: c.sourceHandle || "",
sink_name: c.targetHandle || "",
}));
// ------------------- SVG Beads helpers -------------------
export const getLengthOfPathInPixels = (path: string) => {
const pathElement = document.createElementNS(
"http://www.w3.org/2000/svg",
"path",
);
pathElement.setAttribute("d", path);
return pathElement.getTotalLength();
};
// ------------------- JS Beads helpers -------------------
export const getPointAtT = (
t: number,
edgePath: string,
pathRef: React.MutableRefObject<SVGPathElement | null>,
) => {
if (!pathRef.current) {
const tempPath = document.createElementNS(
"http://www.w3.org/2000/svg",
"path",
);
tempPath.setAttribute("d", edgePath);
pathRef.current = tempPath;
}
const totalLength = pathRef.current.getTotalLength();
const point = pathRef.current.getPointAtLength(t * totalLength);
return { x: point.x, y: point.y };
};
export const getTForDistance = (
distanceFromEnd: number,
totalLength: number,
) => {
return Math.max(0, Math.min(1, 1 - distanceFromEnd / totalLength));
};
export const setTargetPositions = (
beads: { t: number; targetT: number; startTime: number }[],
beadDiameter: number,
getTForDistanceFunc: (distanceFromEnd: number) => number,
) => {
return beads.map((bead, index) => ({
...bead,
targetT: getTForDistanceFunc(beadDiameter * (index + 1)),
}));
};

View File

@@ -1,35 +1,12 @@
import {
Connection as RFConnection,
Edge as RFEdge,
MarkerType,
EdgeChange,
} from "@xyflow/react";
import { Connection as RFConnection, EdgeChange } from "@xyflow/react";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { useCallback, useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { useCallback } from "react";
import { useNodeStore } from "../../../stores/nodeStore";
export const useCustomEdge = () => {
const connections = useEdgeStore(useShallow((s) => s.connections));
const addConnection = useEdgeStore((s) => s.addConnection);
const removeConnection = useEdgeStore((s) => s.removeConnection);
const edges: RFEdge[] = useMemo(
() =>
connections.map((c) => ({
id: c.edge_id,
type: "custom",
source: c.source,
target: c.target,
sourceHandle: c.sourceHandle,
targetHandle: c.targetHandle,
markerEnd: {
type: MarkerType.ArrowClosed,
strokeWidth: 2,
color: "#555",
},
})),
[connections],
);
const edges = useEdgeStore((s) => s.edges);
const addEdge = useEdgeStore((s) => s.addEdge);
const removeEdge = useEdgeStore((s) => s.removeEdge);
const onConnect = useCallback(
(conn: RFConnection) => {
@@ -40,31 +17,42 @@ export const useCustomEdge = () => {
!conn.targetHandle
)
return;
const exists = connections.some(
(c) =>
c.source === conn.source &&
c.target === conn.target &&
c.sourceHandle === conn.sourceHandle &&
c.targetHandle === conn.targetHandle,
const exists = edges.some(
(e) =>
e.source === conn.source &&
e.target === conn.target &&
e.sourceHandle === conn.sourceHandle &&
e.targetHandle === conn.targetHandle,
);
if (exists) return;
addConnection({
const nodes = useNodeStore.getState().nodes;
const isStatic = nodes.find((n) => n.id === conn.source)?.data
?.staticOutput;
addEdge({
source: conn.source,
target: conn.target,
sourceHandle: conn.sourceHandle,
targetHandle: conn.targetHandle,
data: {
isStatic,
},
});
},
[connections, addConnection],
[edges, addEdge],
);
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => {
changes.forEach((ch) => {
if (ch.type === "remove") removeConnection(ch.id);
changes.forEach((change) => {
if (change.type === "remove") {
removeEdge(change.id);
}
});
},
[removeConnection],
[removeEdge],
);
return { edges, onConnect, onEdgesChange };

View File

@@ -21,6 +21,7 @@ export type CustomNodeData = {
block_id: string;
status?: AgentExecutionStatus;
nodeExecutionResult?: NodeExecutionResult;
staticOutput?: boolean;
// TODO : We need better type safety for the following backend fields.
costs: BlockCost[];
categories: BlockInfoCategoriesItem[];

View File

@@ -5,20 +5,15 @@ import { useEdgeStore } from "../stores/edgeStore";
import { useNodeStore } from "../stores/nodeStore";
import { scrollbarStyles } from "@/components/styles/scrollbars";
import { cn } from "@/lib/utils";
import { customEdgeToLink } from "./helper";
export const RightSidebar = () => {
const connections = useEdgeStore((s) => s.connections);
const edges = useEdgeStore((s) => s.edges);
const nodes = useNodeStore((s) => s.nodes);
const backendLinks: Link[] = useMemo(
() =>
connections.map((c) => ({
source_id: c.source,
sink_id: c.target,
source_name: c.sourceHandle,
sink_name: c.targetHandle,
})),
[connections],
() => edges.map(customEdgeToLink),
[edges],
);
return (
@@ -61,16 +56,16 @@ export const RightSidebar = () => {
Links ({backendLinks.length})
</h3>
<div className="mb-6 space-y-3">
{connections.map((c) => (
{backendLinks.map((l) => (
<div
key={c.edge_id}
key={l.id}
className="rounded border p-2 text-xs dark:border-slate-700"
>
<div className="font-medium">
{c.source}[{c.sourceHandle}] {c.target}[{c.targetHandle}]
{l.source_id}[{l.source_name}] {l.sink_id}[{l.sink_name}]
</div>
<div className="mt-1 text-slate-500 dark:text-slate-400">
edge_id: {c.edge_id}
edge_id: {l.id}
</div>
</div>
))}

View File

@@ -6,6 +6,8 @@ import {
import { BlockUIType } from "./types";
import { NodeModel } from "@/app/api/__generated__/models/nodeModel";
import { NodeModelMetadata } from "@/app/api/__generated__/models/nodeModelMetadata";
import { Link } from "@/app/api/__generated__/models/link";
import { CustomEdge } from "./FlowEditor/edges/CustomEdge";
export const convertBlockInfoIntoCustomNodeData = (
block: BlockInfo,
@@ -19,6 +21,7 @@ export const convertBlockInfoIntoCustomNodeData = (
outputSchema: block.outputSchema,
categories: block.categories,
uiType: block.uiType as BlockUIType,
staticOutput: block.staticOutput,
block_id: block.id,
costs: block.costs,
};
@@ -57,6 +60,27 @@ export const convertNodesPlusBlockInfoIntoCustomNodes = (
return customNode;
};
export const linkToCustomEdge = (link: Link): CustomEdge => ({
id: link.id ?? "",
type: "custom" as const,
source: link.source_id,
target: link.sink_id,
sourceHandle: link.source_name,
targetHandle: link.sink_name,
data: {
isStatic: link.is_static,
},
});
export const customEdgeToLink = (edge: CustomEdge): Link => ({
id: edge.id || undefined,
source_id: edge.source,
sink_id: edge.target,
source_name: edge.sourceHandle || "",
sink_name: edge.targetHandle || "",
is_static: edge.data?.isStatic,
});
export enum BlockCategory {
AI = "AI",
SOCIAL = "SOCIAL",

View File

@@ -1,12 +1,13 @@
import { create } from "zustand";
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
import { Connection, useEdgeStore } from "./edgeStore";
import { useEdgeStore } from "./edgeStore";
import { Key, storage } from "@/services/storage/local-storage";
import { useNodeStore } from "./nodeStore";
import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
interface CopyableData {
nodes: CustomNode[];
connections: Connection[];
edges: CustomEdge[];
}
type CopyPasteStore = {
@@ -17,14 +18,14 @@ type CopyPasteStore = {
export const useCopyPasteStore = create<CopyPasteStore>(() => ({
copySelectedNodes: () => {
const { nodes } = useNodeStore.getState();
const { connections } = useEdgeStore.getState();
const { edges } = useEdgeStore.getState();
const selectedNodes = nodes.filter((node) => node.selected);
const selectedNodeIds = new Set(selectedNodes.map((node) => node.id));
const selectedConnections = connections.filter(
(conn) =>
selectedNodeIds.has(conn.source) && selectedNodeIds.has(conn.target),
const selectedEdges = edges.filter(
(edge) =>
selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target),
);
const copiedData: CopyableData = {
@@ -34,7 +35,7 @@ export const useCopyPasteStore = create<CopyPasteStore>(() => ({
...node.data,
},
})),
connections: selectedConnections,
edges: selectedEdges,
};
storage.set(Key.COPIED_FLOW_DATA, JSON.stringify(copiedData));
@@ -46,7 +47,7 @@ export const useCopyPasteStore = create<CopyPasteStore>(() => ({
const copiedData = JSON.parse(copiedDataString) as CopyableData;
const { addNode } = useNodeStore.getState();
const { addConnection } = useEdgeStore.getState();
const { addEdge } = useEdgeStore.getState();
const oldToNewIdMap: Record<string, string> = {};
@@ -85,15 +86,15 @@ export const useCopyPasteStore = create<CopyPasteStore>(() => ({
});
});
copiedData.connections.forEach((conn) => {
const newSourceId = oldToNewIdMap[conn.source] ?? conn.source;
const newTargetId = oldToNewIdMap[conn.target] ?? conn.target;
copiedData.edges.forEach((edge) => {
const newSourceId = oldToNewIdMap[edge.source] ?? edge.source;
const newTargetId = oldToNewIdMap[edge.target] ?? edge.target;
addConnection({
addEdge({
source: newSourceId,
target: newTargetId,
sourceHandle: conn.sourceHandle ?? "",
targetHandle: conn.targetHandle ?? "",
sourceHandle: edge.sourceHandle ?? "",
targetHandle: edge.targetHandle ?? "",
});
});
},

View File

@@ -1,103 +1,165 @@
import { create } from "zustand";
import { convertConnectionsToBackendLinks } from "../components/FlowEditor/edges/helpers";
import { Link } from "@/app/api/__generated__/models/link";
export type Connection = {
edge_id: string;
source: string;
sourceHandle: string;
target: string;
targetHandle: string;
};
import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
import { customEdgeToLink, linkToCustomEdge } from "../components/helper";
import { MarkerType } from "@xyflow/react";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
type EdgeStore = {
connections: Connection[];
edges: CustomEdge[];
setConnections: (connections: Connection[]) => void;
addConnection: (
conn: Omit<Connection, "edge_id"> & { edge_id?: string },
) => void;
removeConnection: (edge_id: string) => void;
upsertMany: (conns: Connection[]) => void;
setEdges: (edges: CustomEdge[]) => void;
addEdge: (edge: Omit<CustomEdge, "id"> & { id?: string }) => CustomEdge;
removeEdge: (edgeId: string) => void;
upsertMany: (edges: CustomEdge[]) => void;
getNodeConnections: (nodeId: string) => Connection[];
getNodeEdges: (nodeId: string) => CustomEdge[];
isInputConnected: (nodeId: string, handle: string) => boolean;
isOutputConnected: (nodeId: string, handle: string) => boolean;
getBackendLinks: () => Link[];
addLinks: (links: Link[]) => void;
getAllHandleIdsOfANode: (nodeId: string) => string[];
updateEdgeBeads: (
targetNodeId: string,
executionResult: NodeExecutionResult,
) => void;
resetEdgeBeads: () => void;
};
function makeEdgeId(conn: Omit<Connection, "edge_id">) {
return `${conn.source}:${conn.sourceHandle}->${conn.target}:${conn.targetHandle}`;
function makeEdgeId(edge: Omit<CustomEdge, "id">) {
return `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}`;
}
export const useEdgeStore = create<EdgeStore>((set, get) => ({
connections: [],
edges: [],
setConnections: (connections) => set({ connections }),
setEdges: (edges) => set({ edges }),
addConnection: (conn) => {
const edge_id = conn.edge_id || makeEdgeId(conn);
const newConn: Connection = { edge_id, ...conn };
addEdge: (edge) => {
const id = edge.id || makeEdgeId(edge);
const newEdge: CustomEdge = {
type: "custom" as const,
markerEnd: {
type: MarkerType.ArrowClosed,
strokeWidth: 2,
color: "#555",
},
...edge,
id,
};
set((state) => {
const exists = state.connections.some(
(c) =>
c.source === newConn.source &&
c.target === newConn.target &&
c.sourceHandle === newConn.sourceHandle &&
c.targetHandle === newConn.targetHandle,
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 { connections: [...state.connections, newConn] };
return { edges: [...state.edges, newEdge] };
});
return { edge_id, ...conn };
return newEdge;
},
removeConnection: (edge_id) =>
removeEdge: (edgeId) =>
set((state) => ({
connections: state.connections.filter((c) => c.edge_id !== edge_id),
edges: state.edges.filter((e) => e.id !== edgeId),
})),
upsertMany: (conns) =>
upsertMany: (edges) =>
set((state) => {
const byKey = new Map(state.connections.map((c) => [c.edge_id, c]));
conns.forEach((c) => {
byKey.set(c.edge_id, c);
const byKey = new Map(state.edges.map((e) => [e.id, e]));
edges.forEach((e) => {
byKey.set(e.id, e);
});
return { connections: Array.from(byKey.values()) };
return { edges: Array.from(byKey.values()) };
}),
getNodeConnections: (nodeId) =>
get().connections.filter((c) => c.source === nodeId || c.target === nodeId),
getNodeEdges: (nodeId) =>
get().edges.filter((e) => e.source === nodeId || e.target === nodeId),
isInputConnected: (nodeId, handle) =>
get().connections.some(
(c) => c.target === nodeId && c.targetHandle === handle,
),
get().edges.some((e) => e.target === nodeId && e.targetHandle === handle),
isOutputConnected: (nodeId, handle) =>
get().connections.some(
(c) => c.source === nodeId && c.sourceHandle === handle,
),
getBackendLinks: () => convertConnectionsToBackendLinks(get().connections),
get().edges.some((e) => e.source === nodeId && e.sourceHandle === handle),
addLinks: (links) =>
getBackendLinks: () => get().edges.map(customEdgeToLink),
addLinks: (links) => {
links.forEach((link) => {
get().addConnection({
edge_id: link.id ?? "",
source: link.source_id,
target: link.sink_id,
sourceHandle: link.source_name,
targetHandle: link.sink_name,
});
}),
get().addEdge(linkToCustomEdge(link));
});
},
getAllHandleIdsOfANode: (nodeId) =>
get()
.connections.filter((c) => c.target === nodeId)
.map((c) => c.targetHandle),
.edges.filter((e) => e.target === nodeId)
.map((e) => e.targetHandle || ""),
updateEdgeBeads: (
targetNodeId: string,
executionResult: NodeExecutionResult,
) => {
set((state) => ({
edges: state.edges.map((edge) => {
if (edge.target !== targetNodeId) {
return edge;
}
const beadData =
edge.data?.beadData ??
new Map<string, NodeExecutionResult["status"]>();
if (
edge.targetHandle &&
edge.targetHandle in executionResult.input_data
) {
beadData.set(executionResult.node_exec_id, executionResult.status);
}
let beadUp = 0;
let beadDown = 0;
beadData.forEach((status) => {
beadUp++;
if (status !== "INCOMPLETE") {
beadDown++;
}
});
if (edge.data?.isStatic && beadUp > 0) {
beadUp = beadDown + 1;
}
return {
...edge,
data: {
...edge.data,
beadUp,
beadDown,
beadData,
},
};
}),
}));
},
resetEdgeBeads: () => {
set((state) => ({
edges: state.edges.map((edge) => ({
...edge,
data: {
...edge.data,
beadUp: 0,
beadDown: 0,
beadData: new Map(),
},
})),
}));
},
}));

View File

@@ -2,12 +2,13 @@ import { create } from "zustand";
import isEqual from "lodash/isEqual";
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
import { Connection, useEdgeStore } from "./edgeStore";
import { useEdgeStore } from "./edgeStore";
import { useNodeStore } from "./nodeStore";
import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
type HistoryState = {
nodes: CustomNode[];
connections: Connection[];
edges: CustomEdge[];
};
type HistoryStore = {
@@ -24,7 +25,7 @@ type HistoryStore = {
const MAX_HISTORY = 50;
export const useHistoryStore = create<HistoryStore>((set, get) => ({
past: [{ nodes: [], connections: [] }],
past: [{ nodes: [], edges: [] }],
future: [],
pushState: (state: HistoryState) => {
@@ -50,7 +51,7 @@ export const useHistoryStore = create<HistoryStore>((set, get) => ({
const previousState = past[past.length - 2];
useNodeStore.getState().setNodes(previousState.nodes);
useEdgeStore.getState().setConnections(previousState.connections);
useEdgeStore.getState().setEdges(previousState.edges);
set({
past: past.slice(0, -1),
@@ -65,7 +66,7 @@ export const useHistoryStore = create<HistoryStore>((set, get) => ({
const nextState = future[0];
useNodeStore.getState().setNodes(nextState.nodes);
useEdgeStore.getState().setConnections(nextState.connections);
useEdgeStore.getState().setEdges(nextState.edges);
set({
past: [...past, nextState],
@@ -76,5 +77,5 @@ export const useHistoryStore = create<HistoryStore>((set, get) => ({
canUndo: () => get().past.length > 1,
canRedo: () => get().future.length > 0,
clear: () => set({ past: [{ nodes: [], connections: [] }], future: [] }),
clear: () => set({ past: [{ nodes: [], edges: [] }], future: [] }),
}));

View File

@@ -49,7 +49,7 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
onNodesChange: (changes) => {
const prevState = {
nodes: get().nodes,
connections: useEdgeStore.getState().connections,
edges: useEdgeStore.getState().edges,
};
const shouldTrack = changes.some(
(change) =>
@@ -94,7 +94,7 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
const newState = {
nodes: get().nodes,
connections: useEdgeStore.getState().connections,
edges: useEdgeStore.getState().edges,
};
useHistoryStore.getState().pushState(newState);