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:
Abhimanyu Yadav
2025-10-24 17:35:09 +05:30
committed by GitHub
parent 48ff225837
commit acb946801b
22 changed files with 747 additions and 94 deletions

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
};
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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);
};
}, []);

View File

@@ -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 {};
};

View File

@@ -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}
>

View File

@@ -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[];

View File

@@ -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>
);
};

View File

@@ -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)}

View File

@@ -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) =>

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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",
};

View File

@@ -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

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 }),
}));

View File

@@ -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;
},
}));