mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 15:17:59 -05:00
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:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -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 {};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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)),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ?? "",
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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: [] }),
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user