feat(frontend): add saving ability in new builder (#11148)

This PR introduces saving functionality to the new builder interface,
allowing users to save and update agent flows. The implementation
includes both UI components and backend integration for persistent
storage of agent configurations.



https://github.com/user-attachments/assets/95ee46de-2373-4484-9f34-5f09aa071c5e


### Key Features Added:

#### 1. **Save Control Component** (`NewSaveControl`)
- Added a new save control popover in the control panel with form inputs
for agent name, description, and version display
- Integrated with the new control panel as a primary action button with
a floppy disk icon
- Supports keyboard shortcuts (Ctrl+S / Cmd+S) for quick saving

#### 2. **Graph Persistence Logic**
- Implemented `useNewSaveControl` hook to handle:
  - Creating new graphs via `usePostV1CreateNewGraph`
  - Updating existing graphs via `usePutV1UpdateGraphVersion`
- Intelligent comparison to prevent unnecessary saves when no changes
are made
  - URL parameter management for flowID and flowVersion tracking

#### 3. **Loading State Management**
- Added `GraphLoadingBox` component to display a loading indicator while
graphs are being fetched
- Enhanced `useFlow` hook with loading state tracking
(`isFlowContentLoading`)
- Improved UX with clear visual feedback during graph operations

#### 4. **Component Reorganization**
- Refactored components from `NewBlockMenu` to `NewControlPanel`
directory structure for better organization:
- Moved all block menu related components under
`NewControlPanel/NewBlockMenu/`
- Separated save control into its own module
(`NewControlPanel/NewSaveControl/`)
  - Improved modularity and separation of concerns

#### 5. **State Management Enhancements**
- Added `controlPanelStore` for managing control panel states (e.g.,
save popover visibility)
- Enhanced `nodeStore` with `getBackendNodes()` method for retrieving
nodes in backend format
- Added `getBackendLinks()` to `edgeStore` for consistent link
formatting

### Technical Improvements:

- **Graph Comparison Logic**: Implemented `graphsEquivalent()` helper to
deeply compare saved and current graph states, preventing redundant
saves
- **Form Validation**: Used Zod schema validation for save form inputs
with proper constraints
- **Error Handling**: Comprehensive error handling with user-friendly
toast notifications
- **Query Invalidation**: Proper cache invalidation after successful
saves to ensure data consistency

### UI/UX Enhancements:

- Clean, modern save dialog with clear labeling and placeholder text
- Real-time version display showing the current graph version
- Disabled state for save button during operations to prevent double
submissions
- Toast notifications for success and error states
- Higher z-index for GraphLoadingBox to ensure visibility over other
elements

### 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] Saving is working perfectly. All nodes, links, their positions,
and hardcoded data are saved correctly.
  - [x] If there are no changes, the user cannot save the graph.
This commit is contained in:
Abhimanyu Yadav
2025-10-16 13:36:18 +05:30
committed by GitHub
parent 12b1067017
commit 8b995c2394
72 changed files with 484 additions and 267 deletions

View File

@@ -1,5 +1,5 @@
import { ReactFlow, Background, Controls } from "@xyflow/react";
import NewControlPanel from "../../NewBlockMenu/NewControlPanel/NewControlPanel";
import NewControlPanel from "../../NewControlPanel/NewControlPanel";
import CustomEdge from "../edges/CustomEdge";
import { useFlow } from "./useFlow";
import { useShallow } from "zustand/react/shallow";
@@ -7,6 +7,7 @@ import { useNodeStore } from "../../../stores/nodeStore";
import { useMemo } from "react";
import { CustomNode } from "../nodes/CustomNode";
import { useCustomEdge } from "../edges/useCustomEdge";
import { GraphLoadingBox } from "./GraphLoadingBox";
export const Flow = () => {
const nodes = useNodeStore(useShallow((state) => state.nodes));
@@ -17,7 +18,7 @@ export const Flow = () => {
const { edges, onConnect, onEdgesChange } = useCustomEdge();
// We use this hook to load the graph and convert them into custom nodes and edges.
useFlow();
const { isFlowContentLoading } = useFlow();
return (
<div className="flex h-full w-full dark:bg-slate-900">
@@ -36,6 +37,7 @@ export const Flow = () => {
<Background />
<Controls />
<NewControlPanel />
{isFlowContentLoading && <GraphLoadingBox />}
</ReactFlow>
</div>
</div>

View File

@@ -0,0 +1,17 @@
import { Text } from "@/components/atoms/Text/Text";
export const GraphLoadingBox = () => {
return (
<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>
<div className="flex flex-col items-center gap-2">
<Text variant="h4">Loading Flow</Text>
<Text variant="small">Please wait while we load your graph...</Text>
</div>
</div>
</div>
);
};

View File

@@ -18,7 +18,7 @@ export const useFlow = () => {
flowVersion: parseAsInteger,
});
const { data: graph } = useGetV1GetSpecificGraph(
const { data: graph, isLoading: isGraphLoading } = useGetV1GetSpecificGraph(
flowID ?? "",
flowVersion !== null ? { version: flowVersion } : {},
{
@@ -32,15 +32,16 @@ export const useFlow = () => {
const nodes = graph?.nodes;
const blockIds = nodes?.map((node) => node.block_id);
const { data: blocks } = useGetV2GetSpecificBlocks(
{ block_ids: blockIds ?? [] },
{
query: {
select: (res) => res.data as BlockInfo[],
enabled: !!flowID && !!blockIds,
const { data: blocks, isLoading: isBlocksLoading } =
useGetV2GetSpecificBlocks(
{ block_ids: blockIds ?? [] },
{
query: {
select: (res) => res.data as BlockInfo[],
enabled: !!flowID && !!blockIds,
},
},
},
);
);
const customNodes = useMemo(() => {
if (!nodes || !blocks) return [];
@@ -57,13 +58,22 @@ export const useFlow = () => {
useEffect(() => {
if (customNodes.length > 0) {
useNodeStore.getState().setNodes([]);
addNodes(customNodes);
}
if (graph?.links) {
useEdgeStore.getState().setConnections([]);
addLinks(graph.links);
}
}, [customNodes, addNodes, graph?.links]);
return {};
useEffect(() => {
return () => {
useNodeStore.getState().setNodes([]);
useEdgeStore.getState().setConnections([]);
};
}, []);
return { isFlowContentLoading: isGraphLoading || isBlocksLoading };
};

View File

@@ -17,6 +17,7 @@ export type CustomNodeData = {
[key: string]: any;
};
title: string;
block_id: string;
description: string;
inputSchema: RJSFSchema;
outputSchema: RJSFSchema;

View File

@@ -24,6 +24,9 @@ export const FormCreator = React.memo(
(state) => state.getHardCodedValues,
);
const handleChange = ({ formData }: any) => {
if ("credentials" in formData && !formData.credentials?.id) {
delete formData.credentials;
}
updateNodeData(nodeId, { hardcodedValues: formData });
};

View File

@@ -24,7 +24,7 @@ export const OutputHandler = ({
}) => {
const { isOutputConnected } = useEdgeStore();
const properties = outputSchema?.properties || {};
const [isOutputVisible, setIsOutputVisible] = useState(false);
const [isOutputVisible, setIsOutputVisible] = useState(true);
return (
<div className="flex flex-col items-end justify-between gap-2 rounded-b-xl border-t border-slate-200/50 bg-white py-3.5">

View File

@@ -7,6 +7,7 @@ import {
} from "./helpers";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useEffect, useRef } from "react";
import { useShallow } from "zustand/react/shallow";
export const useCredentialField = ({
credentialSchema,
@@ -34,8 +35,8 @@ export const useCredentialField = ({
},
});
const hardcodedValues = useNodeStore((state) =>
state.getHardCodedValues(nodeId),
const hardcodedValues = useNodeStore(
useShallow((state) => state.getHardCodedValues(nodeId)),
);
const credentialProvider = getCredentialProviderFromSchema(

View File

@@ -40,7 +40,6 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
const showAdvanced = useNodeStore(
(state) => state.nodeAdvancedStates[nodeId] ?? false,
);
const formData = useNodeStore((state) => state.getHardCodedValues(nodeId));
const { isArrayItem, arrayFieldHandleId } = useContext(ArrayEditorContext);
@@ -71,7 +70,7 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
let credentialProvider = null;
if (isCredential) {
credentialProvider = getCredentialProviderFromSchema(
formData,
useNodeStore.getState().getHardCodedValues(nodeId),
schema as BlockIOCredentialsSubSchema,
);
}

View File

@@ -1,26 +0,0 @@
import { useState } from "react";
interface useBlockMenuProps {
// pinBlocksPopover: boolean;
setBlockMenuSelected: React.Dispatch<
React.SetStateAction<"" | "save" | "block" | "search">
>;
}
export const useBlockMenu = ({
// pinBlocksPopover,
setBlockMenuSelected,
}: useBlockMenuProps) => {
const [open, setOpen] = useState(false);
const onOpen = (newOpen: boolean) => {
// if (!pinBlocksPopover) {
setOpen(newOpen);
setBlockMenuSelected(newOpen ? "block" : "");
// }
};
return {
open,
onOpen,
};
};

View File

@@ -1,157 +0,0 @@
import React, { useCallback, useEffect } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/__legacy__/ui/popover";
import { Card, CardContent, CardFooter } from "@/components/__legacy__/ui/card";
import { Input } from "@/components/__legacy__/ui/input";
import { Button } from "@/components/__legacy__/ui/button";
import { GraphMeta } from "@/lib/autogpt-server-api";
import { Label } from "@/components/__legacy__/ui/label";
import { IconSave } from "@/components/__legacy__/ui/icons";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { ControlPanelButton } from "../ControlPanelButton";
interface SaveControlProps {
agentMeta: GraphMeta | null;
agentName: string;
agentDescription: string;
canSave: boolean;
onSave: () => void;
onNameChange: (name: string) => void;
onDescriptionChange: (description: string) => void;
pinSavePopover: boolean;
blockMenuSelected: "save" | "block" | "search" | "";
setBlockMenuSelected: React.Dispatch<
React.SetStateAction<"" | "save" | "block" | "search">
>;
}
export const NewSaveControl = ({
agentMeta,
canSave,
onSave,
agentName,
onNameChange,
agentDescription,
onDescriptionChange,
blockMenuSelected,
setBlockMenuSelected,
pinSavePopover,
}: SaveControlProps) => {
const handleSave = useCallback(() => {
onSave();
}, [onSave]);
const { toast } = useToast();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault();
handleSave();
toast({
duration: 2000,
title: "All changes saved successfully!",
});
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleSave, toast]);
return (
<Popover
open={pinSavePopover ? true : undefined}
onOpenChange={(open) => open || setBlockMenuSelected("")}
>
<PopoverTrigger>
<ControlPanelButton
data-id="save-control-popover-trigger"
data-testid="blocks-control-save-button"
selected={blockMenuSelected === "save"}
onClick={() => {
setBlockMenuSelected("save");
}}
className="rounded-none"
>
{/* Need to find phosphor icon alternative for this lucide icon */}
<IconSave className="h-5 w-5" strokeWidth={2} />
</ControlPanelButton>
</PopoverTrigger>
<PopoverContent
side="right"
sideOffset={16}
align="start"
className="w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
data-id="save-control-popover-content"
>
<Card className="border-none shadow-none dark:bg-slate-900">
<CardContent className="p-4">
<div className="grid gap-3">
<Label htmlFor="name" className="dark:text-gray-300">
Name
</Label>
<Input
id="name"
placeholder="Enter your agent name"
className="col-span-3"
value={agentName}
onChange={(e) => onNameChange(e.target.value)}
data-id="save-control-name-input"
data-testid="save-control-name-input"
maxLength={100}
/>
<Label htmlFor="description" className="dark:text-gray-300">
Description
</Label>
<Input
id="description"
placeholder="Your agent description"
className="col-span-3"
value={agentDescription}
onChange={(e) => onDescriptionChange(e.target.value)}
data-id="save-control-description-input"
data-testid="save-control-description-input"
maxLength={500}
/>
{agentMeta?.version && (
<>
<Label htmlFor="version" className="dark:text-gray-300">
Version
</Label>
<Input
id="version"
placeholder="Version"
className="col-span-3"
value={agentMeta?.version || "-"}
disabled
data-testid="save-control-version-output"
/>
</>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col items-stretch gap-2">
<Button
className="w-full dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleSave}
data-id="save-control-save-agent"
data-testid="save-control-save-agent-button"
disabled={!canSave}
>
Save Agent
</Button>
</CardFooter>
</Card>
</PopoverContent>
</Popover>
);
};

View File

@@ -6,7 +6,7 @@ import { beautifyString } from "@/lib/utils";
import { useAllBlockContent } from "./useAllBlockContent";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { blockMenuContainerStyle } from "../style";
import { useNodeStore } from "../../../stores/nodeStore";
import { useNodeStore } from "../../../../stores/nodeStore";
export const AllBlocksContent = () => {
const {

View File

@@ -1,7 +1,7 @@
import React from "react";
import { Block } from "../Block";
import { blockMenuContainerStyle } from "../style";
import { useNodeStore } from "../../../stores/nodeStore";
import { useNodeStore } from "../../../../stores/nodeStore";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
interface BlocksListProps {

View File

@@ -5,35 +5,20 @@ import {
PopoverTrigger,
} from "@/components/__legacy__/ui/popover";
import { BlockMenuContent } from "../BlockMenuContent/BlockMenuContent";
import { ControlPanelButton } from "../ControlPanelButton";
import { useBlockMenu } from "./useBlockMenu";
import { ControlPanelButton } from "../../ControlPanelButton";
import { LegoIcon } from "@phosphor-icons/react";
import { useControlPanelStore } from "@/app/(platform)/build/stores/controlPanelStore";
interface BlockMenuProps {
// pinBlocksPopover: boolean;
blockMenuSelected: "save" | "block" | "search" | "";
setBlockMenuSelected: React.Dispatch<
React.SetStateAction<"" | "save" | "block" | "search">
>;
}
export const BlockMenu: React.FC<BlockMenuProps> = ({
// pinBlocksPopover,
blockMenuSelected,
setBlockMenuSelected,
}) => {
const { open: _open, onOpen } = useBlockMenu({
// pinBlocksPopover,
setBlockMenuSelected,
});
export const BlockMenu = () => {
const { blockMenuOpen, setBlockMenuOpen } = useControlPanelStore();
return (
// pinBlocksPopover ? true : open
<Popover onOpenChange={onOpen}>
<Popover onOpenChange={setBlockMenuOpen}>
<PopoverTrigger className="hover:cursor-pointer">
<ControlPanelButton
data-id="blocks-control-popover-trigger"
data-testid="blocks-control-blocks-button"
selected={blockMenuSelected === "block"}
selected={blockMenuOpen}
className="rounded-none"
>
{/* Need to find phosphor icon alternative for this lucide icon */}

View File

@@ -4,7 +4,7 @@ import { BlockMenuSearchBar } from "../BlockMenuSearchBar/BlockMenuSearchBar";
import { Separator } from "@/components/__legacy__/ui/separator";
import { BlockMenuDefault } from "../BlockMenuDefault/BlockMenuDefault";
import { BlockMenuSearch } from "../BlockMenuSearch/BlockMenuSearch";
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
export const BlockMenuContent = () => {
const { searchQuery } = useBlockMenuStore();

View File

@@ -5,7 +5,7 @@ import { IntegrationsContent } from "../IntegrationsContent/IntegrationsContent"
import { MarketplaceAgentsContent } from "../MarketplaceAgentsContent/MarketplaceAgentsContent";
import { MyAgentsContent } from "../MyAgentsContent/MyAgentsContent";
import { SuggestionContent } from "../SuggestionContent/SuggestionContent";
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { DefaultStateType } from "../types";
export const BlockMenuDefaultContent = () => {

View File

@@ -7,11 +7,11 @@ import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
import { Block } from "../Block";
import { UGCAgentBlock } from "../UGCAgentBlock";
import { getSearchItemType } from "./helper";
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { blockMenuContainerStyle } from "../style";
import { cn } from "@/lib/utils";
import { NoSearchResult } from "../NoSearchResult";
import { useNodeStore } from "../../../stores/nodeStore";
import { useNodeStore } from "../../../../stores/nodeStore";
export const BlockMenuSearch = () => {
const {

View File

@@ -1,4 +1,4 @@
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { useGetV2BuilderSearchInfinite } from "@/app/api/__generated__/endpoints/store/store";
import { SearchResponse } from "@/app/api/__generated__/models/searchResponse";

View File

@@ -1,6 +1,6 @@
import { debounce } from "lodash";
import { useCallback, useEffect, useRef, useState } from "react";
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
const SEARCH_DEBOUNCE_MS = 300;

View File

@@ -3,7 +3,7 @@ import { MenuItem } from "../MenuItem";
import { useBlockMenuSidebar } from "./useBlockMenuSidebar";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { DefaultStateType } from "../types";
export const BlockMenuSidebar = () => {

View File

@@ -1,6 +1,6 @@
import { useGetV2GetBuilderItemCounts } from "@/app/api/__generated__/endpoints/default/default";
import { CountResponse } from "@/app/api/__generated__/models/countResponse";
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
export const useBlockMenuSidebar = () => {
const { defaultState, setDefaultState } = useBlockMenuStore();

View File

@@ -5,8 +5,8 @@ import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { useIntegrationBlocks } from "./useIntegrationBlocks";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { useNodeStore } from "../../../stores/nodeStore";
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
import { useNodeStore } from "../../../../stores/nodeStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
export const IntegrationBlocks = () => {
const { integration, setIntegration } = useBlockMenuStore();

View File

@@ -1,6 +1,6 @@
import { useGetV2GetBuilderBlocksInfinite } from "@/app/api/__generated__/endpoints/default/default";
import { BlockResponse } from "@/app/api/__generated__/models/blockResponse";
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
const PAGE_SIZE = 10;

View File

@@ -3,7 +3,7 @@ import { scrollbarStyles } from "@/components/styles/scrollbars";
import { IntegrationBlocks } from "../IntegrationBlocks/IntegrationBlocks";
import { PaginatedIntegrationList } from "../PaginatedIntegrationList/PaginatedIntegrationList";
import { cn } from "@/lib/utils";
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
export const IntegrationsContent = () => {
const { integration } = useBlockMenuStore();

View File

@@ -4,7 +4,7 @@ import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteS
import { usePaginatedIntegrationList } from "./usePaginatedIntegrationList";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { blockMenuContainerStyle } from "../style";
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
export const PaginatedIntegrationList = () => {
const { setIntegration } = useBlockMenuStore();

View File

@@ -4,8 +4,8 @@ import { Block } from "../Block";
import { useSuggestionContent } from "./useSuggestionContent";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { blockMenuContainerStyle } from "../style";
import { useNodeStore } from "../../../stores/nodeStore";
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
import { useNodeStore } from "../../../../stores/nodeStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { DefaultStateType } from "../types";
export const SuggestionContent = () => {

View File

@@ -1,16 +1,18 @@
// import { Separator } from "@/components/__legacy__/ui/separator";
import { cn } from "@/lib/utils";
import React, { useMemo } from "react";
import { BlockMenu } from "../BlockMenu/BlockMenu";
import { BlockMenu } from "./NewBlockMenu/BlockMenu/BlockMenu";
import { useNewControlPanel } from "./useNewControlPanel";
// import { NewSaveControl } from "../SaveControl/NewSaveControl";
import { GraphExecutionID } from "@/lib/autogpt-server-api";
// import { ControlPanelButton } from "../ControlPanelButton";
import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
// import { GraphSearchMenu } from "../GraphMenu/GraphMenu";
import { CustomNode } from "../../FlowEditor/nodes/CustomNode";
import { CustomNode } from "../FlowEditor/nodes/CustomNode";
import { history } from "@/app/(platform)/build/components/legacy-builder/history";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { Separator } from "@/components/__legacy__/ui/separator";
import { NewSaveControl } from "./NewSaveControl/NewSaveControl";
export type Control = {
icon: React.ReactNode;
@@ -40,8 +42,6 @@ export const NewControlPanel = ({
const _isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH);
const {
blockMenuSelected,
setBlockMenuSelected,
// agentDescription,
// setAgentDescription,
// saveAgent,
@@ -74,15 +74,11 @@ export const NewControlPanel = ({
return (
<section
className={cn(
"top- absolute left-4 z-10 w-[4.25rem] overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
"absolute left-4 top-10 z-10 w-[4.25rem] overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
)}
>
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
<BlockMenu
// pinBlocksPopover={pinBlocksPopover}
blockMenuSelected={blockMenuSelected}
setBlockMenuSelected={setBlockMenuSelected}
/>
<BlockMenu />
{/* <Separator className="text-[#E1E1E1]" />
{isGraphSearchEnabled && (
<>
@@ -107,20 +103,9 @@ export const NewControlPanel = ({
>
{control.icon}
</ControlPanelButton>
))}
))} */}
<Separator className="text-[#E1E1E1]" />
<NewSaveControl
agentMeta={savedAgent}
canSave={!isSaving && !isRunning && !isStopping}
onSave={saveAgent}
agentDescription={agentDescription}
onDescriptionChange={setAgentDescription}
agentName={agentName}
onNameChange={setAgentName}
pinSavePopover={pinSavePopover}
blockMenuSelected={blockMenuSelected}
setBlockMenuSelected={setBlockMenuSelected}
/> */}
<NewSaveControl />
</div>
</section>
);

View File

@@ -0,0 +1,124 @@
import React from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/__legacy__/ui/popover";
import { Card, CardContent, CardFooter } from "@/components/__legacy__/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { useNewSaveControl } from "./useNewSaveControl";
import { Form, FormField } from "@/components/__legacy__/ui/form";
import { ControlPanelButton } from "../ControlPanelButton";
import { useControlPanelStore } from "../../../stores/controlPanelStore";
import { FloppyDiskIcon } from "@phosphor-icons/react";
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
export const NewSaveControl = () => {
const { form, onSubmit, isLoading, graphVersion } = useNewSaveControl();
const { saveControlOpen, setSaveControlOpen } = useControlPanelStore();
return (
<Popover onOpenChange={setSaveControlOpen}>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<ControlPanelButton
data-id="save-control-popover-trigger"
data-testid="save-control-save-button"
selected={saveControlOpen}
className="rounded-none"
>
{/* Need to find phosphor icon alternative for this lucide icon */}
<FloppyDiskIcon className="h-6 w-6" />
</ControlPanelButton>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right">Save</TooltipContent>
</Tooltip>
<PopoverContent
side="right"
sideOffset={15}
align="start"
data-id="save-control-popover-content"
className="w-96 max-w-[400px] rounded-xlarge"
>
<Card className="border-none dark:bg-slate-900">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="p-0">
<div className="space-y-3">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<Input
id="name"
label="Name"
size="small"
placeholder="Enter your agent name"
data-id="save-control-name-input"
data-testid="save-control-name-input"
maxLength={100}
wrapperClassName="!mb-0"
{...field}
/>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<Input
id="description"
size="small"
label="Description"
placeholder="Your agent description"
data-id="save-control-description-input"
data-testid="save-control-description-input"
maxLength={500}
wrapperClassName="!mb-0"
{...field}
/>
)}
/>
{graphVersion && (
<Input
id="version"
placeholder="Version"
size="small"
value={graphVersion || "-"}
disabled
data-testid="save-control-version-output"
label="Version"
wrapperClassName="!mb-0"
/>
)}
</div>
</CardContent>
{/* TODO: Add a cron schedule button */}
<CardFooter className="mt-3 flex flex-col items-stretch gap-2 p-0">
<Button
variant="primary"
type="submit"
size="small"
className="w-full dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-800"
data-id="save-control-save-agent"
data-testid="save-control-save-agent-button"
disabled={isLoading}
>
Save Agent
</Button>
</CardFooter>
</form>
</Form>
</Card>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,51 @@
import { Graph } from "@/app/api/__generated__/models/graph";
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
import { Link } from "@/app/api/__generated__/models/link";
import { NodeModel } from "@/app/api/__generated__/models/nodeModel";
import { Node } from "@/app/api/__generated__/models/node";
import { deepEquals } from "@rjsf/utils";
export const graphsEquivalent = (
saved: GraphModel | undefined,
current: Graph | undefined,
): boolean => {
if (!saved || !current) {
return false;
}
const sortNodes = (nodes: NodeModel[] | Node[]) =>
nodes.toSorted((a, b) => a.id?.localeCompare(b.id ?? "") ?? 0);
const sortLinks = (links: Link[]) =>
links.toSorted(
(a, b) =>
8 * a.source_id.localeCompare(b.source_id) +
4 * a.sink_id.localeCompare(b.sink_id) +
2 * a.source_name.localeCompare(b.source_name) +
a.sink_name.localeCompare(b.sink_name),
);
const _saved = {
name: saved.name,
description: saved.description,
nodes: sortNodes(saved.nodes ?? []).map((v) => ({
block_id: v.block_id,
input_default: v.input_default,
metadata: v.metadata,
})),
links: sortLinks(saved.links ?? []).map((v) => ({
sink_name: v.sink_name,
source_name: v.source_name,
})),
};
// Normalize current graph - exclude IDs
const _current = {
name: current.name,
description: current.description,
nodes: sortNodes(current.nodes ?? []).map(({ id: _, ...rest }) => rest),
links: sortLinks(current.links ?? []).map(
({ source_id: _, sink_id: __, ...rest }) => rest,
),
};
return deepEquals(_saved, _current);
};

View File

@@ -0,0 +1,179 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import {
getGetV1GetSpecificGraphQueryKey,
useGetV1GetSpecificGraph,
usePostV1CreateNewGraph,
usePutV1UpdateGraphVersion,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
import { useNodeStore } from "../../../stores/nodeStore";
import { useEdgeStore } from "../../../stores/edgeStore";
import { Graph } from "@/app/api/__generated__/models/graph";
import { useControlPanelStore } from "../../../stores/controlPanelStore";
import { graphsEquivalent } from "./helpers";
const formSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
description: z.string().max(500),
});
type SaveableGraphFormValues = z.infer<typeof formSchema>;
export const useNewSaveControl = () => {
const { setSaveControlOpen } = useControlPanelStore();
const { toast } = useToast();
const queryClient = useQueryClient();
const [{ flowID, flowVersion }, setQueryStates] = useQueryStates({
flowID: parseAsString,
flowVersion: parseAsInteger,
});
const { data: graph } = useGetV1GetSpecificGraph(
flowID ?? "",
flowVersion !== null ? { version: flowVersion } : {},
{
query: {
select: (res) => res.data as GraphModel,
enabled: !!flowID,
},
},
);
const { mutateAsync: createNewGraph, isPending: isCreating } =
usePostV1CreateNewGraph({
mutation: {
onSuccess: (response) => {
const data = response.data as GraphModel;
form.reset({
name: data.name,
description: data.description,
});
setSaveControlOpen(false);
setQueryStates({
flowID: data.id,
flowVersion: data.version,
});
toast({
title: "All changes saved successfully!",
});
},
onError: (error) => {
toast({
title: (error.detail as string) ?? "An unexpected error occurred.",
description: "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
const { mutateAsync: updateGraph, isPending: isUpdating } =
usePutV1UpdateGraphVersion({
mutation: {
onSuccess: (response) => {
const data = response.data as GraphModel;
form.reset({
name: data.name,
description: data.description,
});
setSaveControlOpen(false);
setQueryStates({
flowID: data.id,
flowVersion: data.version,
});
toast({
title: "All changes saved successfully!",
});
queryClient.invalidateQueries({
queryKey: getGetV1GetSpecificGraphQueryKey(data.id),
});
},
onError: (error) => {
toast({
title: (error.detail as string) ?? "An unexpected error occurred.",
description: "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
const form = useForm<SaveableGraphFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: graph?.name ?? "",
description: graph?.description ?? "",
},
});
// Handle Ctrl+S / Cmd+S keyboard shortcut
useEffect(() => {
const handleKeyDown = async (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault();
await onSubmit(form.getValues());
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [form]);
useEffect(() => {
if (graph) {
form.reset({
name: graph.name ?? "",
description: graph.description ?? "",
});
}
}, [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,
graphVersion: graph?.version,
onSubmit,
};
};

View File

@@ -6,7 +6,7 @@ import {
} from "@/components/__legacy__/ui/popover";
import { MagnifyingGlassIcon } from "@phosphor-icons/react";
import { GraphSearchContent } from "../GraphMenuContent/GraphContent";
import { ControlPanelButton } from "../ControlPanelButton";
import { ControlPanelButton } from "../../ControlPanelButton";
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
import { useGraphMenu } from "./useGraphMenu";

View File

@@ -15,6 +15,7 @@ export const convertBlockInfoIntoCustomNodeData = (
inputSchema: block.inputSchema,
outputSchema: block.outputSchema,
uiType: block.uiType as BlockUIType,
block_id: block.id,
};
return customNodeData;
};

View File

@@ -65,7 +65,7 @@ import RunnerUIWrapper, { RunnerUIWrapperRef } from "../RunnerUIWrapper";
import OttoChatWidget from "@/app/(platform)/build/components/legacy-builder/OttoChatWidget";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useCopyPaste } from "../../../../../../hooks/useCopyPaste";
import NewControlPanel from "@/app/(platform)/build/components/NewBlockMenu/NewControlPanel/NewControlPanel";
import NewControlPanel from "@/app/(platform)/build/components/NewControlPanel/NewControlPanel";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { BuildActionBar } from "../BuildActionBar";

View File

@@ -7,13 +7,13 @@ import {
import { Button } from "@/components/atoms/Button/Button";
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
import { GraphSearchContent } from "../NewBlockMenu/GraphMenuContent/GraphContent";
import { GraphSearchContent } from "../NewControlPanel/NewSearchGraph/GraphMenuContent/GraphContent";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { useGraphMenu } from "../NewBlockMenu/GraphMenu/useGraphMenu";
import { useGraphMenu } from "../NewControlPanel/NewSearchGraph/GraphMenu/useGraphMenu";
interface GraphSearchControlProps {
nodes: CustomNode[];

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import { DefaultStateType } from "../components/NewBlockMenu/types";
import { DefaultStateType } from "../components/NewControlPanel/NewBlockMenu/types";
type BlockMenuStore = {
searchQuery: string;

View File

@@ -0,0 +1,22 @@
import { create } from "zustand";
type ControlPanelStore = {
blockMenuOpen: boolean;
saveControlOpen: boolean;
setBlockMenuOpen: (open: boolean) => void;
setSaveControlOpen: (open: boolean) => void;
reset: () => void;
};
export const useControlPanelStore = create<ControlPanelStore>((set) => ({
blockMenuOpen: false,
saveControlOpen: false,
setBlockMenuOpen: (open) => set({ blockMenuOpen: open }),
setSaveControlOpen: (open) => set({ saveControlOpen: open }),
reset: () =>
set({
blockMenuOpen: false,
saveControlOpen: false,
}),
}));

View File

@@ -23,6 +23,7 @@ type EdgeStore = {
getNodeConnections: (nodeId: string) => Connection[];
isInputConnected: (nodeId: string, handle: string) => boolean;
isOutputConnected: (nodeId: string, handle: string) => boolean;
getBackendLinks: () => Link[];
addLinks: (links: Link[]) => void;
getAllHandleIdsOfANode: (nodeId: string) => string[];

View File

@@ -3,6 +3,7 @@ import { NodeChange, applyNodeChanges } from "@xyflow/react";
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import { convertBlockInfoIntoCustomNodeData } from "../components/helper";
import { Node } from "@/app/api/__generated__/models/node";
type NodeStore = {
nodes: CustomNode[];
@@ -19,6 +20,8 @@ type NodeStore = {
getShowAdvanced: (nodeId: string) => boolean;
addNodes: (nodes: CustomNode[]) => void;
getHardCodedValues: (nodeId: string) => Record<string, any>;
convertCustomNodeToBackendNode: (node: CustomNode) => Node;
getBackendNodes: () => Node[];
};
export const useNodeStore = create<NodeStore>((set, get) => ({
@@ -84,4 +87,20 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
get().nodes.find((n) => n.id === nodeId)?.data?.hardcodedValues || {}
);
},
convertCustomNodeToBackendNode: (node: CustomNode) => {
return {
id: node.id,
block_id: node.data.block_id,
input_default: node.data.hardcodedValues,
metadata: {
// TODO: Add more metadata
position: node.position,
},
};
},
getBackendNodes: () => {
return get().nodes.map((node) =>
get().convertCustomNodeToBackendNode(node),
);
},
}));

View File

@@ -20,7 +20,7 @@ test("user can signup successfully", async ({ page }) => {
const marketplaceText = getText(
"Bringing you AI agents designed by thinkers from around the world",
);
).first();
// Verify we're on marketplace and authenticated
await hasUrl(page, "/marketplace");