mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-10 07:38:04 -05:00
feat(frontend): add agent execution functionality in new builder (#11186)
This PR implements real-time agent execution functionality in the new flow editor, enabling users to run, monitor, and view results of their agent workflows directly within the builder interface. https://github.com/user-attachments/assets/8a730e08-f88d-49d4-be31-980e2c7a2f83 #### Key Features Added: ##### 1. **Agent Execution Controls** - Added "Run Agent" / "Stop Agent" button with gradient styling in the builder interface - Implemented execution state management through a new `graphStore` for tracking running status - Save graph automatically before execution to ensure latest changes are persisted ##### 2. **Real-time Execution Monitoring** - Implemented WebSocket-based real-time updates for node execution status via `useFlowRealtime` hook - Subscribe to graph execution events and node execution events for live status tracking - Visual execution status badges on nodes showing states: `QUEUED`, `RUNNING`, `COMPLETED`, `FAILED`, etc. - Animated gradient border effect when agent is actively running ##### 3. **Node Execution Results Display** - New `NodeDataRenderer` component to display input/output data for each executed node - Collapsible result sections with formatted JSON display - Prepared UI for future functionality: copy, info, and expand actions for node data #### Technical Implementation: - **State Management**: Extended `nodeStore` with execution status and result tracking methods - **WebSocket Integration**: Real-time communication for execution updates without polling - **Component Architecture**: Modular components for execution controls, status display, and result rendering - **Visual Feedback**: Color-coded status badges and animated borders for clear execution state indication #### TODO Items for Future PRs: - Complete implementation of node result action buttons (copy, info, expand) - Add agent output display component - Implement schedule run functionality - Handle credential and input parameters for graph execution - Add tooltips for better UX ### Checklist - [x] Create a new agent with at least 3 blocks and verify execution starts correctly - [x] Verify real-time status updates appear on nodes during execution - [x] Confirm execution results display in the node output sections - [x] Verify the animated border appears when agent is running - [x] Check that node status badges show correct states (QUEUED, RUNNING, COMPLETED, etc.) - [x] Test WebSocket reconnection after connection loss - [x] Verify graph is saved before execution begins
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { RunGraph } from "./components/RunGraph";
|
||||
|
||||
export const BuilderActions = () => {
|
||||
return (
|
||||
<div className="absolute bottom-4 left-[50%] z-[100] -translate-x-1/2">
|
||||
{/* TODO: Add Agent Output */}
|
||||
<RunGraph />
|
||||
{/* TODO: Add Schedule run button */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { PlayIcon } from "lucide-react";
|
||||
import { useRunGraph } from "./useRunGraph";
|
||||
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { StopIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const RunGraph = () => {
|
||||
const { runGraph, isSaving } = useRunGraph();
|
||||
const isGraphRunning = useGraphStore(
|
||||
useShallow((state) => state.isGraphRunning),
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className={cn(
|
||||
"relative min-w-44 border-none bg-gradient-to-r from-purple-500 to-pink-500 text-lg",
|
||||
)}
|
||||
onClick={() => runGraph()}
|
||||
>
|
||||
{!isGraphRunning && !isSaving ? (
|
||||
<PlayIcon className="mr-1 size-5" />
|
||||
) : (
|
||||
<StopIcon className="mr-1 size-5" />
|
||||
)}
|
||||
{isGraphRunning || isSaving ? "Stop Agent" : "Run Agent"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { usePostV1ExecuteGraphAgent } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { useNewSaveControl } from "../../../NewControlPanel/NewSaveControl/useNewSaveControl";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { GraphExecutionMeta } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/use-agent-runs";
|
||||
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export const useRunGraph = () => {
|
||||
const { onSubmit: onSaveGraph, isLoading: isSaving } = useNewSaveControl({
|
||||
showToast: false,
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const setIsGraphRunning = useGraphStore(
|
||||
useShallow((state) => state.setIsGraphRunning),
|
||||
);
|
||||
const [{ flowID, flowVersion }, setQueryStates] = useQueryStates({
|
||||
flowID: parseAsString,
|
||||
flowVersion: parseAsInteger,
|
||||
flowExecutionID: parseAsString,
|
||||
});
|
||||
|
||||
const { mutateAsync: executeGraph } = usePostV1ExecuteGraphAgent({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
const { id } = response.data as GraphExecutionMeta;
|
||||
setQueryStates({
|
||||
flowExecutionID: id,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsGraphRunning(false);
|
||||
|
||||
toast({
|
||||
title: (error.detail as string) ?? "An unexpected error occurred.",
|
||||
description: "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const runGraph = async () => {
|
||||
setIsGraphRunning(true);
|
||||
await onSaveGraph(undefined);
|
||||
|
||||
// Todo : We need to save graph which has inputs and credentials inputs
|
||||
await executeGraph({
|
||||
graphId: flowID ?? "",
|
||||
graphVersion: flowVersion || null,
|
||||
data: {
|
||||
inputs: {},
|
||||
credentials_inputs: {},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
runGraph,
|
||||
isSaving,
|
||||
};
|
||||
};
|
||||
@@ -7,7 +7,11 @@ import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useMemo } from "react";
|
||||
import { CustomNode } from "../nodes/CustomNode/CustomNode";
|
||||
import { useCustomEdge } from "../edges/useCustomEdge";
|
||||
import { GraphLoadingBox } from "./GraphLoadingBox";
|
||||
import { useFlowRealtime } from "./useFlowRealtime";
|
||||
import { GraphLoadingBox } from "./components/GraphLoadingBox";
|
||||
import { BuilderActions } from "../BuilderActions/BuilderActions";
|
||||
import { RunningBackground } from "./components/RunningBackground";
|
||||
import { useGraphStore } from "../../../stores/graphStore";
|
||||
|
||||
export const Flow = () => {
|
||||
const nodes = useNodeStore(useShallow((state) => state.nodes));
|
||||
@@ -18,8 +22,11 @@ export const Flow = () => {
|
||||
const { edges, onConnect, onEdgesChange } = useCustomEdge();
|
||||
|
||||
// We use this hook to load the graph and convert them into custom nodes and edges.
|
||||
const { isFlowContentLoading } = useFlow();
|
||||
useFlow();
|
||||
useFlowRealtime();
|
||||
|
||||
const { isFlowContentLoading } = useFlow();
|
||||
const { isGraphRunning } = useGraphStore();
|
||||
return (
|
||||
<div className="flex h-full w-full dark:bg-slate-900">
|
||||
<div className="relative flex-1">
|
||||
@@ -37,7 +44,9 @@ export const Flow = () => {
|
||||
<Background />
|
||||
<Controls />
|
||||
<NewControlPanel />
|
||||
<BuilderActions />
|
||||
{isFlowContentLoading && <GraphLoadingBox />}
|
||||
{isGraphRunning && <RunningBackground />}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ export const GraphLoadingBox = () => {
|
||||
<div className="absolute left-[50%] top-[50%] z-[99] -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="flex flex-col items-center gap-4 rounded-xlarge border border-gray-200 bg-white p-8 shadow-lg dark:border-gray-700 dark:bg-slate-800">
|
||||
<div className="relative h-12 w-12">
|
||||
<div className="absolute inset-0 animate-spin rounded-full border-4 border-gray-200 border-t-black dark:border-gray-700 dark:border-t-blue-400"></div>
|
||||
<div className="absolute inset-0 animate-spin rounded-full border-4 border-violet-200 border-t-violet-500 dark:border-gray-700 dark:border-t-blue-400"></div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Text variant="h4">Loading Flow</Text>
|
||||
@@ -0,0 +1,157 @@
|
||||
export const RunningBackground = () => {
|
||||
return (
|
||||
<div className="absolute inset-0 h-full w-full">
|
||||
<style jsx>{`
|
||||
@keyframes rotateGradient {
|
||||
0% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#bc82f3 17%,
|
||||
#f5b9ea 24%,
|
||||
#8d99ff 35%,
|
||||
#aa6eee 58%,
|
||||
#ff6778 70%,
|
||||
#ffba71 81%,
|
||||
#c686ff 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
14.28% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#c686ff 17%,
|
||||
#bc82f3 24%,
|
||||
#f5b9ea 35%,
|
||||
#8d99ff 58%,
|
||||
#aa6eee 70%,
|
||||
#ff6778 81%,
|
||||
#ffba71 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
28.56% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#ffba71 17%,
|
||||
#c686ff 24%,
|
||||
#bc82f3 35%,
|
||||
#f5b9ea 58%,
|
||||
#8d99ff 70%,
|
||||
#aa6eee 81%,
|
||||
#ff6778 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
42.84% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#ff6778 17%,
|
||||
#ffba71 24%,
|
||||
#c686ff 35%,
|
||||
#bc82f3 58%,
|
||||
#f5b9ea 70%,
|
||||
#8d99ff 81%,
|
||||
#aa6eee 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
57.12% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#aa6eee 17%,
|
||||
#ff6778 24%,
|
||||
#ffba71 35%,
|
||||
#c686ff 58%,
|
||||
#bc82f3 70%,
|
||||
#f5b9ea 81%,
|
||||
#8d99ff 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
71.4% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#8d99ff 17%,
|
||||
#aa6eee 24%,
|
||||
#ff6778 35%,
|
||||
#ffba71 58%,
|
||||
#c686ff 70%,
|
||||
#bc82f3 81%,
|
||||
#f5b9ea 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
85.68% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#f5b9ea 17%,
|
||||
#8d99ff 24%,
|
||||
#aa6eee 35%,
|
||||
#ff6778 58%,
|
||||
#ffba71 70%,
|
||||
#c686ff 81%,
|
||||
#bc82f3 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
100% {
|
||||
border-image: linear-gradient(
|
||||
to right,
|
||||
#bc82f3 17%,
|
||||
#f5b9ea 24%,
|
||||
#8d99ff 35%,
|
||||
#aa6eee 58%,
|
||||
#ff6778 70%,
|
||||
#ffba71 81%,
|
||||
#c686ff 92%
|
||||
)
|
||||
1;
|
||||
}
|
||||
}
|
||||
.animate-gradient {
|
||||
animation: rotateGradient 8s linear infinite;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
className="animate-gradient absolute inset-0 bg-transparent blur-xl"
|
||||
style={{
|
||||
borderWidth: "15px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
borderImage:
|
||||
"linear-gradient(to right, #BC82F3 17%, #F5B9EA 24%, #8D99FF 35%, #AA6EEE 58%, #FF6778 70%, #FFBA71 81%, #C686FF 92%) 1",
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="animate-gradient absolute inset-0 bg-transparent blur-lg"
|
||||
style={{
|
||||
borderWidth: "10px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
borderImage:
|
||||
"linear-gradient(to right, #BC82F3 17%, #F5B9EA 24%, #8D99FF 35%, #AA6EEE 58%, #FF6778 70%, #FFBA71 81%, #C686FF 92%) 1",
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="animate-gradient absolute inset-0 bg-transparent blur-md"
|
||||
style={{
|
||||
borderWidth: "6px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
borderImage:
|
||||
"linear-gradient(to right, #BC82F3 17%, #F5B9EA 24%, #8D99FF 35%, #AA6EEE 58%, #FF6778 70%, #FFBA71 81%, #C686FF 92%) 1",
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="animate-gradient absolute inset-0 bg-transparent blur-sm"
|
||||
style={{
|
||||
borderWidth: "6px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
borderImage:
|
||||
"linear-gradient(to right, #BC82F3 17%, #F5B9EA 24%, #8D99FF 35%, #AA6EEE 58%, #FF6778 70%, #FFBA71 81%, #C686FF 92%) 1",
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useGetV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/default/default";
|
||||
import { useGetV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import {
|
||||
useGetV1GetExecutionDetails,
|
||||
useGetV1GetSpecificGraph,
|
||||
} from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
||||
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
@@ -8,16 +11,39 @@ import { useShallow } from "zustand/react/shallow";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { convertNodesPlusBlockInfoIntoCustomNodes } from "../../helper";
|
||||
import { useEdgeStore } from "../../../stores/edgeStore";
|
||||
import { GetV1GetExecutionDetails200 } from "@/app/api/__generated__/models/getV1GetExecutionDetails200";
|
||||
import { useGraphStore } from "../../../stores/graphStore";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
|
||||
export const useFlow = () => {
|
||||
const addNodes = useNodeStore(useShallow((state) => state.addNodes));
|
||||
const addLinks = useEdgeStore(useShallow((state) => state.addLinks));
|
||||
|
||||
const [{ flowID, flowVersion }] = useQueryStates({
|
||||
const updateNodeStatus = useNodeStore(
|
||||
useShallow((state) => state.updateNodeStatus),
|
||||
);
|
||||
const updateNodeExecutionResult = useNodeStore(
|
||||
useShallow((state) => state.updateNodeExecutionResult),
|
||||
);
|
||||
const setIsGraphRunning = useGraphStore(
|
||||
useShallow((state) => state.setIsGraphRunning),
|
||||
);
|
||||
const [{ flowID, flowVersion, flowExecutionID }] = useQueryStates({
|
||||
flowID: parseAsString,
|
||||
flowVersion: parseAsInteger,
|
||||
flowExecutionID: parseAsString,
|
||||
});
|
||||
|
||||
const { data: executionDetails } = useGetV1GetExecutionDetails(
|
||||
flowID || "",
|
||||
flowExecutionID || "",
|
||||
{
|
||||
query: {
|
||||
select: (res) => res.data as GetV1GetExecutionDetails200,
|
||||
enabled: !!flowID && !!flowExecutionID,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { data: graph, isLoading: isGraphLoading } = useGetV1GetSpecificGraph(
|
||||
flowID ?? "",
|
||||
flowVersion !== null ? { version: flowVersion } : {},
|
||||
@@ -57,21 +83,52 @@ export const useFlow = () => {
|
||||
}, [nodes, blocks]);
|
||||
|
||||
useEffect(() => {
|
||||
// adding nodes
|
||||
if (customNodes.length > 0) {
|
||||
useNodeStore.getState().setNodes([]);
|
||||
addNodes(customNodes);
|
||||
}
|
||||
|
||||
// adding links
|
||||
if (graph?.links) {
|
||||
useEdgeStore.getState().setConnections([]);
|
||||
addLinks(graph.links);
|
||||
}
|
||||
}, [customNodes, addNodes, graph?.links]);
|
||||
|
||||
// update graph running status
|
||||
const isRunning =
|
||||
executionDetails?.status === AgentExecutionStatus.RUNNING ||
|
||||
executionDetails?.status === AgentExecutionStatus.QUEUED;
|
||||
setIsGraphRunning(isRunning);
|
||||
|
||||
// update node execution status in nodes
|
||||
if (
|
||||
executionDetails &&
|
||||
"node_executions" in executionDetails &&
|
||||
executionDetails.node_executions
|
||||
) {
|
||||
executionDetails.node_executions.forEach((nodeExecution) => {
|
||||
updateNodeStatus(nodeExecution.node_id, nodeExecution.status);
|
||||
});
|
||||
}
|
||||
|
||||
// update node execution results in nodes
|
||||
if (
|
||||
executionDetails &&
|
||||
"node_executions" in executionDetails &&
|
||||
executionDetails.node_executions
|
||||
) {
|
||||
executionDetails.node_executions.forEach((nodeExecution) => {
|
||||
updateNodeExecutionResult(nodeExecution.node_id, nodeExecution);
|
||||
});
|
||||
}
|
||||
}, [customNodes, addNodes, graph?.links, executionDetails, updateNodeStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useNodeStore.getState().setNodes([]);
|
||||
useEdgeStore.getState().setConnections([]);
|
||||
setIsGraphRunning(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// In this hook, I am only keeping websocket related code.
|
||||
|
||||
import { GraphExecutionID } from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import { useEffect } from "react";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
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";
|
||||
|
||||
export const useFlowRealtime = () => {
|
||||
const api = useBackendAPI();
|
||||
const updateNodeExecutionResult = useNodeStore(
|
||||
useShallow((state) => state.updateNodeExecutionResult),
|
||||
);
|
||||
const updateStatus = useNodeStore(
|
||||
useShallow((state) => state.updateNodeStatus),
|
||||
);
|
||||
const setIsGraphRunning = useGraphStore(
|
||||
useShallow((state) => state.setIsGraphRunning),
|
||||
);
|
||||
|
||||
const [{ flowExecutionID, flowID }] = useQueryStates({
|
||||
flowExecutionID: parseAsString,
|
||||
flowID: parseAsString,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const deregisterNodeExecutionEvent = api.onWebSocketMessage(
|
||||
"node_execution_event",
|
||||
(data) => {
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
const deregisterGraphExecutionStatusEvent = api.onWebSocketMessage(
|
||||
"graph_execution_event",
|
||||
(graphExecution) => {
|
||||
if (graphExecution.id != flowExecutionID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isRunning =
|
||||
graphExecution.status === AgentExecutionStatus.RUNNING ||
|
||||
graphExecution.status === AgentExecutionStatus.QUEUED;
|
||||
|
||||
setIsGraphRunning(isRunning);
|
||||
},
|
||||
);
|
||||
|
||||
const deregisterGraphExecutionSubscription =
|
||||
flowID && flowExecutionID
|
||||
? api.onWebSocketConnect(() => {
|
||||
// Subscribe to execution updates
|
||||
api
|
||||
.subscribeToGraphExecution(flowExecutionID as GraphExecutionID) // TODO: We are currently using a manual type, we need to fix it in future
|
||||
.then(() => {
|
||||
console.debug(
|
||||
`Subscribed to updates for execution #${flowExecutionID}`,
|
||||
);
|
||||
})
|
||||
.catch((error) =>
|
||||
console.error(
|
||||
`Failed to subscribe to updates for execution #${flowExecutionID}:`,
|
||||
error,
|
||||
),
|
||||
);
|
||||
})
|
||||
: () => {};
|
||||
|
||||
return () => {
|
||||
deregisterNodeExecutionEvent();
|
||||
deregisterGraphExecutionSubscription();
|
||||
deregisterGraphExecutionStatusEvent();
|
||||
};
|
||||
}, [api, flowExecutionID]);
|
||||
|
||||
return {};
|
||||
};
|
||||
@@ -147,6 +147,7 @@ export const ObjectEditor = React.forwardRef<HTMLDivElement, ObjectEditorProps>(
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="min-w-10"
|
||||
onClick={() => removeProperty(key)}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { StickyNoteBlock } from "./StickyNoteBlock";
|
||||
import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
|
||||
import { StandardNodeBlock } from "./StandardNodeBlock";
|
||||
import { BlockCost } from "@/app/api/__generated__/models/blockCost";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
|
||||
export type CustomNodeData = {
|
||||
hardcodedValues: {
|
||||
@@ -17,6 +19,8 @@ export type CustomNodeData = {
|
||||
outputSchema: RJSFSchema;
|
||||
uiType: BlockUIType;
|
||||
block_id: string;
|
||||
status?: AgentExecutionStatus;
|
||||
nodeExecutionResult?: NodeExecutionResult;
|
||||
// TODO : We need better type safety for the following backend fields.
|
||||
costs: BlockCost[];
|
||||
categories: BlockInfoCategoriesItem[];
|
||||
|
||||
@@ -8,6 +8,9 @@ import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { OutputHandler } from "../OutputHandler";
|
||||
import { NodeCost } from "./components/NodeCost";
|
||||
import { NodeBadges } from "./components/NodeBadges";
|
||||
import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
|
||||
import { nodeStyleBasedOnStatus } from "./helpers";
|
||||
import { NodeDataRenderer } from "./components/NodeDataRenderer";
|
||||
|
||||
type StandardNodeBlockType = {
|
||||
data: CustomNodeData;
|
||||
@@ -23,57 +26,60 @@ export const StandardNodeBlock = ({
|
||||
(state) => state.nodeAdvancedStates[nodeId] || false,
|
||||
);
|
||||
const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced);
|
||||
|
||||
const status = useNodeStore((state) => state.getNodeStatus(nodeId));
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-12 rounded-xl bg-gradient-to-br from-white to-slate-50/30 shadow-lg shadow-slate-900/5 ring-1 ring-slate-200/60 backdrop-blur-sm",
|
||||
"z-12 max-w-[370px] rounded-xl shadow-lg shadow-slate-900/5 ring-1 ring-slate-200/60 backdrop-blur-sm",
|
||||
selected && "shadow-2xl ring-2 ring-slate-200",
|
||||
status && nodeStyleBasedOnStatus[status],
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex h-auto flex-col gap-2 rounded-xl border-b border-slate-200/50 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4">
|
||||
{/* Upper section */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Text
|
||||
variant="large-semibold"
|
||||
className="tracking-tight text-slate-800"
|
||||
>
|
||||
{beautifyString(data.title)}
|
||||
</Text>
|
||||
<Text variant="small" className="!font-medium !text-slate-500">
|
||||
#{nodeId.split("-")[0]}
|
||||
</Text>
|
||||
<div className="rounded-xl bg-white">
|
||||
{/* Header */}
|
||||
<div className="flex h-auto flex-col gap-2 rounded-xl border-b border-slate-200/50 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4">
|
||||
{/* Upper section */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Text
|
||||
variant="large-semibold"
|
||||
className="tracking-tight text-slate-800"
|
||||
>
|
||||
{beautifyString(data.title)}
|
||||
</Text>
|
||||
<Text variant="small" className="!font-medium !text-slate-500">
|
||||
#{nodeId.split("-")[0]}
|
||||
</Text>
|
||||
</div>
|
||||
{/* Lower section */}
|
||||
<div className="flex space-x-2">
|
||||
<NodeCost blockCosts={data.costs} nodeId={nodeId} />
|
||||
<NodeBadges categories={data.categories} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Lower section */}
|
||||
<div className="flex space-x-2">
|
||||
<NodeCost blockCosts={data.costs} nodeId={nodeId} />
|
||||
<NodeBadges categories={data.categories} />
|
||||
{/* Input Handles */}
|
||||
<div className="bg-white pb-6 pr-6">
|
||||
<FormCreator
|
||||
jsonSchema={preprocessInputSchema(data.inputSchema)}
|
||||
nodeId={nodeId}
|
||||
uiType={data.uiType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Advanced Button */}
|
||||
<div className="flex items-center justify-between gap-2 border-t border-slate-200/50 bg-white px-5 py-3.5">
|
||||
<Text variant="body" className="font-medium text-slate-700">
|
||||
Advanced
|
||||
</Text>
|
||||
<Switch
|
||||
onCheckedChange={(checked) => setShowAdvanced(nodeId, checked)}
|
||||
checked={showAdvanced}
|
||||
/>
|
||||
</div>
|
||||
{/* Output Handles */}
|
||||
<OutputHandler outputSchema={data.outputSchema} nodeId={nodeId} />
|
||||
|
||||
{/* Input Handles */}
|
||||
<div className="bg-white/40 pb-6 pr-6">
|
||||
<FormCreator
|
||||
jsonSchema={preprocessInputSchema(data.inputSchema)}
|
||||
nodeId={nodeId}
|
||||
uiType={data.uiType}
|
||||
/>
|
||||
<NodeDataRenderer nodeId={nodeId} />
|
||||
</div>
|
||||
|
||||
{/* Advanced Button */}
|
||||
<div className="flex items-center justify-between gap-2 rounded-b-xl border-t border-slate-200/50 bg-gradient-to-r from-slate-50/60 to-white/80 px-5 py-3.5">
|
||||
<Text variant="body" className="font-medium text-slate-700">
|
||||
Advanced
|
||||
</Text>
|
||||
<Switch
|
||||
onCheckedChange={(checked) => setShowAdvanced(nodeId, checked)}
|
||||
checked={showAdvanced}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output Handles */}
|
||||
<OutputHandler outputSchema={data.outputSchema} nodeId={nodeId} />
|
||||
{status && <NodeExecutionBadge status={status} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ export const StickyNoteBlock = ({ data, id }: StickyNoteBlockType) => {
|
||||
style={{ transform: `rotate(${angle}deg)` }}
|
||||
>
|
||||
<Text variant="h3" className="tracking-tight text-slate-800">
|
||||
Notes #{id}
|
||||
Notes #{id.split("-")[0]}
|
||||
</Text>
|
||||
<FormCreator
|
||||
jsonSchema={preprocessInputSchema(data.inputSchema)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import useCredits from "@/hooks/useCredits";
|
||||
import { CoinIcon } from "@phosphor-icons/react";
|
||||
import { isCostFilterMatch } from "../../../../helper";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export const NodeCost = ({
|
||||
blockCosts,
|
||||
@@ -13,9 +14,10 @@ export const NodeCost = ({
|
||||
nodeId: string;
|
||||
}) => {
|
||||
const { formatCredits } = useCredits();
|
||||
const hardcodedValues = useNodeStore((state) =>
|
||||
state.getHardCodedValues(nodeId),
|
||||
const hardcodedValues = useNodeStore(
|
||||
useShallow((state) => state.getHardCodedValues(nodeId)),
|
||||
);
|
||||
|
||||
const blockCost =
|
||||
blockCosts &&
|
||||
blockCosts.find((cost) =>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import {
|
||||
ArrowSquareInIcon,
|
||||
CaretDownIcon,
|
||||
CopyIcon,
|
||||
InfoIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const nodeExecutionResult = useNodeStore(
|
||||
useShallow((state) => state.getNodeExecutionResult(nodeId)),
|
||||
);
|
||||
|
||||
const data = {
|
||||
"[Input]": nodeExecutionResult?.input_data,
|
||||
...nodeExecutionResult?.output_data,
|
||||
};
|
||||
|
||||
// Don't render if there's no data
|
||||
if (!nodeExecutionResult || Object.keys(data).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need to Fix - when we are on build page and try to rerun the graph again, it gives error
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-b-xl border-t border-slate-200/50 px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="body-medium" className="!font-semibold text-slate-700">
|
||||
Node Output
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="h-fit min-w-0 p-1 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<CaretDownIcon
|
||||
size={16}
|
||||
weight="bold"
|
||||
className={`transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div className="flex max-w-[350px] flex-col gap-4">
|
||||
{Object.entries(data || {}).map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text
|
||||
variant="body-medium"
|
||||
className="!font-semibold text-slate-600"
|
||||
>
|
||||
Pin:
|
||||
</Text>
|
||||
<Text variant="body" className="text-slate-700">
|
||||
{beautifyString(key)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="w-full space-y-2">
|
||||
<Text
|
||||
variant="small"
|
||||
className="!font-semibold text-slate-600"
|
||||
>
|
||||
Data:
|
||||
</Text>
|
||||
<div className="relative">
|
||||
<Text
|
||||
variant="small"
|
||||
className="rounded-xlarge bg-zinc-50 p-3 text-slate-700"
|
||||
>
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</Text>
|
||||
<div className="mt-1 flex justify-end gap-1">
|
||||
{/* TODO: Add tooltip for each button and also make all these blocks working */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="h-fit min-w-0 gap-1.5 p-2 text-black hover:text-slate-900"
|
||||
>
|
||||
<InfoIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="h-fit min-w-0 gap-1.5 p-2 text-black hover:text-slate-900"
|
||||
>
|
||||
<ArrowSquareInIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="h-fit min-w-0 gap-1.5 p-2 text-black hover:text-slate-900"
|
||||
>
|
||||
<CopyIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* TODO: Currently this button is not working, need to make it working */}
|
||||
<Button variant="outline" size="small" className="w-fit self-start">
|
||||
View More
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
||||
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const statusStyles: Record<AgentExecutionStatus, string> = {
|
||||
INCOMPLETE: "text-slate-700 border-slate-400",
|
||||
QUEUED: "text-blue-700 border-blue-400",
|
||||
RUNNING: "text-amber-700 border-amber-400",
|
||||
COMPLETED: "text-green-700 border-green-400",
|
||||
TERMINATED: "text-orange-700 border-orange-400",
|
||||
FAILED: "text-red-700 border-red-400",
|
||||
};
|
||||
|
||||
export const NodeExecutionBadge = ({
|
||||
status,
|
||||
}: {
|
||||
status: AgentExecutionStatus;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-end rounded-b-xl py-2 pr-4">
|
||||
<Badge
|
||||
className={cn(statusStyles[status], "gap-2 rounded-full bg-white")}
|
||||
>
|
||||
{status}
|
||||
{status === AgentExecutionStatus.RUNNING && (
|
||||
<LoadingSpinner className="size-4" />
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
|
||||
export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = {
|
||||
INCOMPLETE: "ring-slate-300 bg-slate-300",
|
||||
QUEUED: " ring-blue-300 bg-blue-300",
|
||||
RUNNING: "ring-amber-300 bg-amber-300",
|
||||
COMPLETED: "ring-green-300 bg-green-300",
|
||||
TERMINATED: "ring-orange-300 bg-orange-300 ",
|
||||
FAILED: "ring-red-300 bg-red-300",
|
||||
};
|
||||
@@ -35,7 +35,7 @@ export const OutputHandler = ({
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
className="flex items-center gap-2 font-medium text-slate-700"
|
||||
className="flex items-center gap-2 !font-semibold text-slate-700"
|
||||
>
|
||||
Output{" "}
|
||||
<CaretDownIcon
|
||||
|
||||
@@ -79,7 +79,7 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 w-[350px] space-y-1">
|
||||
<div className="w-[350px] space-y-1 pt-4">
|
||||
{label && schema.type && (
|
||||
<label htmlFor={fieldId} className="flex items-center gap-1">
|
||||
{!suppressHandle && !fromAnyOf && !isCredential && (
|
||||
|
||||
@@ -19,7 +19,9 @@ import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
|
||||
export const NewSaveControl = () => {
|
||||
const { form, onSubmit, isLoading, graphVersion } = useNewSaveControl();
|
||||
const { form, onSubmit, isLoading, graphVersion } = useNewSaveControl({
|
||||
showToast: true,
|
||||
});
|
||||
const { saveControlOpen, setSaveControlOpen } = useControlPanelStore();
|
||||
return (
|
||||
<Popover onOpenChange={setSaveControlOpen}>
|
||||
@@ -111,6 +113,7 @@ export const NewSaveControl = () => {
|
||||
data-id="save-control-save-agent"
|
||||
data-testid="save-control-save-agent-button"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
Save Agent
|
||||
</Button>
|
||||
|
||||
@@ -25,7 +25,11 @@ const formSchema = z.object({
|
||||
|
||||
type SaveableGraphFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const useNewSaveControl = () => {
|
||||
export const useNewSaveControl = ({
|
||||
showToast = true,
|
||||
}: {
|
||||
showToast?: boolean;
|
||||
}) => {
|
||||
const { setSaveControlOpen } = useControlPanelStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -60,9 +64,11 @@ export const useNewSaveControl = () => {
|
||||
flowID: data.id,
|
||||
flowVersion: data.version,
|
||||
});
|
||||
toast({
|
||||
title: "All changes saved successfully!",
|
||||
});
|
||||
if (showToast) {
|
||||
toast({
|
||||
title: "All changes saved successfully!",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
@@ -88,9 +94,11 @@ export const useNewSaveControl = () => {
|
||||
flowID: data.id,
|
||||
flowVersion: data.version,
|
||||
});
|
||||
toast({
|
||||
title: "All changes saved successfully!",
|
||||
});
|
||||
if (showToast) {
|
||||
toast({
|
||||
title: "All changes saved successfully!",
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV1GetSpecificGraphQueryKey(data.id),
|
||||
});
|
||||
@@ -113,6 +121,41 @@ export const useNewSaveControl = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: SaveableGraphFormValues | undefined) => {
|
||||
const graphNodes = useNodeStore.getState().getBackendNodes();
|
||||
const graphLinks = useEdgeStore.getState().getBackendLinks();
|
||||
|
||||
if (graph && graph.id) {
|
||||
const data: Graph = {
|
||||
id: graph.id,
|
||||
name:
|
||||
values?.name || graph.name || `New Agent ${new Date().toISOString()}`,
|
||||
description: values?.description ?? graph.description ?? "",
|
||||
nodes: graphNodes,
|
||||
links: graphLinks,
|
||||
};
|
||||
if (graphsEquivalent(graph, data)) {
|
||||
if (showToast) {
|
||||
toast({
|
||||
title: "No changes to save",
|
||||
description: "The graph is the same as the saved version.",
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await updateGraph({ graphId: graph.id, data: data });
|
||||
} else {
|
||||
const data: Graph = {
|
||||
name: values?.name || `New Agent ${new Date().toISOString()}`,
|
||||
description: values?.description || "",
|
||||
nodes: graphNodes,
|
||||
links: graphLinks,
|
||||
};
|
||||
await createNewGraph({ data: { graph: data } });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Ctrl+S / Cmd+S keyboard shortcut
|
||||
useEffect(() => {
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
@@ -127,7 +170,7 @@ export const useNewSaveControl = () => {
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [form]);
|
||||
}, [onSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (graph) {
|
||||
@@ -138,38 +181,6 @@ export const useNewSaveControl = () => {
|
||||
}
|
||||
}, [graph, form]);
|
||||
|
||||
const onSubmit = async (values: SaveableGraphFormValues) => {
|
||||
const graphNodes = useNodeStore.getState().getBackendNodes();
|
||||
const graphLinks = useEdgeStore.getState().getBackendLinks();
|
||||
|
||||
if (graph && graph.id) {
|
||||
const data: Graph = {
|
||||
id: graph.id,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
nodes: graphNodes,
|
||||
links: graphLinks,
|
||||
};
|
||||
if (graphsEquivalent(graph, data)) {
|
||||
toast({
|
||||
title: "No changes to save",
|
||||
description: "The graph is the same as the saved version.",
|
||||
variant: "default",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await updateGraph({ graphId: graph.id, data: data });
|
||||
} else {
|
||||
const data: Graph = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
nodes: graphNodes,
|
||||
links: graphLinks,
|
||||
};
|
||||
await createNewGraph({ data: { graph: data } });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
form,
|
||||
isLoading: isCreating || isUpdating,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface GraphStore {
|
||||
isGraphRunning: boolean;
|
||||
setIsGraphRunning: (isGraphRunning: boolean) => void;
|
||||
}
|
||||
|
||||
export const useGraphStore = create<GraphStore>((set) => ({
|
||||
isGraphRunning: false,
|
||||
setIsGraphRunning: (isGraphRunning: boolean) => set({ isGraphRunning }),
|
||||
}));
|
||||
@@ -4,6 +4,8 @@ import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode
|
||||
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
||||
import { convertBlockInfoIntoCustomNodeData } from "../components/helper";
|
||||
import { Node } from "@/app/api/__generated__/models/node";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
|
||||
type NodeStore = {
|
||||
nodes: CustomNode[];
|
||||
@@ -22,6 +24,15 @@ type NodeStore = {
|
||||
getHardCodedValues: (nodeId: string) => Record<string, any>;
|
||||
convertCustomNodeToBackendNode: (node: CustomNode) => Node;
|
||||
getBackendNodes: () => Node[];
|
||||
|
||||
updateNodeStatus: (nodeId: string, status: AgentExecutionStatus) => void;
|
||||
getNodeStatus: (nodeId: string) => AgentExecutionStatus | undefined;
|
||||
|
||||
updateNodeExecutionResult: (
|
||||
nodeId: string,
|
||||
result: NodeExecutionResult,
|
||||
) => void;
|
||||
getNodeExecutionResult: (nodeId: string) => NodeExecutionResult | undefined;
|
||||
};
|
||||
|
||||
export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
@@ -103,4 +114,27 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
get().convertCustomNodeToBackendNode(node),
|
||||
);
|
||||
},
|
||||
updateNodeStatus: (nodeId: string, status: AgentExecutionStatus) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === nodeId ? { ...n, data: { ...n.data, status } } : n,
|
||||
),
|
||||
}));
|
||||
},
|
||||
getNodeStatus: (nodeId: string) => {
|
||||
return get().nodes.find((n) => n.id === nodeId)?.data?.status;
|
||||
},
|
||||
|
||||
updateNodeExecutionResult: (nodeId: string, result: NodeExecutionResult) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === nodeId
|
||||
? { ...n, data: { ...n.data, nodeExecutionResult: result } }
|
||||
: n,
|
||||
),
|
||||
}));
|
||||
},
|
||||
getNodeExecutionResult: (nodeId: string) => {
|
||||
return get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResult;
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user