+ {/* Timestamps section */}
+
+
+
+ Started At:
+
+
+ {result.started_at
+ ? new Date(
+ result.started_at,
+ ).toLocaleString()
+ : "—"}
+
+
+
+
+ Ended At:
+
+
+ {result.ended_at
+ ? new Date(result.ended_at).toLocaleString()
+ : "—"}
+
+
+
+
{result.summary_text && (
- Execution Accuracy Trends
+
+
Execution Accuracy Trends
+
+
+ Chart Filters (matches monitoring system):
+
+
+ Only days with ≥1 execution with correctness score
+ Last 30 days
+ Averages calculated from scored executions only
+
+
+
{/* Alert Section */}
{trendsData.alert && (
diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts b/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts
index 13f8d988fe..e7e2997d0d 100644
--- a/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts
@@ -1,15 +1,15 @@
-import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
+import { getOnboardingStatus } from "@/app/api/helpers";
import BackendAPI from "@/lib/autogpt-server-api";
-import { NextResponse } from "next/server";
+import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import { revalidatePath } from "next/cache";
-import { shouldShowOnboarding } from "@/app/api/helpers";
+import { NextResponse } from "next/server";
// Handle the callback to complete the user session login
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
- let next = "/marketplace";
+ let next = "/";
if (code) {
const supabase = await getServerSupabase();
@@ -25,11 +25,14 @@ export async function GET(request: Request) {
const api = new BackendAPI();
await api.createUser();
- if (await shouldShowOnboarding()) {
+ // Get onboarding status from backend (includes chat flag evaluated for this user)
+ const { shouldShowOnboarding } = await getOnboardingStatus();
+ if (shouldShowOnboarding) {
next = "/onboarding";
revalidatePath("/onboarding", "layout");
} else {
- revalidatePath("/", "layout");
+ next = "/";
+ revalidatePath(next, "layout");
}
} catch (createUserError) {
console.error("Error creating user:", createUserError);
diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts
index 41d05a9afb..fd67519957 100644
--- a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts
@@ -1,6 +1,17 @@
import { OAuthPopupResultMessage } from "./types";
import { NextResponse } from "next/server";
+/**
+ * Safely encode a value as JSON for embedding in a script tag.
+ * Escapes characters that could break out of the script context to prevent XSS.
+ */
+function safeJsonStringify(value: unknown): string {
+ return JSON.stringify(value)
+ .replace(//g, "\\u003e")
+ .replace(/&/g, "\\u0026");
+}
+
// This route is intended to be used as the callback for integration OAuth flows,
// controlled by the CredentialsInput component. The CredentialsInput opens the login
// page in a pop-up window, which then redirects to this route to close the loop.
@@ -23,12 +34,13 @@ export async function GET(request: Request) {
console.debug("Sending message to opener:", message);
// Return a response with the message as JSON and a script to close the window
+ // Use safeJsonStringify to prevent XSS by escaping <, >, and & characters
return new NextResponse(
`
diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx
index 3372772c89..9e2e637ef6 100644
--- a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx
@@ -1,22 +1,22 @@
"use client";
-import Image from "next/image";
-import Link from "next/link";
-import { useSearchParams } from "next/navigation";
-import { useState, useMemo, useRef } from "react";
-import { AuthCard } from "@/components/auth/AuthCard";
-import { Text } from "@/components/atoms/Text/Text";
+import { useGetOauthGetOauthAppInfo } from "@/app/api/__generated__/endpoints/oauth/oauth";
+import { okData } from "@/app/api/helpers";
import { Button } from "@/components/atoms/Button/Button";
+import { Text } from "@/components/atoms/Text/Text";
+import { AuthCard } from "@/components/auth/AuthCard";
+import { CredentialsInput } from "@/components/contextual/CredentialsInput/CredentialsInput";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
-import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import type {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
CredentialsType,
} from "@/lib/autogpt-server-api";
import { CheckIcon, CircleIcon } from "@phosphor-icons/react";
-import { useGetOauthGetOauthAppInfo } from "@/app/api/__generated__/endpoints/oauth/oauth";
-import { okData } from "@/app/api/helpers";
+import Image from "next/image";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { useMemo, useRef, useState } from "react";
// All credential types - we accept any type of credential
const ALL_CREDENTIAL_TYPES: CredentialsType[] = [
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/BuilderActions.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/BuilderActions.tsx
index 64eb624621..86e4a3eb9c 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/BuilderActions.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/BuilderActions.tsx
@@ -10,7 +10,10 @@ export const BuilderActions = memo(() => {
flowID: parseAsString,
});
return (
-
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx
index de56bb46b8..8ec1ba8be3 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx
@@ -1,11 +1,6 @@
import { BlockUIType } from "@/app/(platform)/build/components/types";
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
-import {
- globalRegistry,
- OutputActions,
- OutputItem,
-} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
import { Label } from "@/components/__legacy__/ui/label";
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
import {
@@ -23,6 +18,11 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
+import {
+ globalRegistry,
+ OutputActions,
+ OutputItem,
+} from "@/components/contextual/OutputRenderers";
import { BookOpenIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
@@ -38,8 +38,12 @@ export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
return outputNodes
.map((node) => {
- const executionResult = node.data.nodeExecutionResult;
- const outputData = executionResult?.output_data?.output;
+ const executionResults = node.data.nodeExecutionResults || [];
+ const latestResult =
+ executionResults.length > 0
+ ? executionResults[executionResults.length - 1]
+ : undefined;
+ const outputData = latestResult?.output_data?.output;
const renderer = globalRegistry.getRenderer(outputData);
@@ -79,6 +83,7 @@ export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/RunGraph.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/RunGraph.tsx
index 7ee00ec285..57890b1f17 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/RunGraph.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/RunGraph.tsx
@@ -5,10 +5,11 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
-import { PlayIcon, StopIcon } from "@phosphor-icons/react";
+import { CircleNotchIcon, PlayIcon, StopIcon } from "@phosphor-icons/react";
import { useShallow } from "zustand/react/shallow";
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
import { useRunGraph } from "./useRunGraph";
+import { cn } from "@/lib/utils";
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
const {
@@ -24,6 +25,31 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
useShallow((state) => state.isGraphRunning),
);
+ const isLoading = isExecutingGraph || isTerminatingGraph || isSaving;
+
+ // Determine which icon to show with proper animation
+ const renderIcon = () => {
+ const iconClass = cn(
+ "size-4 transition-transform duration-200 ease-out",
+ !isLoading && "group-hover:scale-110",
+ );
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (isGraphRunning) {
+ return ;
+ }
+
+ return ;
+ };
+
return (
<>
@@ -31,19 +57,20 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
- {!isGraphRunning ? (
-
- ) : (
-
- )}
+ {renderIcon()}
- {isGraphRunning ? "Stop agent" : "Run agent"}
+ {isLoading
+ ? "Processing..."
+ : isGraphRunning
+ ? "Stop agent"
+ : "Run agent"}
{
const { saveGraph, isSaving } = useSaveGraph({
@@ -33,6 +34,29 @@ export const useRunGraph = () => {
useShallow((state) => state.clearAllNodeErrors),
);
+ // Tutorial integration - force open dialog when tutorial requests it
+ const forceOpenRunInputDialog = useTutorialStore(
+ (state) => state.forceOpenRunInputDialog,
+ );
+ const setForceOpenRunInputDialog = useTutorialStore(
+ (state) => state.setForceOpenRunInputDialog,
+ );
+
+ // Sync tutorial state with dialog state
+ useEffect(() => {
+ if (forceOpenRunInputDialog && !openRunInputDialog) {
+ setOpenRunInputDialog(true);
+ }
+ }, [forceOpenRunInputDialog, openRunInputDialog]);
+
+ // Reset tutorial state when dialog closes
+ const handleSetOpenRunInputDialog = (isOpen: boolean) => {
+ setOpenRunInputDialog(isOpen);
+ if (!isOpen && forceOpenRunInputDialog) {
+ setForceOpenRunInputDialog(false);
+ }
+ };
+
const [{ flowID, flowVersion, flowExecutionID }, setQueryStates] =
useQueryStates({
flowID: parseAsString,
@@ -138,6 +162,6 @@ export const useRunGraph = () => {
isExecutingGraph,
isTerminatingGraph,
openRunInputDialog,
- setOpenRunInputDialog,
+ setOpenRunInputDialog: handleSetOpenRunInputDialog,
};
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog.tsx
index 431feeaade..51308a4a82 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog.tsx
@@ -8,6 +8,9 @@ import { Text } from "@/components/atoms/Text/Text";
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
import { useRunInputDialog } from "./useRunInputDialog";
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
+import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore";
+import { useEffect } from "react";
+import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
export const RunInputDialog = ({
isOpen,
@@ -21,22 +24,35 @@ export const RunInputDialog = ({
const hasInputs = useGraphStore((state) => state.hasInputs);
const hasCredentials = useGraphStore((state) => state.hasCredentials);
const inputSchema = useGraphStore((state) => state.inputSchema);
- const credentialsSchema = useGraphStore(
- (state) => state.credentialsInputSchema,
- );
const {
- credentialsUiSchema,
+ credentialFields,
+ requiredCredentials,
handleManualRun,
handleInputChange,
openCronSchedulerDialog,
setOpenCronSchedulerDialog,
inputValues,
credentialValues,
- handleCredentialChange,
+ handleCredentialFieldChange,
isExecutingGraph,
} = useRunInputDialog({ setIsOpen });
+ // Tutorial integration - track input values for the tutorial
+ const setTutorialInputValues = useTutorialStore(
+ (state) => state.setTutorialInputValues,
+ );
+ const isTutorialRunning = useTutorialStore(
+ (state) => state.isTutorialRunning,
+ );
+
+ // Update tutorial store when input values change
+ useEffect(() => {
+ if (isTutorialRunning) {
+ setTutorialInputValues(inputValues);
+ }
+ }, [inputValues, isTutorialRunning, setTutorialInputValues]);
+
return (
<>
-
- {/* Credentials Section */}
- {hasCredentials() && (
-
-
-
- Credentials
-
+
+
+ {/* Credentials Section */}
+ {hasCredentials() && credentialFields.length > 0 && (
+
+
+
+ Credentials
+
+
+
+
+
-
- handleCredentialChange(v.formData)}
- uiSchema={credentialsUiSchema}
- initialValues={{}}
- formContext={{
- showHandles: false,
- size: "large",
- }}
- />
-
-
- )}
+ )}
- {/* Inputs Section */}
- {hasInputs() && (
-
-
-
- Inputs
-
+ {/* Inputs Section */}
+ {hasInputs() && (
+
+
+
+ Inputs
+
+
+
+ handleInputChange(v.formData)}
+ uiSchema={uiSchema}
+ initialValues={{}}
+ formContext={{
+ showHandles: false,
+ size: "large",
+ }}
+ />
+
-
- handleInputChange(v.formData)}
- uiSchema={uiSchema}
- initialValues={{}}
- formContext={{
- showHandles: false,
- size: "large",
- }}
- />
-
-
- )}
+ )}
+
- {/* Action Button */}
-
+
{purpose === "run" && (
{!isExecutingGraph && (
@@ -115,8 +136,9 @@ export const RunInputDialog = ({
setOpenCronSchedulerDialog(true)}
+ data-id="run-input-schedule-button"
>
Schedule Run
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts
index a71ad0bd07..629d4662a9 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts
@@ -1,14 +1,17 @@
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import { usePostV1ExecuteGraphAgent } from "@/app/api/__generated__/endpoints/graphs/graphs";
-import { useToast } from "@/components/molecules/Toast/use-toast";
+
import {
+ ApiError,
CredentialsMetaInput,
GraphExecutionMeta,
} from "@/lib/autogpt-server-api";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
-import { useMemo, useState } from "react";
-import { uiSchema } from "../../../FlowEditor/nodes/uiSchema";
-import { isCredentialFieldSchema } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers";
+import { useCallback, useMemo, useState } from "react";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+import { useToast } from "@/components/molecules/Toast/use-toast";
+import { useReactFlow } from "@xyflow/react";
+import type { CredentialField } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/helpers";
export const useRunInputDialog = ({
setIsOpen,
@@ -31,6 +34,7 @@ export const useRunInputDialog = ({
flowVersion: parseAsInteger,
});
const { toast } = useToast();
+ const { setViewport } = useReactFlow();
const { mutateAsync: executeGraph, isPending: isExecutingGraph } =
usePostV1ExecuteGraphAgent({
@@ -42,46 +46,122 @@ export const useRunInputDialog = ({
});
},
onError: (error) => {
- // Reset running state on error
+ if (error instanceof ApiError && error.isGraphValidationError()) {
+ const errorData = error.response?.detail || {
+ node_errors: {},
+ message: undefined,
+ };
+ const nodeErrors = errorData.node_errors || {};
+
+ if (Object.keys(nodeErrors).length > 0) {
+ Object.entries(nodeErrors).forEach(
+ ([nodeId, nodeErrorsForNode]) => {
+ useNodeStore
+ .getState()
+ .updateNodeErrors(
+ nodeId,
+ nodeErrorsForNode as { [key: string]: string },
+ );
+ },
+ );
+ } else {
+ useNodeStore.getState().nodes.forEach((node) => {
+ useNodeStore.getState().updateNodeErrors(node.id, {});
+ });
+ }
+
+ toast({
+ title: errorData?.message || "Graph validation failed",
+ description:
+ "Please fix the validation errors on the highlighted nodes and try again.",
+ variant: "destructive",
+ });
+ setIsOpen(false);
+
+ const firstBackendId = Object.keys(nodeErrors)[0];
+
+ if (firstBackendId) {
+ const firstErrorNode = useNodeStore
+ .getState()
+ .nodes.find(
+ (n) =>
+ n.data.metadata?.backend_id === firstBackendId ||
+ n.id === firstBackendId,
+ );
+
+ if (firstErrorNode) {
+ setTimeout(() => {
+ setViewport(
+ {
+ x:
+ -firstErrorNode.position.x * 0.8 +
+ window.innerWidth / 2 -
+ 150,
+ y: -firstErrorNode.position.y * 0.8 + 50,
+ zoom: 0.8,
+ },
+ { duration: 500 },
+ );
+ }, 50);
+ }
+ }
+ } else {
+ toast({
+ title: "Error running graph",
+ description:
+ (error as Error).message || "An unexpected error occurred.",
+ variant: "destructive",
+ });
+ setIsOpen(false);
+ }
setIsGraphRunning(false);
- toast({
- title: (error.detail as string) ?? "An unexpected error occurred.",
- description: "An unexpected error occurred.",
- variant: "destructive",
- });
},
},
});
- // We are rendering the credentials field differently compared to other fields.
- // In the node, we have the field name as "credential" - so our library catches it and renders it differently.
- // But here we have a different name, something like `Firecrawl credentials`, so here we are telling the library that this field is a credential field type.
-
- const credentialsUiSchema = useMemo(() => {
- const dynamicUiSchema: any = { ...uiSchema };
-
- if (credentialsSchema?.properties) {
- Object.keys(credentialsSchema.properties).forEach((fieldName) => {
- const fieldSchema = credentialsSchema.properties[fieldName];
- if (isCredentialFieldSchema(fieldSchema)) {
- dynamicUiSchema[fieldName] = {
- ...dynamicUiSchema[fieldName],
- "ui:field": "credentials",
- };
- }
- });
- }
-
- return dynamicUiSchema;
+ // Convert credentials schema to credential fields array for CredentialsGroupedView
+ const credentialFields: CredentialField[] = useMemo(() => {
+ if (!credentialsSchema?.properties) return [];
+ return Object.entries(credentialsSchema.properties);
}, [credentialsSchema]);
+ // Get required credentials as a Set
+ const requiredCredentials = useMemo(() => {
+ return new Set(credentialsSchema?.required || []);
+ }, [credentialsSchema]);
+
+ // Handler for individual credential changes
+ const handleCredentialFieldChange = useCallback(
+ (key: string, value?: CredentialsMetaInput) => {
+ setCredentialValues((prev) => {
+ if (value) {
+ return { ...prev, [key]: value };
+ } else {
+ const next = { ...prev };
+ delete next[key];
+ return next;
+ }
+ });
+ },
+ [],
+ );
+
const handleManualRun = async () => {
+ // Filter out incomplete credentials (those without a valid id)
+ // RJSF auto-populates const values (provider, type) but not id field
+ const validCredentials = Object.fromEntries(
+ Object.entries(credentialValues).filter(([_, cred]) => cred && cred.id),
+ );
+
+ useNodeStore.getState().clearAllNodeExecutionResults();
+ useNodeStore.getState().cleanNodesStatuses();
+
await executeGraph({
graphId: flowID ?? "",
graphVersion: flowVersion || null,
data: {
inputs: inputValues,
- credentials_inputs: credentialValues,
+ credentials_inputs: validCredentials,
source: "builder",
},
});
@@ -100,12 +180,14 @@ export const useRunInputDialog = ({
};
return {
- credentialsUiSchema,
+ credentialFields,
+ requiredCredentials,
inputValues,
credentialValues,
isExecutingGraph,
handleInputChange,
handleCredentialChange,
+ handleCredentialFieldChange,
handleManualRun,
openCronSchedulerDialog,
setOpenCronSchedulerDialog,
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/ScheduleGraph/ScheduleGraph.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/ScheduleGraph/ScheduleGraph.tsx
index 5cc8538de1..854917154e 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/ScheduleGraph/ScheduleGraph.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/ScheduleGraph/ScheduleGraph.tsx
@@ -26,6 +26,7 @@ export const ScheduleGraph = ({ flowID }: { flowID: string | null }) => {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FloatingSafeModeToogle.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FloatingSafeModeToogle.tsx
index c1a7ef3b35..227d892fff 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FloatingSafeModeToogle.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FloatingSafeModeToogle.tsx
@@ -18,69 +18,110 @@ interface Props {
fullWidth?: boolean;
}
+interface SafeModeButtonProps {
+ isEnabled: boolean;
+ label: string;
+ tooltipEnabled: string;
+ tooltipDisabled: string;
+ onToggle: () => void;
+ isPending: boolean;
+ fullWidth?: boolean;
+}
+
+function SafeModeButton({
+ isEnabled,
+ label,
+ tooltipEnabled,
+ tooltipDisabled,
+ onToggle,
+ isPending,
+ fullWidth = false,
+}: SafeModeButtonProps) {
+ return (
+
+
+
+ {isEnabled ? (
+ <>
+
+
+ {label}: ON
+
+ >
+ ) : (
+ <>
+
+
+ {label}: OFF
+
+ >
+ )}
+
+
+
+
+
+ {label}: {isEnabled ? "ON" : "OFF"}
+
+
+ {isEnabled ? tooltipEnabled : tooltipDisabled}
+
+
+
+
+ );
+}
+
export function FloatingSafeModeToggle({
graph,
className,
fullWidth = false,
}: Props) {
const {
- currentSafeMode,
+ currentHITLSafeMode,
+ showHITLToggle,
+ handleHITLToggle,
+ currentSensitiveActionSafeMode,
+ showSensitiveActionToggle,
+ handleSensitiveActionToggle,
isPending,
shouldShowToggle,
- isStateUndetermined,
- handleToggle,
} = useAgentSafeMode(graph);
- if (!shouldShowToggle || isStateUndetermined || isPending) {
+ if (!shouldShowToggle || isPending) {
return null;
}
return (
-
-
-
-
- {currentSafeMode! ? (
- <>
-
-
- Safe Mode: ON
-
- >
- ) : (
- <>
-
-
- Safe Mode: OFF
-
- >
- )}
-
-
-
-
-
- Safe Mode: {currentSafeMode! ? "ON" : "OFF"}
-
-
- {currentSafeMode!
- ? "Human in the loop blocks require manual review"
- : "Human in the loop blocks proceed automatically"}
-
-
-
-
+
+ {showHITLToggle && (
+
+ )}
+ {showSensitiveActionToggle && (
+
+ )}
);
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx
index faaebb6b35..87ae4300b8 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx
@@ -55,14 +55,16 @@ export const Flow = () => {
const edgeTypes = useMemo(() => ({ custom: CustomEdge }), []);
const onNodeDragStop = useCallback(() => {
+ const currentNodes = useNodeStore.getState().nodes;
setNodes(
- resolveCollisions(nodes, {
+ resolveCollisions(currentNodes, {
maxIterations: Infinity,
overlapThreshold: 0.5,
margin: 15,
}),
);
- }, [setNodes, nodes]);
+ }, [setNodes]);
+
const { edges, onConnect, onEdgesChange } = useCustomEdge();
// for loading purpose
@@ -97,6 +99,9 @@ export const Flow = () => {
onConnect={onConnect}
onEdgesChange={onEdgesChange}
onNodeDragStop={onNodeDragStop}
+ onNodeContextMenu={(event) => {
+ event.preventDefault();
+ }}
maxZoom={2}
minZoom={0.1}
onDragOver={onDragOver}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/components/CustomControl.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/components/CustomControl.tsx
index c6f8e8ded4..7b723d73b3 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/components/CustomControl.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/components/CustomControl.tsx
@@ -6,12 +6,17 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import {
+ ChalkboardIcon,
+ CircleNotchIcon,
FrameCornersIcon,
MinusIcon,
PlusIcon,
} from "@phosphor-icons/react/dist/ssr";
import { LockIcon, LockOpenIcon } from "lucide-react";
-import { memo } from "react";
+import { memo, useEffect, useState } from "react";
+import { useSearchParams, useRouter } from "next/navigation";
+import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore";
+import { startTutorial, setTutorialLoadingCallback } from "../../tutorial";
export const CustomControls = memo(
({
@@ -22,31 +27,69 @@ export const CustomControls = memo(
setIsLocked: (isLocked: boolean) => void;
}) => {
const { zoomIn, zoomOut, fitView } = useReactFlow();
+ const { isTutorialRunning, setIsTutorialRunning } = useTutorialStore();
+ const [isTutorialLoading, setIsTutorialLoading] = useState(false);
+ const searchParams = useSearchParams();
+ const router = useRouter();
+
+ useEffect(() => {
+ setTutorialLoadingCallback(setIsTutorialLoading);
+ return () => setTutorialLoadingCallback(() => {});
+ }, []);
+
+ const handleTutorialClick = () => {
+ if (isTutorialLoading) return;
+
+ const flowId = searchParams.get("flowID");
+ if (flowId) {
+ router.push("/build?view=new");
+ return;
+ }
+
+ startTutorial();
+ setIsTutorialRunning(true);
+ };
const controls = [
{
- icon:
,
+ id: "zoom-in-button",
+ icon:
,
label: "Zoom In",
onClick: () => zoomIn(),
className: "h-10 w-10 border-none",
},
{
- icon:
,
+ id: "zoom-out-button",
+ icon:
,
label: "Zoom Out",
onClick: () => zoomOut(),
className: "h-10 w-10 border-none",
},
{
- icon:
,
+ id: "tutorial-button",
+ icon: isTutorialLoading ? (
+
+ ) : (
+
+ ),
+ label: isTutorialLoading ? "Loading Tutorial..." : "Start Tutorial",
+ onClick: handleTutorialClick,
+ className: `h-10 w-10 border-none ${isTutorialRunning || isTutorialLoading ? "bg-zinc-100" : "bg-white"}`,
+ disabled: isTutorialLoading,
+ },
+ {
+ id: "fit-view-button",
+ icon:
,
label: "Fit View",
onClick: () => fitView({ padding: 0.2, duration: 800, maxZoom: 1 }),
className: "h-10 w-10 border-none",
},
{
+ id: "lock-button",
icon: !isLocked ? (
-
+
) : (
-
+
),
label: "Toggle Lock",
onClick: () => setIsLocked(!isLocked),
@@ -55,15 +98,20 @@ export const CustomControls = memo(
];
return (
-
- {controls.map((control, index) => (
-
+
+ {controls.map((control) => (
+
{control.icon}
{control.label}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts
index 407482073f..f5533848d2 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts
@@ -3,6 +3,7 @@ import { useGetV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/def
import {
useGetV1GetExecutionDetails,
useGetV1GetSpecificGraph,
+ useGetV1ListUserGraphs,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
@@ -17,6 +18,7 @@ import { useReactFlow } from "@xyflow/react";
import { useControlPanelStore } from "../../../stores/controlPanelStore";
import { useHistoryStore } from "../../../stores/historyStore";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
+import { okData } from "@/app/api/helpers";
export const useFlow = () => {
const [isLocked, setIsLocked] = useState(false);
@@ -36,6 +38,9 @@ export const useFlow = () => {
const setGraphExecutionStatus = useGraphStore(
useShallow((state) => state.setGraphExecutionStatus),
);
+ const setAvailableSubGraphs = useGraphStore(
+ useShallow((state) => state.setAvailableSubGraphs),
+ );
const updateEdgeBeads = useEdgeStore(
useShallow((state) => state.updateEdgeBeads),
);
@@ -62,6 +67,11 @@ export const useFlow = () => {
},
);
+ // Fetch all available graphs for sub-agent update detection
+ const { data: availableGraphs } = useGetV1ListUserGraphs({
+ query: { select: okData },
+ });
+
const { data: graph, isLoading: isGraphLoading } = useGetV1GetSpecificGraph(
flowID ?? "",
flowVersion !== null ? { version: flowVersion } : {},
@@ -116,19 +126,19 @@ export const useFlow = () => {
}
}, [graph]);
+ // Update available sub-graphs in store for sub-agent update detection
+ useEffect(() => {
+ if (availableGraphs) {
+ setAvailableSubGraphs(availableGraphs);
+ }
+ }, [availableGraphs, setAvailableSubGraphs]);
+
// adding nodes
useEffect(() => {
if (customNodes.length > 0) {
useNodeStore.getState().setNodes([]);
+ useNodeStore.getState().clearResolutionState();
addNodes(customNodes);
-
- // Sync hardcoded values with handle IDs.
- // If a key–value field has a key without a value, the backend omits it from hardcoded values.
- // But if a handleId exists for that key, it causes inconsistency.
- // This ensures hardcoded values stay in sync with handle IDs.
- customNodes.forEach((node) => {
- useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
- });
}
}, [customNodes, addNodes]);
@@ -140,6 +150,14 @@ export const useFlow = () => {
}
}, [graph?.links, addLinks]);
+ useEffect(() => {
+ if (customNodes.length > 0 && graph?.links) {
+ customNodes.forEach((node) => {
+ useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
+ });
+ }
+ }, [customNodes, graph?.links]);
+
// update node execution status in nodes
useEffect(() => {
if (
@@ -203,6 +221,7 @@ export const useFlow = () => {
useEffect(() => {
return () => {
useNodeStore.getState().setNodes([]);
+ useNodeStore.getState().clearResolutionState();
useEdgeStore.getState().setEdges([]);
useGraphStore.getState().reset();
useEdgeStore.getState().resetEdgeBeads();
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx
index ff80fdc8ac..3b6425a7c6 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx
@@ -8,6 +8,7 @@ import {
getBezierPath,
} from "@xyflow/react";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { XIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { NodeExecutionResult } from "@/lib/autogpt-server-api";
@@ -18,6 +19,8 @@ export type CustomEdgeData = {
beadUp?: number;
beadDown?: number;
beadData?: Map;
+ edgeColorClass?: string;
+ edgeHexColor?: string;
};
export type CustomEdge = XYEdge;
@@ -35,6 +38,7 @@ const CustomEdge = ({
selected,
}: EdgeProps) => {
const removeConnection = useEdgeStore((state) => state.removeEdge);
+ const isBroken = useNodeStore((state) => state.isEdgeBroken(id));
const [isHovered, setIsHovered] = useState(false);
const [edgePath, labelX, labelY] = getBezierPath({
@@ -49,6 +53,13 @@ const CustomEdge = ({
const isStatic = data?.isStatic ?? false;
const beadUp = data?.beadUp ?? 0;
const beadDown = data?.beadDown ?? 0;
+ const edgeColorClass = data?.edgeColorClass;
+
+ const handleRemoveEdge = () => {
+ removeConnection(id);
+ // Note: broken edge tracking is cleaned up automatically by useSubAgentUpdateState
+ // when it detects the edge no longer exists
+ };
return (
<>
@@ -57,9 +68,13 @@ const CustomEdge = ({
markerEnd={markerEnd}
className={cn(
isStatic && "!stroke-[1.5px] [stroke-dasharray:6]",
- selected
- ? "stroke-zinc-800"
- : "stroke-zinc-500/50 hover:stroke-zinc-500",
+ isBroken
+ ? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]"
+ : selected
+ ? "stroke-zinc-800"
+ : edgeColorClass
+ ? cn(edgeColorClass, "opacity-70 hover:opacity-100")
+ : "stroke-zinc-500/50 hover:stroke-zinc-500",
)}
/>
removeConnection(id)}
+ onClick={handleRemoveEdge}
className={cn(
"absolute h-fit min-w-0 p-1 transition-opacity",
- isHovered ? "opacity-100" : "opacity-0",
+ isBroken
+ ? "bg-red-500 opacity-100 hover:bg-red-600"
+ : isHovered
+ ? "opacity-100"
+ : "opacity-0",
)}
- variant="secondary"
+ variant={isBroken ? "primary" : "secondary"}
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: "all",
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts
index bf4ba3a418..6342008948 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts
@@ -6,7 +6,9 @@ import {
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { useCallback } from "react";
import { useNodeStore } from "../../../stores/nodeStore";
+import { useHistoryStore } from "../../../stores/historyStore";
import { CustomEdge } from "./CustomEdge";
+import { getEdgeColorFromOutputType } from "../nodes/helpers";
export const useCustomEdge = () => {
const edges = useEdgeStore((s) => s.edges);
@@ -33,8 +35,13 @@ export const useCustomEdge = () => {
if (exists) return;
const nodes = useNodeStore.getState().nodes;
- const isStatic = nodes.find((n) => n.id === conn.source)?.data
- ?.staticOutput;
+ const sourceNode = nodes.find((n) => n.id === conn.source);
+ const isStatic = sourceNode?.data?.staticOutput;
+
+ const { colorClass, hexColor } = getEdgeColorFromOutputType(
+ sourceNode?.data?.outputSchema,
+ conn.sourceHandle,
+ );
addEdge({
source: conn.source,
@@ -43,6 +50,8 @@ export const useCustomEdge = () => {
targetHandle: conn.targetHandle,
data: {
isStatic,
+ edgeColorClass: colorClass,
+ edgeHexColor: hexColor,
},
});
},
@@ -51,7 +60,20 @@ export const useCustomEdge = () => {
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => {
+ const hasRemoval = changes.some((change) => change.type === "remove");
+
+ const prevState = hasRemoval
+ ? {
+ nodes: useNodeStore.getState().nodes,
+ edges: edges,
+ }
+ : null;
+
setEdges(applyEdgeChanges(changes, edges));
+
+ if (prevState) {
+ useHistoryStore.getState().pushState(prevState);
+ }
},
[edges, setEdges],
);
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/NodeHandle.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/NodeHandle.tsx
index 99edb00c45..fdec177788 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/NodeHandle.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/NodeHandle.tsx
@@ -3,6 +3,7 @@ import { Handle, Position } from "@xyflow/react";
import { useEdgeStore } from "../../../stores/edgeStore";
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
import { cn } from "@/lib/utils";
+import { useNodeStore } from "../../../stores/nodeStore";
const InputNodeHandle = ({
handleId,
@@ -15,6 +16,9 @@ const InputNodeHandle = ({
const isInputConnected = useEdgeStore((state) =>
state.isInputConnected(nodeId ?? "", cleanedHandleId),
);
+ const isInputBroken = useNodeStore((state) =>
+ state.isInputBroken(nodeId, cleanedHandleId),
+ );
return (
@@ -38,27 +46,34 @@ const OutputNodeHandle = ({
field_name,
nodeId,
hexColor,
+ isBroken,
}: {
field_name: string;
nodeId: string;
hexColor: string;
+ isBroken: boolean;
}) => {
const isOutputConnected = useEdgeStore((state) =>
state.isOutputConnected(nodeId, field_name),
);
+
return (
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx
index 3523079b71..d4aa26480d 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx
@@ -1,24 +1,27 @@
-import React from "react";
-import { Node as XYNode, NodeProps } from "@xyflow/react";
-import { RJSFSchema } from "@rjsf/utils";
-import { BlockUIType } from "../../../types";
-import { StickyNoteBlock } from "./components/StickyNoteBlock";
-import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
-import { BlockCost } from "@/app/api/__generated__/models/blockCost";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
+import { BlockCost } from "@/app/api/__generated__/models/blockCost";
+import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
-import { NodeContainer } from "./components/NodeContainer";
-import { NodeHeader } from "./components/NodeHeader";
-import { FormCreator } from "../FormCreator";
-import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
-import { OutputHandler } from "../OutputHandler";
-import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
-import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
-import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
-import { cn } from "@/lib/utils";
-import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
-import { AyrshareConnectButton } from "./components/AyrshareConnectButton";
import { NodeModelMetadata } from "@/app/api/__generated__/models/nodeModelMetadata";
+import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
+import { cn } from "@/lib/utils";
+import { RJSFSchema } from "@rjsf/utils";
+import { NodeProps, Node as XYNode } from "@xyflow/react";
+import React from "react";
+import { BlockUIType } from "../../../types";
+import { FormCreator } from "../FormCreator";
+import { OutputHandler } from "../OutputHandler";
+import { AyrshareConnectButton } from "./components/AyrshareConnectButton";
+import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
+import { NodeContainer } from "./components/NodeContainer";
+import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
+import { NodeHeader } from "./components/NodeHeader";
+import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
+import { NodeRightClickMenu } from "./components/NodeRightClickMenu";
+import { StickyNoteBlock } from "./components/StickyNoteBlock";
+import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
+import { SubAgentUpdateFeature } from "./components/SubAgentUpdate/SubAgentUpdateFeature";
+import { useCustomNode } from "./useCustomNode";
export type CustomNodeData = {
hardcodedValues: {
@@ -31,7 +34,7 @@ export type CustomNodeData = {
uiType: BlockUIType;
block_id: string;
status?: AgentExecutionStatus;
- nodeExecutionResult?: NodeExecutionResult;
+ nodeExecutionResults?: NodeExecutionResult[];
staticOutput?: boolean;
// TODO : We need better type safety for the following backend fields.
costs: BlockCost[];
@@ -44,6 +47,10 @@ export type CustomNode = XYNode;
export const CustomNode: React.FC> = React.memo(
({ data, id: nodeId, selected }) => {
+ const { inputSchema, outputSchema } = useCustomNode({ data, nodeId });
+
+ const isAgent = data.uiType === BlockUIType.AGENT;
+
if (data.uiType === BlockUIType.NOTE) {
return (
@@ -62,23 +69,17 @@ export const CustomNode: React.FC> = React.memo(
const isAyrshare = data.uiType === BlockUIType.AYRSHARE;
- const inputSchema =
- data.uiType === BlockUIType.AGENT
- ? (data.hardcodedValues.input_schema ?? {})
- : data.inputSchema;
-
- const outputSchema =
- data.uiType === BlockUIType.AGENT
- ? (data.hardcodedValues.output_schema ?? {})
- : data.outputSchema;
-
const hasConfigErrors =
data.errors &&
Object.values(data.errors).some(
(value) => value !== null && value !== undefined && value !== "",
);
- const outputData = data.nodeExecutionResult?.output_data;
+ const latestResult =
+ data.nodeExecutionResults && data.nodeExecutionResults.length > 0
+ ? data.nodeExecutionResults[data.nodeExecutionResults.length - 1]
+ : undefined;
+ const outputData = latestResult?.output_data;
const hasOutputError =
typeof outputData === "object" &&
outputData !== null &&
@@ -86,12 +87,11 @@ export const CustomNode: React.FC> = React.memo(
const hasErrors = hasConfigErrors || hasOutputError;
- // Currently all blockTypes design are similar - that's why i am using the same component for all of them
- // If in future - if we need some drastic change in some blockTypes design - we can create separate components for them
- return (
+ const node = (
+ {isAgent &&
}
{isWebhook &&
}
{isAyrshare &&
}
> = React.memo(
);
+
+ return (
+
+ {node}
+
+ );
},
);
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx
index da9c13335f..00004c3a8a 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx
@@ -27,6 +27,7 @@ export const NodeContainer = ({
status && nodeStyleBasedOnStatus[status],
hasErrors ? nodeStyleBasedOnStatus[AgentExecutionStatus.FAILED] : "",
)}
+ data-id={`custom-node-${nodeId}`}
>
{children}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContextMenu.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContextMenu.tsx
index 6e482122f6..1a0e23fead 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContextMenu.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContextMenu.tsx
@@ -1,26 +1,31 @@
-import { Separator } from "@/components/__legacy__/ui/separator";
+import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import {
DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
-import { DotsThreeOutlineVerticalIcon } from "@phosphor-icons/react";
-import { Copy, Trash2, ExternalLink } from "lucide-react";
-import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
-import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
+import {
+ SecondaryDropdownMenuContent,
+ SecondaryDropdownMenuItem,
+ SecondaryDropdownMenuSeparator,
+} from "@/components/molecules/SecondaryMenu/SecondaryMenu";
+import {
+ ArrowSquareOutIcon,
+ CopyIcon,
+ DotsThreeOutlineVerticalIcon,
+ TrashIcon,
+} from "@phosphor-icons/react";
import { useReactFlow } from "@xyflow/react";
-export const NodeContextMenu = ({
- nodeId,
- subGraphID,
-}: {
+type Props = {
nodeId: string;
subGraphID?: string;
-}) => {
+};
+
+export const NodeContextMenu = ({ nodeId, subGraphID }: Props) => {
const { deleteElements } = useReactFlow();
- const handleCopy = () => {
+ function handleCopy() {
useNodeStore.setState((state) => ({
nodes: state.nodes.map((node) => ({
...node,
@@ -30,47 +35,47 @@ export const NodeContextMenu = ({
useCopyPasteStore.getState().copySelectedNodes();
useCopyPasteStore.getState().pasteNodes();
- };
+ }
- const handleDelete = () => {
+ function handleDelete() {
deleteElements({ nodes: [{ id: nodeId }] });
- };
+ }
return (
-
-
-
- Copy Node
-
+
+
+
+ Copy
+
+
{subGraphID && (
- window.open(`/build?flowID=${subGraphID}`)}
- className="hover:rounded-xlarge"
- >
-
- Open Agent
-
+ <>
+ window.open(`/build?flowID=${subGraphID}`)}
+ >
+
+ Open agent
+
+
+ >
)}
-
-
-
-
- Delete
-
-
+
+
+ Delete
+
+
);
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx
index 4dadccef2b..c4659b8dcf 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx
@@ -1,31 +1,32 @@
-import { Text } from "@/components/atoms/Text/Text";
-import { beautifyString, cn } from "@/lib/utils";
-import { NodeCost } from "./NodeCost";
-import { NodeBadges } from "./NodeBadges";
-import { NodeContextMenu } from "./NodeContextMenu";
-import { CustomNodeData } from "../CustomNode";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
-import { useState } from "react";
+import { Text } from "@/components/atoms/Text/Text";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
+import { beautifyString, cn } from "@/lib/utils";
+import { useState } from "react";
+import { CustomNodeData } from "../CustomNode";
+import { NodeBadges } from "./NodeBadges";
+import { NodeContextMenu } from "./NodeContextMenu";
+import { NodeCost } from "./NodeCost";
-export const NodeHeader = ({
- data,
- nodeId,
-}: {
+type Props = {
data: CustomNodeData;
nodeId: string;
-}) => {
+};
+
+export const NodeHeader = ({ data, nodeId }: Props) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData);
- const title = (data.metadata?.customized_name as string) || data.title;
+ const title =
+ (data.metadata?.customized_name as string) ||
+ data.hardcodedValues?.agent_name ||
+ data.title;
+
const [isEditingTitle, setIsEditingTitle] = useState(false);
- const [editedTitle, setEditedTitle] = useState(
- beautifyString(title).replace("Block", "").trim(),
- );
+ const [editedTitle, setEditedTitle] = useState(title);
const handleTitleEdit = () => {
updateNodeData(nodeId, {
@@ -69,7 +70,10 @@ export const NodeHeader = ({
-
+
{beautifyString(title).replace("Block", "").trim()}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx
index 3f0ae6e350..c5df24e0e6 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx
@@ -1,7 +1,13 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/molecules/Accordion/Accordion";
import { beautifyString, cn } from "@/lib/utils";
-import { CaretDownIcon, CopyIcon, CheckIcon } from "@phosphor-icons/react";
+import { CopyIcon, CheckIcon } from "@phosphor-icons/react";
import { NodeDataViewer } from "./components/NodeDataViewer/NodeDataViewer";
import { ContentRenderer } from "./components/ContentRenderer";
import { useNodeOutput } from "./useNodeOutput";
@@ -9,135 +15,134 @@ import { ViewMoreData } from "./components/ViewMoreData";
export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
const {
- outputData,
- isExpanded,
- setIsExpanded,
+ latestOutputData,
copiedKey,
handleCopy,
executionResultId,
- inputData,
+ latestInputData,
} = useNodeOutput(nodeId);
- if (Object.keys(outputData).length === 0) {
+ if (Object.keys(latestOutputData).length === 0) {
return null;
}
return (
-
-
-
- Node Output
-
- setIsExpanded(!isExpanded)}
- className="h-fit min-w-0 p-1 text-slate-600 hover:text-slate-900"
- >
-
-
-
+
+
+
+
+
+ Node Output
+
+
+
+
+
+
Input
- {isExpanded && (
- <>
-
-
-
Input
+
-
-
-
-
-
handleCopy("input", inputData)}
- className={cn(
- "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
- copiedKey === "input" &&
- "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
- )}
- >
- {copiedKey === "input" ? (
-
- ) : (
-
- )}
-
+
+
+ handleCopy("input", latestInputData)}
+ className={cn(
+ "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
+ copiedKey === "input" &&
+ "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
+ )}
+ >
+ {copiedKey === "input" ? (
+
+ ) : (
+
+ )}
+
+
-
- {Object.entries(outputData)
- .slice(0, 2)
- .map(([key, value]) => (
-
-
-
- Pin:
-
-
- {beautifyString(key)}
-
-
-
-
- Data:
-
-
- {value.map((item, index) => (
-
-
-
- ))}
-
-
-
-
handleCopy(key, value)}
- className={cn(
- "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
- copiedKey === key &&
- "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
- )}
+ {Object.entries(latestOutputData)
+ .slice(0, 2)
+ .map(([key, value]) => {
+ return (
+
+
+
- {copiedKey === key ? (
-
- ) : (
-
- )}
-
+ Pin:
+
+
+ {beautifyString(key)}
+
+
+
+
+ Data:
+
+
+ {value.map((item, index) => (
+
+
+
+ ))}
+
+
+
+ handleCopy(key, value)}
+ className={cn(
+ "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
+ copiedKey === key &&
+ "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
+ )}
+ >
+ {copiedKey === key ? (
+
+ ) : (
+
+ )}
+
+
+
-
-
- ))}
-
-
- {Object.keys(outputData).length > 2 && (
-
- )}
- >
- )}
+ );
+ })}
+
+
+
+
+
);
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx
index 9cb1a62e3d..6571bc7b6f 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx
@@ -1,7 +1,7 @@
"use client";
-import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
-import { globalRegistry } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
+import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
+import { globalRegistry } from "@/components/contextual/OutputRenderers";
export const TextRenderer: React.FC<{
value: any;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx
index c505282e7b..680b6bc44a 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx
@@ -1,7 +1,3 @@
-import {
- OutputActions,
- OutputItem,
-} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
@@ -11,6 +7,10 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
+import {
+ OutputActions,
+ OutputItem,
+} from "@/components/contextual/OutputRenderers";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { beautifyString } from "@/lib/utils";
import {
@@ -19,22 +19,51 @@ import {
CopyIcon,
DownloadIcon,
} from "@phosphor-icons/react";
-import { FC } from "react";
+import React, { FC } from "react";
import { useNodeDataViewer } from "./useNodeDataViewer";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+import { useShallow } from "zustand/react/shallow";
+import { NodeDataType } from "../../helpers";
-interface NodeDataViewerProps {
- data: any;
+export interface NodeDataViewerProps {
+ data?: any;
pinName: string;
+ nodeId?: string;
execId?: string;
isViewMoreData?: boolean;
+ dataType?: NodeDataType;
}
export const NodeDataViewer: FC
= ({
data,
pinName,
+ nodeId,
execId = "N/A",
isViewMoreData = false,
+ dataType = "output",
}) => {
+ const executionResults = useNodeStore(
+ useShallow((state) =>
+ nodeId ? state.getNodeExecutionResults(nodeId) : [],
+ ),
+ );
+ const latestInputData = useNodeStore(
+ useShallow((state) =>
+ nodeId ? state.getLatestNodeInputData(nodeId) : undefined,
+ ),
+ );
+ const accumulatedOutputData = useNodeStore(
+ useShallow((state) =>
+ nodeId ? state.getAccumulatedNodeOutputData(nodeId) : {},
+ ),
+ );
+
+ const resolvedData =
+ data ??
+ (dataType === "input"
+ ? (latestInputData ?? {})
+ : (accumulatedOutputData[pinName] ?? []));
+
const {
outputItems,
copyExecutionId,
@@ -42,7 +71,20 @@ export const NodeDataViewer: FC = ({
handleDownloadItem,
dataArray,
copiedIndex,
- } = useNodeDataViewer(data, pinName, execId);
+ groupedExecutions,
+ totalGroupedItems,
+ handleCopyGroupedItem,
+ handleDownloadGroupedItem,
+ copiedKey,
+ } = useNodeDataViewer(
+ resolvedData,
+ pinName,
+ execId,
+ executionResults,
+ dataType,
+ );
+
+ const shouldGroupExecutions = groupedExecutions.length > 0;
return (
@@ -68,44 +110,141 @@ export const NodeDataViewer: FC = ({
- Full Output Preview
+ Full {dataType === "input" ? "Input" : "Output"} Preview
- {dataArray.length} item{dataArray.length !== 1 ? "s" : ""} total
+ {shouldGroupExecutions ? totalGroupedItems : dataArray.length}{" "}
+ item
+ {shouldGroupExecutions
+ ? totalGroupedItems !== 1
+ ? "s"
+ : ""
+ : dataArray.length !== 1
+ ? "s"
+ : ""}{" "}
+ total
-
-
- Execution ID:
-
-
- {execId}
-
-
-
-
-
-
- Pin:{" "}
- {beautifyString(pinName)}
-
+ {shouldGroupExecutions ? (
+
+ Pin:{" "}
+ {beautifyString(pinName)}
+
+ ) : (
+ <>
+
+
+ Execution ID:
+
+
+ {execId}
+
+
+
+
+
+
+ Pin:{" "}
+
+ {beautifyString(pinName)}
+
+
+ >
+ )}
- {dataArray.length > 0 ? (
+ {shouldGroupExecutions ? (
+
+ {groupedExecutions.map((execution) => (
+
+
+
+ Execution ID:
+
+
+ {execution.execId}
+
+
+
+ {execution.outputItems.length > 0 ? (
+ execution.outputItems.map((item, index) => (
+
+
+
+
+
+
+
+ handleCopyGroupedItem(
+ execution.execId,
+ index,
+ item,
+ )
+ }
+ aria-label="Copy item"
+ >
+ {copiedKey ===
+ `${execution.execId}-${index}` ? (
+
+ ) : (
+
+ )}
+
+
+ handleDownloadGroupedItem(item)
+ }
+ aria-label="Download item"
+ >
+
+
+
+
+ ))
+ ) : (
+
+ No data available
+
+ )}
+
+
+ ))}
+
+ ) : dataArray.length > 0 ? (
{outputItems.map((item, index) => (
@@ -151,7 +290,7 @@ export const NodeDataViewer: FC = ({
- {outputItems.length > 0 && (
+ {outputItems.length > 1 && (
({
value: item.value,
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts
index 1adec625a0..818d1266c1 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts
@@ -1,82 +1,70 @@
-import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
-import { globalRegistry } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
-import { downloadOutputs } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers/utils/download";
+import { downloadOutputs } from "@/components/contextual/OutputRenderers/utils/download";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { beautifyString } from "@/lib/utils";
-import React, { useMemo, useState } from "react";
+import { useState } from "react";
+import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
+import {
+ NodeDataType,
+ createOutputItems,
+ getExecutionData,
+ normalizeToArray,
+ type OutputItem,
+} from "../../helpers";
+
+export type GroupedExecution = {
+ execId: string;
+ outputItems: Array;
+};
export const useNodeDataViewer = (
data: any,
pinName: string,
execId: string,
+ executionResults?: NodeExecutionResult[],
+ dataType?: NodeDataType,
) => {
const { toast } = useToast();
const [copiedIndex, setCopiedIndex] = useState(null);
+ const [copiedKey, setCopiedKey] = useState(null);
- // Normalize data to array format
- const dataArray = useMemo(() => {
- return Array.isArray(data) ? data : [data];
- }, [data]);
+ const dataArray = Array.isArray(data) ? data : [data];
- // Prepare items for the enhanced renderer system
- const outputItems = useMemo(() => {
- if (!dataArray) return [];
-
- const items: Array<{
- key: string;
- label: string;
- value: unknown;
- metadata?: OutputMetadata;
- renderer: any;
- }> = [];
-
- dataArray.forEach((value, index) => {
- const metadata: OutputMetadata = {};
-
- // Extract metadata from the value if it's an object
- if (
- typeof value === "object" &&
- value !== null &&
- !React.isValidElement(value)
- ) {
- const objValue = value as any;
- if (objValue.type) metadata.type = objValue.type;
- if (objValue.mimeType) metadata.mimeType = objValue.mimeType;
- if (objValue.filename) metadata.filename = objValue.filename;
- if (objValue.language) metadata.language = objValue.language;
- }
-
- const renderer = globalRegistry.getRenderer(value, metadata);
- if (renderer) {
- items.push({
- key: `item-${index}`,
+ const outputItems =
+ !dataArray || dataArray.length === 0
+ ? []
+ : createOutputItems(dataArray).map((item, index) => ({
+ ...item,
label: index === 0 ? beautifyString(pinName) : "",
- value,
- metadata,
- renderer,
- });
- } else {
- // Fallback to text renderer
- const textRenderer = globalRegistry
- .getAllRenderers()
- .find((r) => r.name === "TextRenderer");
- if (textRenderer) {
- items.push({
- key: `item-${index}`,
- label: index === 0 ? beautifyString(pinName) : "",
- value:
- typeof value === "string"
- ? value
- : JSON.stringify(value, null, 2),
- metadata,
- renderer: textRenderer,
- });
- }
- }
- });
+ }));
- return items;
- }, [dataArray, pinName]);
+ const groupedExecutions =
+ !executionResults || executionResults.length === 0
+ ? []
+ : [...executionResults].reverse().map((result) => {
+ const rawData = getExecutionData(
+ result,
+ dataType || "output",
+ pinName,
+ );
+ let dataArray: unknown[];
+ if (dataType === "input") {
+ dataArray =
+ rawData !== undefined && rawData !== null ? [rawData] : [];
+ } else {
+ dataArray = normalizeToArray(rawData);
+ }
+
+ const outputItems = createOutputItems(dataArray);
+ return {
+ execId: result.node_exec_id,
+ outputItems,
+ };
+ });
+
+ const totalGroupedItems = groupedExecutions.reduce(
+ (total, execution) => total + execution.outputItems.length,
+ 0,
+ );
const copyExecutionId = () => {
navigator.clipboard.writeText(execId).then(() => {
@@ -122,6 +110,45 @@ export const useNodeDataViewer = (
]);
};
+ const handleCopyGroupedItem = async (
+ execId: string,
+ index: number,
+ item: OutputItem,
+ ) => {
+ const copyContent = item.renderer.getCopyContent(item.value, item.metadata);
+
+ if (!copyContent) {
+ return;
+ }
+
+ try {
+ let text: string;
+ if (typeof copyContent.data === "string") {
+ text = copyContent.data;
+ } else if (copyContent.fallbackText) {
+ text = copyContent.fallbackText;
+ } else {
+ return;
+ }
+
+ await navigator.clipboard.writeText(text);
+ setCopiedKey(`${execId}-${index}`);
+ setTimeout(() => setCopiedKey(null), 2000);
+ } catch (error) {
+ console.error("Failed to copy:", error);
+ }
+ };
+
+ const handleDownloadGroupedItem = (item: OutputItem) => {
+ downloadOutputs([
+ {
+ value: item.value,
+ metadata: item.metadata,
+ renderer: item.renderer,
+ },
+ ]);
+ };
+
return {
outputItems,
dataArray,
@@ -129,5 +156,10 @@ export const useNodeDataViewer = (
handleCopyItem,
handleDownloadItem,
copiedIndex,
+ groupedExecutions,
+ totalGroupedItems,
+ handleCopyGroupedItem,
+ handleDownloadGroupedItem,
+ copiedKey,
};
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ViewMoreData.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ViewMoreData.tsx
index 7bf026fe43..74d0da06c2 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ViewMoreData.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ViewMoreData.tsx
@@ -8,16 +8,28 @@ import { useState } from "react";
import { NodeDataViewer } from "./NodeDataViewer/NodeDataViewer";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { CheckIcon, CopyIcon } from "@phosphor-icons/react";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+import { useShallow } from "zustand/react/shallow";
+import {
+ NodeDataType,
+ getExecutionEntries,
+ normalizeToArray,
+} from "../helpers";
export const ViewMoreData = ({
- outputData,
- execId,
+ nodeId,
+ dataType = "output",
}: {
- outputData: Record>;
- execId?: string;
+ nodeId: string;
+ dataType?: NodeDataType;
}) => {
const [copiedKey, setCopiedKey] = useState(null);
const { toast } = useToast();
+ const executionResults = useNodeStore(
+ useShallow((state) => state.getNodeExecutionResults(nodeId)),
+ );
+
+ const reversedExecutionResults = [...executionResults].reverse();
const handleCopy = (key: string, value: any) => {
const textToCopy =
@@ -29,8 +41,8 @@ export const ViewMoreData = ({
setTimeout(() => setCopiedKey(null), 2000);
};
- const copyExecutionId = () => {
- navigator.clipboard.writeText(execId || "N/A").then(() => {
+ const copyExecutionId = (executionId: string) => {
+ navigator.clipboard.writeText(executionId || "N/A").then(() => {
toast({
title: "Execution ID copied to clipboard!",
duration: 2000,
@@ -42,7 +54,7 @@ export const ViewMoreData = ({
@@ -52,83 +64,114 @@ export const ViewMoreData = ({
- Complete Output Data
+ Complete {dataType === "input" ? "Input" : "Output"} Data
-
-
- Execution ID:
-
-
- {execId}
-
-
-
-
-
-
- {Object.entries(outputData).map(([key, value]) => (
-
+ {reversedExecutionResults.map((result) => (
+
+
+ Execution ID:
+
- Pin:
-
-
- {beautifyString(key)}
+ {result.node_exec_id}
+ copyExecutionId(result.node_exec_id)}
+ className="h-6 w-6 min-w-0 p-0"
+ >
+
+
-
-
- Data:
-
-
- {value.map((item, index) => (
-
-
-
- ))}
-
-
- handleCopy(key, value)}
- className={cn(
- "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
- copiedKey === key &&
- "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
- )}
- >
- {copiedKey === key ? (
-
- ) : (
-
- )}
-
-
-
+
+ {getExecutionEntries(result, dataType).map(
+ ([key, value]) => {
+ const normalizedValue = normalizeToArray(value);
+ return (
+
+
+
+ Pin:
+
+
+ {beautifyString(key)}
+
+
+
+
+ Data:
+
+
+ {normalizedValue.map((item, index) => (
+
+
+
+ ))}
+
+
+
+
+ handleCopy(
+ `${result.node_exec_id}-${key}`,
+ normalizedValue,
+ )
+ }
+ className={cn(
+ "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
+ copiedKey ===
+ `${result.node_exec_id}-${key}` &&
+ "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
+ )}
+ >
+ {copiedKey ===
+ `${result.node_exec_id}-${key}` ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+ },
+ )}
))}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/helpers.ts
new file mode 100644
index 0000000000..c75cd83cac
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/helpers.ts
@@ -0,0 +1,83 @@
+import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
+import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
+import { globalRegistry } from "@/components/contextual/OutputRenderers";
+import React from "react";
+
+export type NodeDataType = "input" | "output";
+
+export type OutputItem = {
+ key: string;
+ value: unknown;
+ metadata?: OutputMetadata;
+ renderer: any;
+};
+
+export const normalizeToArray = (value: unknown) => {
+ if (value === undefined) return [];
+ return Array.isArray(value) ? value : [value];
+};
+
+export const getExecutionData = (
+ result: NodeExecutionResult,
+ dataType: NodeDataType,
+ pinName: string,
+) => {
+ if (dataType === "input") {
+ return result.input_data;
+ }
+
+ return result.output_data?.[pinName];
+};
+
+export const createOutputItems = (dataArray: unknown[]): Array
=> {
+ const items: Array = [];
+
+ dataArray.forEach((value, index) => {
+ const metadata: OutputMetadata = {};
+
+ if (
+ typeof value === "object" &&
+ value !== null &&
+ !React.isValidElement(value)
+ ) {
+ const objValue = value as any;
+ if (objValue.type) metadata.type = objValue.type;
+ if (objValue.mimeType) metadata.mimeType = objValue.mimeType;
+ if (objValue.filename) metadata.filename = objValue.filename;
+ if (objValue.language) metadata.language = objValue.language;
+ }
+
+ const renderer = globalRegistry.getRenderer(value, metadata);
+ if (renderer) {
+ items.push({
+ key: `item-${index}`,
+ value,
+ metadata,
+ renderer,
+ });
+ } else {
+ const textRenderer = globalRegistry
+ .getAllRenderers()
+ .find((r) => r.name === "TextRenderer");
+ if (textRenderer) {
+ items.push({
+ key: `item-${index}`,
+ value:
+ typeof value === "string" ? value : JSON.stringify(value, null, 2),
+ metadata,
+ renderer: textRenderer,
+ });
+ }
+ }
+ });
+
+ return items;
+};
+
+export const getExecutionEntries = (
+ result: NodeExecutionResult,
+ dataType: NodeDataType,
+) => {
+ const data = dataType === "input" ? result.input_data : result.output_data;
+ return Object.entries(data || {});
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/useNodeOutput.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/useNodeOutput.tsx
index ba8559a66c..8ebf1dfaf3 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/useNodeOutput.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/useNodeOutput.tsx
@@ -4,19 +4,21 @@ import { useShallow } from "zustand/react/shallow";
import { useState } from "react";
export const useNodeOutput = (nodeId: string) => {
- const [isExpanded, setIsExpanded] = useState(true);
const [copiedKey, setCopiedKey] = useState(null);
const { toast } = useToast();
- const nodeExecutionResult = useNodeStore(
- useShallow((state) => state.getNodeExecutionResult(nodeId)),
+ const latestResult = useNodeStore(
+ useShallow((state) => state.getLatestNodeExecutionResult(nodeId)),
);
- const inputData = nodeExecutionResult?.input_data;
+ const latestInputData = useNodeStore(
+ useShallow((state) => state.getLatestNodeInputData(nodeId)),
+ );
+
+ const latestOutputData: Record> = useNodeStore(
+ useShallow((state) => state.getLatestNodeOutputData(nodeId) || {}),
+ );
- const outputData: Record> = {
- ...nodeExecutionResult?.output_data,
- };
const handleCopy = async (key: string, value: any) => {
try {
const text = JSON.stringify(value, null, 2);
@@ -36,14 +38,12 @@ export const useNodeOutput = (nodeId: string) => {
});
}
};
+
return {
- outputData: outputData,
- inputData: inputData,
- isExpanded: isExpanded,
- setIsExpanded: setIsExpanded,
- copiedKey: copiedKey,
- setCopiedKey: setCopiedKey,
- handleCopy: handleCopy,
- executionResultId: nodeExecutionResult?.node_exec_id,
+ latestOutputData,
+ latestInputData,
+ copiedKey,
+ handleCopy,
+ executionResultId: latestResult?.node_exec_id,
};
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeRightClickMenu.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeRightClickMenu.tsx
new file mode 100644
index 0000000000..a56e42544f
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeRightClickMenu.tsx
@@ -0,0 +1,104 @@
+import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+import {
+ SecondaryMenuContent,
+ SecondaryMenuItem,
+ SecondaryMenuSeparator,
+} from "@/components/molecules/SecondaryMenu/SecondaryMenu";
+import { ArrowSquareOutIcon, CopyIcon, TrashIcon } from "@phosphor-icons/react";
+import * as ContextMenu from "@radix-ui/react-context-menu";
+import { useReactFlow } from "@xyflow/react";
+import { useEffect, useRef } from "react";
+import { CustomNode } from "../CustomNode";
+
+type Props = {
+ nodeId: string;
+ subGraphID?: string;
+ children: React.ReactNode;
+};
+
+const DOUBLE_CLICK_TIMEOUT = 300;
+
+export function NodeRightClickMenu({ nodeId, subGraphID, children }: Props) {
+ const { deleteElements } = useReactFlow();
+ const lastRightClickTime = useRef(0);
+ const containerRef = useRef(null);
+
+ function copyNode() {
+ useNodeStore.setState((state) => ({
+ nodes: state.nodes.map((node) => ({
+ ...node,
+ selected: node.id === nodeId,
+ })),
+ }));
+
+ useCopyPasteStore.getState().copySelectedNodes();
+ useCopyPasteStore.getState().pasteNodes();
+ }
+
+ function deleteNode() {
+ deleteElements({ nodes: [{ id: nodeId }] });
+ }
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ function handleContextMenu(e: MouseEvent) {
+ const now = Date.now();
+ const timeSinceLastClick = now - lastRightClickTime.current;
+
+ if (timeSinceLastClick < DOUBLE_CLICK_TIMEOUT) {
+ e.stopImmediatePropagation();
+ lastRightClickTime.current = 0;
+ return;
+ }
+
+ lastRightClickTime.current = now;
+ }
+
+ container.addEventListener("contextmenu", handleContextMenu, true);
+
+ return () => {
+ container.removeEventListener("contextmenu", handleContextMenu, true);
+ };
+ }, []);
+
+ return (
+
+
+ {children}
+
+
+
+
+ Copy
+
+
+
+ {subGraphID && (
+ <>
+ window.open(`/build?flowID=${subGraphID}`)}
+ >
+
+ Open agent
+
+
+ >
+ )}
+
+
+
+ Delete
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/SubAgentUpdateFeature.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/SubAgentUpdateFeature.tsx
new file mode 100644
index 0000000000..388349b2e0
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/SubAgentUpdateFeature.tsx
@@ -0,0 +1,118 @@
+import React from "react";
+import { ArrowUpIcon, WarningIcon } from "@phosphor-icons/react";
+import { Button } from "@/components/atoms/Button/Button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/atoms/Tooltip/BaseTooltip";
+import { cn, beautifyString } from "@/lib/utils";
+import { CustomNodeData } from "../../CustomNode";
+import { useSubAgentUpdateState } from "./useSubAgentUpdateState";
+import { IncompatibleUpdateDialog } from "./components/IncompatibleUpdateDialog";
+import { ResolutionModeBar } from "./components/ResolutionModeBar";
+
+/**
+ * Inline component for the update bar that can be placed after the header.
+ * Use this inside the node content where you want the bar to appear.
+ */
+type SubAgentUpdateFeatureProps = {
+ nodeID: string;
+ nodeData: CustomNodeData;
+};
+
+export function SubAgentUpdateFeature({
+ nodeID,
+ nodeData,
+}: SubAgentUpdateFeatureProps) {
+ const {
+ updateInfo,
+ isInResolutionMode,
+ handleUpdateClick,
+ showIncompatibilityDialog,
+ setShowIncompatibilityDialog,
+ handleConfirmIncompatibleUpdate,
+ } = useSubAgentUpdateState({ nodeID: nodeID, nodeData: nodeData });
+
+ const agentName = nodeData.title || "Agent";
+
+ if (!updateInfo.hasUpdate && !isInResolutionMode) {
+ return null;
+ }
+
+ return (
+ <>
+ {isInResolutionMode ? (
+
+ ) : (
+
+ )}
+ {/* Incompatibility dialog - rendered here since this component owns the state */}
+ {updateInfo.incompatibilities && (
+ setShowIncompatibilityDialog(false)}
+ onConfirm={handleConfirmIncompatibleUpdate}
+ currentVersion={updateInfo.currentVersion}
+ latestVersion={updateInfo.latestVersion}
+ agentName={beautifyString(agentName)}
+ incompatibilities={updateInfo.incompatibilities}
+ />
+ )}
+ >
+ );
+}
+
+type SubAgentUpdateAvailableBarProps = {
+ currentVersion: number;
+ latestVersion: number;
+ isCompatible: boolean;
+ onUpdate: () => void;
+};
+
+function SubAgentUpdateAvailableBar({
+ currentVersion,
+ latestVersion,
+ isCompatible,
+ onUpdate,
+}: SubAgentUpdateAvailableBarProps): React.ReactElement {
+ return (
+
+
+
+
+ Update available (v{currentVersion} → v{latestVersion})
+
+ {!isCompatible && (
+
+
+
+
+
+ Incompatible changes detected
+
+ Click Update to see details
+
+
+
+ )}
+
+
+ Update
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/components/IncompatibleUpdateDialog.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/components/IncompatibleUpdateDialog.tsx
new file mode 100644
index 0000000000..67f20789ed
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/components/IncompatibleUpdateDialog.tsx
@@ -0,0 +1,274 @@
+import React from "react";
+import {
+ WarningIcon,
+ XCircleIcon,
+ PlusCircleIcon,
+} from "@phosphor-icons/react";
+import { Button } from "@/components/atoms/Button/Button";
+import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
+import { Dialog } from "@/components/molecules/Dialog/Dialog";
+import { beautifyString } from "@/lib/utils";
+import { IncompatibilityInfo } from "@/app/(platform)/build/hooks/useSubAgentUpdate/types";
+
+type IncompatibleUpdateDialogProps = {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ currentVersion: number;
+ latestVersion: number;
+ agentName: string;
+ incompatibilities: IncompatibilityInfo;
+};
+
+export function IncompatibleUpdateDialog({
+ isOpen,
+ onClose,
+ onConfirm,
+ currentVersion,
+ latestVersion,
+ agentName,
+ incompatibilities,
+}: IncompatibleUpdateDialogProps) {
+ const hasMissingInputs = incompatibilities.missingInputs.length > 0;
+ const hasMissingOutputs = incompatibilities.missingOutputs.length > 0;
+ const hasNewInputs = incompatibilities.newInputs.length > 0;
+ const hasNewOutputs = incompatibilities.newOutputs.length > 0;
+ const hasNewRequired = incompatibilities.newRequiredInputs.length > 0;
+ const hasTypeMismatches = incompatibilities.inputTypeMismatches.length > 0;
+
+ const hasInputChanges = hasMissingInputs || hasNewInputs;
+ const hasOutputChanges = hasMissingOutputs || hasNewOutputs;
+
+ return (
+
+
+ Incompatible Update
+
+ }
+ controlled={{
+ isOpen,
+ set: async (open) => {
+ if (!open) onClose();
+ },
+ }}
+ onClose={onClose}
+ styling={{ maxWidth: "32rem" }}
+ >
+
+
+
+ Updating {beautifyString(agentName)} from v
+ {currentVersion} to v{latestVersion} will break some connections.
+
+
+ {/* Input changes - two column layout */}
+ {hasInputChanges && (
+
+ }
+ leftTitle="Removed"
+ leftItems={incompatibilities.missingInputs}
+ rightIcon={
+
+ }
+ rightTitle="Added"
+ rightItems={incompatibilities.newInputs}
+ />
+ )}
+
+ {/* Output changes - two column layout */}
+ {hasOutputChanges && (
+
+ }
+ leftTitle="Removed"
+ leftItems={incompatibilities.missingOutputs}
+ rightIcon={
+
+ }
+ rightTitle="Added"
+ rightItems={incompatibilities.newOutputs}
+ />
+ )}
+
+ {hasTypeMismatches && (
+
+ }
+ title="Type Changed"
+ description="These connected inputs have a different type:"
+ items={incompatibilities.inputTypeMismatches.map(
+ (m) => `${m.name} (${m.oldType} → ${m.newType})`,
+ )}
+ />
+ )}
+
+ {hasNewRequired && (
+
+ }
+ title="New Required Inputs"
+ description="These inputs are now required:"
+ items={incompatibilities.newRequiredInputs}
+ />
+ )}
+
+
+
+ If you proceed, you'll need to remove the broken connections
+ before you can save or run your agent.
+
+
+
+
+
+ Cancel
+
+
+ Update Anyway
+
+
+
+
+
+ );
+}
+
+type TwoColumnSectionProps = {
+ title: string;
+ leftIcon: React.ReactNode;
+ leftTitle: string;
+ leftItems: string[];
+ rightIcon: React.ReactNode;
+ rightTitle: string;
+ rightItems: string[];
+};
+
+function TwoColumnSection({
+ title,
+ leftIcon,
+ leftTitle,
+ leftItems,
+ rightIcon,
+ rightTitle,
+ rightItems,
+}: TwoColumnSectionProps) {
+ return (
+
+
{title}
+
+ {/* Left column - Breaking changes */}
+
+
+ {leftIcon}
+ {leftTitle}
+
+
+ {leftItems.length > 0 ? (
+ leftItems.map((item) => (
+
+
+ {item}
+
+
+ ))
+ ) : (
+
+ None
+
+ )}
+
+
+
+ {/* Right column - Possible solutions */}
+
+
+ {rightIcon}
+ {rightTitle}
+
+
+ {rightItems.length > 0 ? (
+ rightItems.map((item) => (
+
+
+ {item}
+
+
+ ))
+ ) : (
+
+ None
+
+ )}
+
+
+
+
+ );
+}
+
+type SingleColumnSectionProps = {
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+ items: string[];
+};
+
+function SingleColumnSection({
+ icon,
+ title,
+ description,
+ items,
+}: SingleColumnSectionProps) {
+ return (
+
+
+ {icon}
+ {title}
+
+
+ {description}
+
+
+ {items.map((item) => (
+
+
+ {item}
+
+
+ ))}
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/components/ResolutionModeBar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/components/ResolutionModeBar.tsx
new file mode 100644
index 0000000000..f60bd60c52
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/components/ResolutionModeBar.tsx
@@ -0,0 +1,107 @@
+import React from "react";
+import { InfoIcon, WarningIcon } from "@phosphor-icons/react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/atoms/Tooltip/BaseTooltip";
+import { IncompatibilityInfo } from "@/app/(platform)/build/hooks/useSubAgentUpdate/types";
+
+type ResolutionModeBarProps = {
+ incompatibilities: IncompatibilityInfo | null;
+};
+
+export function ResolutionModeBar({
+ incompatibilities,
+}: ResolutionModeBarProps): React.ReactElement {
+ const renderIncompatibilities = () => {
+ if (!incompatibilities) return
No incompatibilities ;
+
+ const sections: React.ReactNode[] = [];
+
+ if (incompatibilities.missingInputs.length > 0) {
+ sections.push(
+
+ Missing inputs:
+ {incompatibilities.missingInputs.map((name, i) => (
+
+ {name}
+ {i < incompatibilities.missingInputs.length - 1 && ", "}
+
+ ))}
+
,
+ );
+ }
+ if (incompatibilities.missingOutputs.length > 0) {
+ sections.push(
+
+ Missing outputs:
+ {incompatibilities.missingOutputs.map((name, i) => (
+
+ {name}
+ {i < incompatibilities.missingOutputs.length - 1 && ", "}
+
+ ))}
+
,
+ );
+ }
+ if (incompatibilities.newRequiredInputs.length > 0) {
+ sections.push(
+
+ New required inputs:
+ {incompatibilities.newRequiredInputs.map((name, i) => (
+
+ {name}
+ {i < incompatibilities.newRequiredInputs.length - 1 && ", "}
+
+ ))}
+
,
+ );
+ }
+ if (incompatibilities.inputTypeMismatches.length > 0) {
+ sections.push(
+
+ Type changed:
+ {incompatibilities.inputTypeMismatches.map((m, i) => (
+
+ {m.name}
+
+ {" "}
+ ({m.oldType} → {m.newType})
+
+ {i < incompatibilities.inputTypeMismatches.length - 1 && ", "}
+
+ ))}
+
,
+ );
+ }
+
+ return <>{sections}>;
+ };
+
+ return (
+
+
+
+
+ Remove incompatible connections
+
+
+
+
+
+
+ Incompatible changes:
+ {renderIncompatibilities()}
+
+ {(incompatibilities?.newRequiredInputs.length ?? 0) > 0
+ ? "Replace / delete"
+ : "Delete"}{" "}
+ the red connections to continue
+
+
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/useSubAgentUpdateState.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/useSubAgentUpdateState.ts
new file mode 100644
index 0000000000..143cd58509
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/useSubAgentUpdateState.ts
@@ -0,0 +1,192 @@
+import { useState, useCallback, useEffect } from "react";
+import { useShallow } from "zustand/react/shallow";
+import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
+import {
+ useSubAgentUpdate,
+ createUpdatedAgentNodeInputs,
+ getBrokenEdgeIDs,
+} from "@/app/(platform)/build/hooks/useSubAgentUpdate";
+import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
+import { CustomNodeData } from "../../CustomNode";
+import { NodeResolutionData } from "@/app/(platform)/build/stores/types";
+
+// Stable empty set to avoid creating new references in selectors
+const EMPTY_SET: Set
= new Set();
+
+type UseSubAgentUpdateParams = {
+ nodeID: string;
+ nodeData: CustomNodeData;
+};
+
+export function useSubAgentUpdateState({
+ nodeID,
+ nodeData,
+}: UseSubAgentUpdateParams) {
+ const [showIncompatibilityDialog, setShowIncompatibilityDialog] =
+ useState(false);
+
+ // Get store actions
+ const updateNodeData = useNodeStore(
+ useShallow((state) => state.updateNodeData),
+ );
+ const setNodeResolutionMode = useNodeStore(
+ useShallow((state) => state.setNodeResolutionMode),
+ );
+ const isNodeInResolutionMode = useNodeStore(
+ useShallow((state) => state.isNodeInResolutionMode),
+ );
+ const setBrokenEdgeIDs = useNodeStore(
+ useShallow((state) => state.setBrokenEdgeIDs),
+ );
+ // Get this node's broken edge IDs from the per-node map
+ // Use EMPTY_SET as fallback to maintain referential stability
+ const brokenEdgeIDs = useNodeStore(
+ (state) => state.brokenEdgeIDs.get(nodeID) || EMPTY_SET,
+ );
+ const getNodeResolutionData = useNodeStore(
+ useShallow((state) => state.getNodeResolutionData),
+ );
+ const connectedEdges = useEdgeStore(
+ useShallow((state) => state.getNodeEdges(nodeID)),
+ );
+ const availableSubGraphs = useGraphStore(
+ useShallow((state) => state.availableSubGraphs),
+ );
+
+ // Extract agent-specific data
+ const graphID = nodeData.hardcodedValues?.graph_id as string | undefined;
+ const graphVersion = nodeData.hardcodedValues?.graph_version as
+ | number
+ | undefined;
+ const currentInputSchema = nodeData.hardcodedValues?.input_schema as
+ | GraphInputSchema
+ | undefined;
+ const currentOutputSchema = nodeData.hardcodedValues?.output_schema as
+ | GraphOutputSchema
+ | undefined;
+
+ // Use the sub-agent update hook
+ const updateInfo = useSubAgentUpdate(
+ nodeID,
+ graphID,
+ graphVersion,
+ currentInputSchema,
+ currentOutputSchema,
+ connectedEdges,
+ availableSubGraphs,
+ );
+
+ const isInResolutionMode = isNodeInResolutionMode(nodeID);
+
+ // Handle update button click
+ const handleUpdateClick = useCallback(() => {
+ if (!updateInfo.hasUpdate || !updateInfo.latestGraph) return;
+
+ if (updateInfo.isCompatible) {
+ // Compatible update - apply directly
+ const newHardcodedValues = createUpdatedAgentNodeInputs(
+ nodeData.hardcodedValues,
+ updateInfo.latestGraph,
+ );
+ updateNodeData(nodeID, { hardcodedValues: newHardcodedValues });
+ } else {
+ // Incompatible update - show dialog
+ setShowIncompatibilityDialog(true);
+ }
+ }, [
+ updateInfo.hasUpdate,
+ updateInfo.latestGraph,
+ updateInfo.isCompatible,
+ nodeData.hardcodedValues,
+ updateNodeData,
+ nodeID,
+ ]);
+
+ // Handle confirming an incompatible update
+ function handleConfirmIncompatibleUpdate() {
+ if (!updateInfo.latestGraph || !updateInfo.incompatibilities) return;
+
+ const latestGraph = updateInfo.latestGraph;
+
+ // Get the new schemas from the latest graph version
+ const newInputSchema =
+ (latestGraph.input_schema as Record) || {};
+ const newOutputSchema =
+ (latestGraph.output_schema as Record) || {};
+
+ // Create the updated hardcoded values but DON'T apply them yet
+ // We'll apply them when resolution is complete
+ const pendingHardcodedValues = createUpdatedAgentNodeInputs(
+ nodeData.hardcodedValues,
+ latestGraph,
+ );
+
+ // Get broken edge IDs and store them for this node
+ const brokenIds = getBrokenEdgeIDs(
+ connectedEdges,
+ updateInfo.incompatibilities,
+ nodeID,
+ );
+ setBrokenEdgeIDs(nodeID, brokenIds);
+
+ // Enter resolution mode with both old and new schemas
+ // DON'T apply the update yet - keep old schema so connections remain visible
+ const resolutionData: NodeResolutionData = {
+ incompatibilities: updateInfo.incompatibilities,
+ pendingUpdate: {
+ input_schema: newInputSchema,
+ output_schema: newOutputSchema,
+ },
+ currentSchema: {
+ input_schema: (currentInputSchema as Record) || {},
+ output_schema: (currentOutputSchema as Record) || {},
+ },
+ pendingHardcodedValues,
+ };
+ setNodeResolutionMode(nodeID, true, resolutionData);
+
+ setShowIncompatibilityDialog(false);
+ }
+
+ // Check if resolution is complete (all broken edges removed)
+ const resolutionData = getNodeResolutionData(nodeID);
+
+ // Auto-check resolution on edge changes
+ useEffect(() => {
+ if (!isInResolutionMode) return;
+
+ // Check if any broken edges still exist
+ const remainingBroken = Array.from(brokenEdgeIDs).filter((edgeId) =>
+ connectedEdges.some((e) => e.id === edgeId),
+ );
+
+ if (remainingBroken.length === 0) {
+ // Resolution complete - now apply the pending update
+ if (resolutionData?.pendingHardcodedValues) {
+ updateNodeData(nodeID, {
+ hardcodedValues: resolutionData.pendingHardcodedValues,
+ });
+ }
+ // setNodeResolutionMode will clean up this node's broken edges automatically
+ setNodeResolutionMode(nodeID, false);
+ }
+ }, [
+ isInResolutionMode,
+ brokenEdgeIDs,
+ connectedEdges,
+ resolutionData,
+ nodeID,
+ ]);
+
+ return {
+ updateInfo,
+ isInResolutionMode,
+ resolutionData,
+ showIncompatibilityDialog,
+ setShowIncompatibilityDialog,
+ handleUpdateClick,
+ handleConfirmIncompatibleUpdate,
+ };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx
index 044bf994ad..1fdee05a2a 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx
@@ -1,10 +1,10 @@
-import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
-import { Text } from "@/components/atoms/Text/Text";
-import Link from "next/link";
import { useGetV2GetLibraryAgentByGraphId } from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
-import { useQueryStates, parseAsString } from "nuqs";
-import { isValidUUID } from "@/app/(platform)/chat/helpers";
+import { Text } from "@/components/atoms/Text/Text";
+import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
+import { isValidUUID } from "@/lib/utils";
+import Link from "next/link";
+import { parseAsString, useQueryStates } from "nuqs";
export const WebhookDisclaimer = ({ nodeId }: { nodeId: string }) => {
const [{ flowID }] = useQueryStates({
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers.ts
index 8d228d0cd0..50326a03e6 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers.ts
@@ -1,4 +1,6 @@
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
+import { NodeResolutionData } from "@/app/(platform)/build/stores/types";
+import { RJSFSchema } from "@rjsf/utils";
export const nodeStyleBasedOnStatus: Record = {
INCOMPLETE: "ring-slate-300 bg-slate-300",
@@ -9,3 +11,48 @@ export const nodeStyleBasedOnStatus: Record = {
TERMINATED: "ring-orange-300 bg-orange-300 ",
FAILED: "ring-red-300 bg-red-300",
};
+
+/**
+ * Merges schemas during resolution mode to include removed inputs/outputs
+ * that still have connections, so users can see and delete them.
+ */
+export function mergeSchemaForResolution(
+ currentSchema: Record,
+ newSchema: Record,
+ resolutionData: NodeResolutionData,
+ type: "input" | "output",
+): Record {
+ const newProps = (newSchema.properties as RJSFSchema) || {};
+ const currentProps = (currentSchema.properties as RJSFSchema) || {};
+ const mergedProps = { ...newProps };
+ const incomp = resolutionData.incompatibilities;
+
+ if (type === "input") {
+ // Add back missing inputs that have connections
+ incomp.missingInputs.forEach((inputName: string) => {
+ if (currentProps[inputName]) {
+ mergedProps[inputName] = currentProps[inputName];
+ }
+ });
+ // Add back inputs with type mismatches (keep old type so connection works visually)
+ incomp.inputTypeMismatches.forEach(
+ (mismatch: { name: string; oldType: string; newType: string }) => {
+ if (currentProps[mismatch.name]) {
+ mergedProps[mismatch.name] = currentProps[mismatch.name];
+ }
+ },
+ );
+ } else {
+ // Add back missing outputs that have connections
+ incomp.missingOutputs.forEach((outputName: string) => {
+ if (currentProps[outputName]) {
+ mergedProps[outputName] = currentProps[outputName];
+ }
+ });
+ }
+
+ return {
+ ...newSchema,
+ properties: mergedProps,
+ };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/useCustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/useCustomNode.tsx
new file mode 100644
index 0000000000..e58d0ab12b
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/useCustomNode.tsx
@@ -0,0 +1,58 @@
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+import { CustomNodeData } from "./CustomNode";
+import { BlockUIType } from "../../../types";
+import { useMemo } from "react";
+import { mergeSchemaForResolution } from "./helpers";
+
+export const useCustomNode = ({
+ data,
+ nodeId,
+}: {
+ data: CustomNodeData;
+ nodeId: string;
+}) => {
+ const isInResolutionMode = useNodeStore((state) =>
+ state.nodesInResolutionMode.has(nodeId),
+ );
+ const resolutionData = useNodeStore((state) =>
+ state.nodeResolutionData.get(nodeId),
+ );
+
+ const isAgent = data.uiType === BlockUIType.AGENT;
+
+ const currentInputSchema = isAgent
+ ? (data.hardcodedValues.input_schema ?? {})
+ : data.inputSchema;
+ const currentOutputSchema = isAgent
+ ? (data.hardcodedValues.output_schema ?? {})
+ : data.outputSchema;
+
+ const inputSchema = useMemo(() => {
+ if (isAgent && isInResolutionMode && resolutionData) {
+ return mergeSchemaForResolution(
+ resolutionData.currentSchema.input_schema,
+ resolutionData.pendingUpdate.input_schema,
+ resolutionData,
+ "input",
+ );
+ }
+ return currentInputSchema;
+ }, [isAgent, isInResolutionMode, resolutionData, currentInputSchema]);
+
+ const outputSchema = useMemo(() => {
+ if (isAgent && isInResolutionMode && resolutionData) {
+ return mergeSchemaForResolution(
+ resolutionData.currentSchema.output_schema,
+ resolutionData.pendingUpdate.output_schema,
+ resolutionData,
+ "output",
+ );
+ }
+ return currentOutputSchema;
+ }, [isAgent, isInResolutionMode, resolutionData, currentOutputSchema]);
+
+ return {
+ inputSchema,
+ outputSchema,
+ };
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx
index 28d1bcc0ab..d6a3fabffa 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx
@@ -5,20 +5,16 @@ import { useNodeStore } from "../../../stores/nodeStore";
import { BlockUIType } from "../../types";
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
-export const FormCreator = React.memo(
- ({
- jsonSchema,
- nodeId,
- uiType,
- showHandles = true,
- className,
- }: {
- jsonSchema: RJSFSchema;
- nodeId: string;
- uiType: BlockUIType;
- showHandles?: boolean;
- className?: string;
- }) => {
+interface FormCreatorProps {
+ jsonSchema: RJSFSchema;
+ nodeId: string;
+ uiType: BlockUIType;
+ showHandles?: boolean;
+ className?: string;
+}
+
+export const FormCreator: React.FC = React.memo(
+ ({ jsonSchema, nodeId, uiType, showHandles = true, className }) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData);
const getHardCodedValues = useNodeStore(
@@ -48,7 +44,10 @@ export const FormCreator = React.memo(
: hardcodedValues;
return (
-
+
+
{fieldSchema?.description && (
@@ -64,15 +72,29 @@ export const OutputHandler = ({
)}
-
+
{fieldTitle}
-
+
({displayType})
{showHandles && (
{
+ if (
+ schema?.type === "array" &&
+ "format" in schema &&
+ schema.format === "table"
+ ) {
+ return {
+ displayType: "table",
+ colorClass: "!text-indigo-500",
+ hexColor: "#6366f1",
+ };
+ }
+
if (schema?.type === "string" && schema?.format) {
const formatMap: Record<
string,
@@ -175,3 +187,38 @@ export const getTypeDisplayInfo = (schema: any) => {
hexColor,
};
};
+
+export function getEdgeColorFromOutputType(
+ outputSchema: RJSFSchema | undefined,
+ sourceHandle: string,
+): { colorClass: string; hexColor: string } {
+ const defaultColor = {
+ colorClass: "stroke-zinc-500/50",
+ hexColor: "#6b7280",
+ };
+
+ if (!outputSchema?.properties) return defaultColor;
+
+ const properties = outputSchema.properties as Record;
+ const handleParts = sourceHandle.split("_#_");
+ let currentSchema: Record = properties;
+
+ for (let i = 0; i < handleParts.length; i++) {
+ const part = handleParts[i];
+ const fieldSchema = currentSchema[part] as Record;
+ if (!fieldSchema) return defaultColor;
+
+ if (i === handleParts.length - 1) {
+ const { hexColor, colorClass } = getTypeDisplayInfo(fieldSchema);
+ return { colorClass: colorClass.replace("!text-", "stroke-"), hexColor };
+ }
+
+ if (fieldSchema.properties) {
+ currentSchema = fieldSchema.properties as Record;
+ } else {
+ return defaultColor;
+ }
+ }
+
+ return defaultColor;
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/uiSchema.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/uiSchema.ts
index ad1fab7c95..065e697828 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/uiSchema.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/uiSchema.ts
@@ -1,6 +1,6 @@
export const uiSchema = {
credentials: {
- "ui:field": "credentials",
+ "ui:field": "custom/credential_field",
provider: { "ui:widget": "hidden" },
type: { "ui:widget": "hidden" },
id: { "ui:autofocus": true },
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/useBrokenOutputs.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/useBrokenOutputs.ts
new file mode 100644
index 0000000000..61ef992a13
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/useBrokenOutputs.ts
@@ -0,0 +1,23 @@
+import { useMemo } from "react";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+
+/**
+ * Hook to get the set of broken output names for a node in resolution mode.
+ */
+export function useBrokenOutputs(nodeID: string): Set {
+ // Subscribe to the actual state values, not just methods
+ const isInResolution = useNodeStore((state) =>
+ state.nodesInResolutionMode.has(nodeID),
+ );
+ const resolutionData = useNodeStore((state) =>
+ state.nodeResolutionData.get(nodeID),
+ );
+
+ return useMemo(() => {
+ if (!isInResolution || !resolutionData) {
+ return new Set();
+ }
+
+ return new Set(resolutionData.incompatibilities.missingOutputs);
+ }, [isInResolution, resolutionData]);
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/constants.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/constants.ts
new file mode 100644
index 0000000000..1a7e4176c6
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/constants.ts
@@ -0,0 +1,129 @@
+// Block IDs for tutorial blocks
+export const BLOCK_IDS = {
+ CALCULATOR: "b1ab9b19-67a6-406d-abf5-2dba76d00c79",
+ AGENT_INPUT: "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b",
+ AGENT_OUTPUT: "363ae599-353e-4804-937e-b2ee3cef3da4",
+} as const;
+
+export const TUTORIAL_SELECTORS = {
+ // Custom nodes - These are all before saving
+ INPUT_NODE: '[data-id="custom-node-2"]',
+ OUTPUT_NODE: '[data-id="custom-node-3 "]',
+ CALCULATOR_NODE: '[data-id="custom-node-1"]',
+
+ // Paricular field selector
+ NAME_FIELD_OUTPUT_NODE: '[data-id="field-3-root_name"]',
+
+ // Output Handlers
+ SECOND_CALCULATOR_RESULT_OUTPUT_HANDLER:
+ '[data-tutorial-id="output-handler-2-result"]',
+ FIRST_CALCULATOR_RESULT_OUTPUT_HANDLER:
+ '[data-tutorial-id="output-handler-1-result"]',
+
+ // Input Handler
+ SECOND_CALCULATOR_NUMBER_A_INPUT_HANDLER:
+ '[data-tutorial-id="input-handler-2-a"]',
+ OUTPUT_VALUE_INPUT_HANDLEER: '[data-tutorial-id="label-3-root_value"]',
+
+ // Block Menu
+ BLOCKS_TRIGGER: '[data-id="blocks-control-popover-trigger"]',
+ BLOCKS_CONTENT: '[data-id="blocks-control-popover-content"]',
+ BLOCKS_SEARCH_INPUT:
+ '[data-id="blocks-control-search-bar"] input[type="text"]',
+ BLOCKS_SEARCH_INPUT_BOX: '[data-id="blocks-control-search-bar"]',
+
+ // Add a new selector that checks within search results
+
+ // Block Menu Sidebar
+ MENU_ITEM_INPUT_BLOCKS: '[data-id="menu-item-input_blocks"]',
+ MENU_ITEM_ALL_BLOCKS: '[data-id="menu-item-all_blocks"]',
+ MENU_ITEM_ACTION_BLOCKS: '[data-id="menu-item-action_blocks"]',
+ MENU_ITEM_OUTPUT_BLOCKS: '[data-id="menu-item-output_blocks"]',
+ MENU_ITEM_INTEGRATIONS: '[data-id="menu-item-integrations"]',
+ MENU_ITEM_MY_AGENTS: '[data-id="menu-item-my_agents"]',
+ MENU_ITEM_MARKETPLACE: '[data-id="menu-item-marketplace_agents"]',
+ MENU_ITEM_SUGGESTION: '[data-id="menu-item-suggestion"]',
+
+ // Block Cards
+ BLOCK_CARD_PREFIX: '[data-id^="block-card-"]',
+ BLOCK_CARD_AGENT_INPUT: '[data-id="block-card-AgentInputBlock"]',
+ // Calculator block - legacy ID used in old tutorial
+ BLOCK_CARD_CALCULATOR:
+ '[data-id="block-card-b1ab9b1967a6406dabf52dba76d00c79"]',
+ BLOCK_CARD_CALCULATOR_IN_SEARCH:
+ '[data-id="blocks-control-search-results"] [data-id="block-card-b1ab9b1967a6406dabf52dba76d00c79"]',
+
+ // Save Control
+ SAVE_TRIGGER: '[data-id="save-control-popover-trigger"]',
+ SAVE_CONTENT: '[data-id="save-control-popover-content"]',
+ SAVE_AGENT_BUTTON: '[data-id="save-control-save-agent"]',
+ SAVE_NAME_INPUT: '[data-id="save-control-name-input"]',
+ SAVE_DESCRIPTION_INPUT: '[data-id="save-control-description-input"]',
+
+ // Builder Actions (Run, Schedule, Outputs)
+ BUILDER_ACTIONS: '[data-id="builder-actions"]',
+ RUN_BUTTON: '[data-id="run-graph-button"]',
+ STOP_BUTTON: '[data-id="stop-graph-button"]',
+ SCHEDULE_BUTTON: '[data-id="schedule-graph-button"]',
+ AGENT_OUTPUTS_BUTTON: '[data-id="agent-outputs-button"]',
+
+ // Run Input Dialog
+ RUN_INPUT_DIALOG_CONTENT: '[data-id="run-input-dialog-content"]',
+ RUN_INPUT_CREDENTIALS_SECTION: '[data-id="run-input-credentials-section"]',
+ RUN_INPUT_CREDENTIALS_FORM: '[data-id="run-input-credentials-form"]',
+ RUN_INPUT_INPUTS_SECTION: '[data-id="run-input-inputs-section"]',
+ RUN_INPUT_INPUTS_FORM: '[data-id="run-input-inputs-form"]',
+ RUN_INPUT_ACTIONS_SECTION: '[data-id="run-input-actions-section"]',
+ RUN_INPUT_MANUAL_RUN_BUTTON: '[data-id="run-input-manual-run-button"]',
+ RUN_INPUT_SCHEDULE_BUTTON: '[data-id="run-input-schedule-button"]',
+
+ // Custom Controls (bottom left)
+ CUSTOM_CONTROLS: '[data-id="custom-controls"]',
+ ZOOM_IN_BUTTON: '[data-id="zoom-in-button"]',
+ ZOOM_OUT_BUTTON: '[data-id="zoom-out-button"]',
+ FIT_VIEW_BUTTON: '[data-id="fit-view-button"]',
+ LOCK_BUTTON: '[data-id="lock-button"]',
+ TUTORIAL_BUTTON: '[data-id="tutorial-button"]',
+
+ // Canvas
+ REACT_FLOW_CANVAS: ".react-flow__pane",
+ REACT_FLOW_NODE: ".react-flow__node",
+ REACT_FLOW_NODE_FIRST: '[data-testid^="rf__node-"]:first-child',
+ REACT_FLOW_EDGE: '[data-testid^="rf__edge-"]',
+
+ // Node elements
+ NODE_CONTAINER: '[data-id^="custom-node-"]',
+ NODE_HEADER: '[data-id^="node-header-"]',
+ NODE_INPUT_HANDLES: '[data-tutorial-id="input-handles"]',
+ NODE_OUTPUT_HANDLE: '[data-handlepos="right"]',
+ NODE_INPUT_HANDLE: "[data-nodeid]",
+ FIRST_CALCULATOR_NODE_OUTPUT: '[data-tutorial-id="node-output"]',
+ // These are the Id's of the nodes before saving
+ CALCULATOR_NODE_FORM_CONTAINER: '[data-id^="form-creator-container-1-node"]', // <-- Add this line
+ AGENT_INPUT_NODE_FORM_CONTAINER: '[data-id^="form-creator-container-2-node"]', // <-- Add this line
+ AGENT_OUTPUT_NODE_FORM_CONTAINER:
+ '[data-id^="form-creator-container-3-node"]', // <-- Add this line
+
+ // Execution badges
+ BADGE_QUEUED: '[data-id^="badge-"][data-id$="-QUEUED"]',
+ BADGE_COMPLETED: '[data-id^="badge-"][data-id$="-COMPLETED"]',
+
+ // Undo/Redo
+ UNDO_BUTTON: '[data-id="undo-button"]',
+ REDO_BUTTON: '[data-id="redo-button"]',
+} as const;
+
+export const CSS_CLASSES = {
+ DISABLE: "new-builder-tutorial-disable",
+ HIGHLIGHT: "new-builder-tutorial-highlight",
+ PULSE: "new-builder-tutorial-pulse",
+} as const;
+
+export const TUTORIAL_CONFIG = {
+ ELEMENT_CHECK_INTERVAL: 50, // ms
+ INPUT_CHECK_INTERVAL: 100, // ms
+ USE_MODAL_OVERLAY: true,
+ SCROLL_BEHAVIOR: "smooth" as const,
+ SCROLL_BLOCK: "center" as const,
+ SEARCH_TERM_CALCULATOR: "Calculator",
+} as const;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/blocks.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/blocks.ts
new file mode 100644
index 0000000000..040991450f
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/blocks.ts
@@ -0,0 +1,89 @@
+import { BLOCK_IDS } from "../constants";
+import { useNodeStore } from "../../../../stores/nodeStore";
+import { getV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/default/default";
+import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
+
+const prefetchedBlocks: Map = new Map();
+
+export const prefetchTutorialBlocks = async (): Promise => {
+ try {
+ const blockIds = [BLOCK_IDS.CALCULATOR];
+ const response = await getV2GetSpecificBlocks({ block_ids: blockIds });
+
+ if (response.status === 200 && response.data) {
+ response.data.forEach((block) => {
+ prefetchedBlocks.set(block.id, block);
+ });
+ console.debug("Tutorial blocks prefetched:", prefetchedBlocks.size);
+ }
+ } catch (error) {
+ console.error("Failed to prefetch tutorial blocks:", error);
+ }
+};
+
+export const getPrefetchedBlock = (blockId: string): BlockInfo | undefined => {
+ return prefetchedBlocks.get(blockId);
+};
+
+export const clearPrefetchedBlocks = (): void => {
+ prefetchedBlocks.clear();
+};
+
+export const addPrefetchedBlock = (
+ blockId: string,
+ position?: { x: number; y: number },
+): void => {
+ const block = prefetchedBlocks.get(blockId);
+ if (block) {
+ useNodeStore.getState().addBlock(block, {}, position);
+ } else {
+ console.error(`Block ${blockId} not found in prefetched blocks`);
+ }
+};
+
+export const getNodeByBlockId = (blockId: string) => {
+ const nodes = useNodeStore.getState().nodes;
+ return nodes.find((n) => n.data?.block_id === blockId);
+};
+
+export const addSecondCalculatorBlock = (): void => {
+ const firstCalculatorNode = getNodeByBlockId(BLOCK_IDS.CALCULATOR);
+
+ if (firstCalculatorNode) {
+ const calcX = firstCalculatorNode.position.x;
+ const calcY = firstCalculatorNode.position.y;
+
+ addPrefetchedBlock(BLOCK_IDS.CALCULATOR, {
+ x: calcX + 500,
+ y: calcY,
+ });
+ } else {
+ addPrefetchedBlock(BLOCK_IDS.CALCULATOR);
+ }
+};
+
+export const getCalculatorNodes = () => {
+ const nodes = useNodeStore.getState().nodes;
+ return nodes.filter((n) => n.data?.block_id === BLOCK_IDS.CALCULATOR);
+};
+
+export const getSecondCalculatorNode = () => {
+ const calculatorNodes = getCalculatorNodes();
+ return calculatorNodes.length >= 2 ? calculatorNodes[1] : null;
+};
+
+export const getFormContainerSelector = (blockId: string): string | null => {
+ const node = getNodeByBlockId(blockId);
+ if (node) {
+ return `[data-id="form-creator-container-${node.id}"]`;
+ }
+ return null;
+};
+
+export const getFormContainerElement = (blockId: string): Element | null => {
+ const selector = getFormContainerSelector(blockId);
+ if (selector) {
+ return document.querySelector(selector);
+ }
+ return null;
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/canvas.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/canvas.ts
new file mode 100644
index 0000000000..dd3d8576d9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/canvas.ts
@@ -0,0 +1,83 @@
+import { TUTORIAL_CONFIG, TUTORIAL_SELECTORS } from "../constants";
+import { useNodeStore } from "../../../../stores/nodeStore";
+
+export const waitForNodeOnCanvas = (
+ timeout = 10000,
+): Promise => {
+ return new Promise((resolve) => {
+ const startTime = Date.now();
+
+ const checkNode = () => {
+ const storeNodes = useNodeStore.getState().nodes;
+ if (storeNodes.length > 0) {
+ const domNode = document.querySelector(
+ TUTORIAL_SELECTORS.REACT_FLOW_NODE,
+ );
+ if (domNode) {
+ resolve(domNode);
+ return;
+ }
+ }
+
+ if (Date.now() - startTime > timeout) {
+ resolve(null);
+ } else {
+ setTimeout(checkNode, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL);
+ }
+ };
+ checkNode();
+ });
+};
+
+export const waitForNodesCount = (
+ count: number,
+ timeout = 10000,
+): Promise => {
+ return new Promise((resolve) => {
+ const startTime = Date.now();
+
+ const checkNodes = () => {
+ const currentCount = useNodeStore.getState().nodes.length;
+ if (currentCount >= count) {
+ resolve(true);
+ } else if (Date.now() - startTime > timeout) {
+ resolve(false);
+ } else {
+ setTimeout(checkNodes, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL);
+ }
+ };
+ checkNodes();
+ });
+};
+
+export const getNodesCount = (): number => {
+ return useNodeStore.getState().nodes.length;
+};
+
+export const getFirstNode = () => {
+ const nodes = useNodeStore.getState().nodes;
+ return nodes.length > 0 ? nodes[0] : null;
+};
+
+export const getNodeById = (nodeId: string) => {
+ const nodes = useNodeStore.getState().nodes;
+ return nodes.find((n) => n.id === nodeId);
+};
+
+export const nodeHasValues = (nodeId: string): boolean => {
+ const node = getNodeById(nodeId);
+ if (!node) return false;
+ const hardcodedValues = node.data?.hardcodedValues || {};
+ return Object.values(hardcodedValues).some(
+ (value) => value !== undefined && value !== null && value !== "",
+ );
+};
+
+export const fitViewToScreen = () => {
+ const fitViewButton = document.querySelector(
+ TUTORIAL_SELECTORS.FIT_VIEW_BUTTON,
+ ) as HTMLButtonElement;
+ if (fitViewButton) {
+ fitViewButton.click();
+ }
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/connections.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/connections.ts
new file mode 100644
index 0000000000..4ec8c4df74
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/connections.ts
@@ -0,0 +1,19 @@
+import { useNodeStore } from "../../../../stores/nodeStore";
+import { useEdgeStore } from "../../../../stores/edgeStore";
+
+export const isConnectionMade = (
+ sourceBlockId: string,
+ targetBlockId: string,
+): boolean => {
+ const edges = useEdgeStore.getState().edges;
+ const nodes = useNodeStore.getState().nodes;
+
+ const sourceNode = nodes.find((n) => n.data?.block_id === sourceBlockId);
+ const targetNode = nodes.find((n) => n.data?.block_id === targetBlockId);
+
+ if (!sourceNode || !targetNode) return false;
+
+ return edges.some((edge) => {
+ return edge.source === sourceNode.id && edge.target === targetNode.id;
+ });
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/dom.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/dom.ts
new file mode 100644
index 0000000000..3ae9359cd9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/dom.ts
@@ -0,0 +1,180 @@
+import { TUTORIAL_CONFIG, TUTORIAL_SELECTORS } from "../constants";
+
+export const waitForElement = (
+ selector: string,
+ timeout = 10000,
+): Promise => {
+ return new Promise((resolve, reject) => {
+ const startTime = Date.now();
+
+ const checkElement = () => {
+ const element = document.querySelector(selector);
+ if (element) {
+ resolve(element);
+ } else if (Date.now() - startTime > timeout) {
+ reject(new Error(`Element ${selector} not found within ${timeout}ms`));
+ } else {
+ setTimeout(checkElement, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL);
+ }
+ };
+ checkElement();
+ });
+};
+
+export const waitForInputValue = (
+ selector: string,
+ targetValue: string,
+ timeout = 30000,
+): Promise => {
+ return new Promise((resolve) => {
+ const startTime = Date.now();
+
+ const checkInput = () => {
+ const input = document.querySelector(selector) as HTMLInputElement;
+ if (input) {
+ const currentValue = input.value.toLowerCase().trim();
+ const target = targetValue.toLowerCase().trim();
+
+ if (currentValue.includes(target) || target.includes(currentValue)) {
+ if (currentValue.length >= 4 || currentValue === target) {
+ resolve();
+ return;
+ }
+ }
+ }
+
+ if (Date.now() - startTime > timeout) {
+ resolve();
+ } else {
+ setTimeout(checkInput, TUTORIAL_CONFIG.INPUT_CHECK_INTERVAL);
+ }
+ };
+ checkInput();
+ });
+};
+
+export const waitForSearchResult = (
+ selector: string,
+ timeout = 15000,
+): Promise => {
+ return new Promise((resolve) => {
+ const startTime = Date.now();
+
+ const checkResult = () => {
+ const element = document.querySelector(selector);
+ if (element) {
+ resolve(element);
+ } else if (Date.now() - startTime > timeout) {
+ resolve(null);
+ } else {
+ setTimeout(checkResult, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL);
+ }
+ };
+ checkResult();
+ });
+};
+
+export const waitForAnyBlockCard = (
+ timeout = 10000,
+): Promise => {
+ return new Promise((resolve) => {
+ const startTime = Date.now();
+
+ const checkBlock = () => {
+ const block = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCK_CARD_PREFIX,
+ );
+ if (block) {
+ resolve(block);
+ } else if (Date.now() - startTime > timeout) {
+ resolve(null);
+ } else {
+ setTimeout(checkBlock, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL);
+ }
+ };
+ checkBlock();
+ });
+};
+
+export const focusElement = (selector: string): void => {
+ const element = document.querySelector(selector) as HTMLElement;
+ if (element) {
+ element.focus();
+ }
+};
+
+export const scrollIntoView = (selector: string): void => {
+ const element = document.querySelector(selector);
+ if (element) {
+ element.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+};
+
+export const typeIntoInput = (selector: string, text: string) => {
+ const input = document.querySelector(selector) as HTMLInputElement;
+ if (input) {
+ input.focus();
+ input.value = text;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+};
+
+export const observeElement = (
+ selector: string,
+ callback: (element: Element) => void,
+): MutationObserver => {
+ const observer = new MutationObserver((mutations, obs) => {
+ const element = document.querySelector(selector);
+ if (element) {
+ callback(element);
+ obs.disconnect();
+ }
+ });
+
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ });
+
+ const element = document.querySelector(selector);
+ if (element) {
+ callback(element);
+ observer.disconnect();
+ }
+
+ return observer;
+};
+
+export const watchSearchInput = (
+ targetValue: string,
+ onMatch: () => void,
+): (() => void) => {
+ const input = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCKS_SEARCH_INPUT,
+ ) as HTMLInputElement;
+ if (!input) return () => {};
+
+ let hasMatched = false;
+
+ const handler = () => {
+ if (hasMatched) return;
+
+ const currentValue = input.value.toLowerCase().trim();
+ const target = targetValue.toLowerCase().trim();
+
+ if (currentValue.length >= 4 && target.startsWith(currentValue)) {
+ hasMatched = true;
+ onMatch();
+ }
+ };
+
+ input.addEventListener("input", handler);
+
+ return () => {
+ input.removeEventListener("input", handler);
+ };
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/highlights.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/highlights.ts
new file mode 100644
index 0000000000..4f425f5c9c
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/highlights.ts
@@ -0,0 +1,56 @@
+import { CSS_CLASSES, TUTORIAL_SELECTORS } from "../constants";
+
+export const disableOtherBlocks = (targetBlockSelector: string) => {
+ document
+ .querySelectorAll(TUTORIAL_SELECTORS.BLOCK_CARD_PREFIX)
+ .forEach((block) => {
+ const isTarget = block.matches(targetBlockSelector);
+ block.classList.toggle(CSS_CLASSES.DISABLE, !isTarget);
+ block.classList.toggle(CSS_CLASSES.HIGHLIGHT, isTarget);
+ });
+};
+
+export const enableAllBlocks = () => {
+ document
+ .querySelectorAll(TUTORIAL_SELECTORS.BLOCK_CARD_PREFIX)
+ .forEach((block) => {
+ block.classList.remove(
+ CSS_CLASSES.DISABLE,
+ CSS_CLASSES.HIGHLIGHT,
+ CSS_CLASSES.PULSE,
+ );
+ });
+};
+
+export const highlightElement = (selector: string) => {
+ const element = document.querySelector(selector);
+ if (element) {
+ element.classList.add(CSS_CLASSES.HIGHLIGHT);
+ }
+};
+
+export const removeAllHighlights = () => {
+ document.querySelectorAll(`.${CSS_CLASSES.HIGHLIGHT}`).forEach((el) => {
+ el.classList.remove(CSS_CLASSES.HIGHLIGHT);
+ });
+ document.querySelectorAll(`.${CSS_CLASSES.PULSE}`).forEach((el) => {
+ el.classList.remove(CSS_CLASSES.PULSE);
+ });
+};
+
+export const pulseElement = (selector: string) => {
+ const element = document.querySelector(selector);
+ if (element) {
+ element.classList.add(CSS_CLASSES.PULSE);
+ }
+};
+
+export const highlightFirstBlockInSearch = () => {
+ const firstBlock = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCK_CARD_PREFIX,
+ );
+ if (firstBlock) {
+ firstBlock.classList.add(CSS_CLASSES.PULSE);
+ firstBlock.scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/index.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/index.ts
new file mode 100644
index 0000000000..bd60ef06bb
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/index.ts
@@ -0,0 +1,66 @@
+export {
+ waitForElement,
+ waitForInputValue,
+ waitForSearchResult,
+ waitForAnyBlockCard,
+ focusElement,
+ scrollIntoView,
+ typeIntoInput,
+ observeElement,
+ watchSearchInput,
+} from "./dom";
+
+export {
+ disableOtherBlocks,
+ enableAllBlocks,
+ highlightElement,
+ removeAllHighlights,
+ pulseElement,
+ highlightFirstBlockInSearch,
+} from "./highlights";
+
+export {
+ prefetchTutorialBlocks,
+ getPrefetchedBlock,
+ clearPrefetchedBlocks,
+ addPrefetchedBlock,
+ getNodeByBlockId,
+ addSecondCalculatorBlock,
+ getCalculatorNodes,
+ getSecondCalculatorNode,
+ getFormContainerSelector,
+ getFormContainerElement,
+} from "./blocks";
+
+export {
+ waitForNodeOnCanvas,
+ waitForNodesCount,
+ getNodesCount,
+ getFirstNode,
+ getNodeById,
+ nodeHasValues,
+ fitViewToScreen,
+} from "./canvas";
+
+export { isConnectionMade } from "./connections";
+
+export {
+ forceBlockMenuOpen,
+ openBlockMenu,
+ closeBlockMenu,
+ clearBlockMenuSearch,
+} from "./menu";
+
+export {
+ openSaveControl,
+ closeSaveControl,
+ forceSaveOpen,
+ clickSaveButton,
+ isAgentSaved,
+} from "./save";
+
+export {
+ handleTutorialCancel,
+ handleTutorialSkip,
+ handleTutorialComplete,
+} from "./state";
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/menu.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/menu.ts
new file mode 100644
index 0000000000..905e34dfb9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/menu.ts
@@ -0,0 +1,25 @@
+import { TUTORIAL_SELECTORS } from "../constants";
+import { useControlPanelStore } from "../../../../stores/controlPanelStore";
+
+export const forceBlockMenuOpen = (force: boolean) => {
+ useControlPanelStore.getState().setForceOpenBlockMenu(force);
+};
+
+export const openBlockMenu = () => {
+ useControlPanelStore.getState().setBlockMenuOpen(true);
+};
+
+export const closeBlockMenu = () => {
+ useControlPanelStore.getState().setBlockMenuOpen(false);
+ useControlPanelStore.getState().setForceOpenBlockMenu(false);
+};
+
+export const clearBlockMenuSearch = () => {
+ const input = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCKS_SEARCH_INPUT,
+ ) as HTMLInputElement;
+ if (input) {
+ input.value = "";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ }
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/save.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/save.ts
new file mode 100644
index 0000000000..e932ee1b84
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/save.ts
@@ -0,0 +1,31 @@
+import { TUTORIAL_SELECTORS } from "../constants";
+import { useControlPanelStore } from "../../../../stores/controlPanelStore";
+
+export const openSaveControl = () => {
+ useControlPanelStore.getState().setSaveControlOpen(true);
+};
+
+export const closeSaveControl = () => {
+ useControlPanelStore.getState().setSaveControlOpen(false);
+ useControlPanelStore.getState().setForceOpenSave(false);
+};
+
+export const forceSaveOpen = (force: boolean) => {
+ useControlPanelStore.getState().setForceOpenSave(force);
+};
+
+export const clickSaveButton = () => {
+ const saveButton = document.querySelector(
+ TUTORIAL_SELECTORS.SAVE_AGENT_BUTTON,
+ ) as HTMLButtonElement;
+ if (saveButton && !saveButton.disabled) {
+ saveButton.click();
+ }
+};
+
+export const isAgentSaved = (): boolean => {
+ const versionInput = document.querySelector(
+ '[data-tutorial-id="save-control-version-output"]',
+ ) as HTMLInputElement;
+ return !!(versionInput && versionInput.value && versionInput.value !== "-");
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/state.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/state.ts
new file mode 100644
index 0000000000..43e19c40d4
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/state.ts
@@ -0,0 +1,49 @@
+import { Key, storage } from "@/services/storage/local-storage";
+import { closeBlockMenu } from "./menu";
+import { closeSaveControl, forceSaveOpen } from "./save";
+import { removeAllHighlights, enableAllBlocks } from "./highlights";
+
+const clearTutorialIntervals = () => {
+ const intervalKeys = [
+ "__tutorialCalcInterval",
+ "__tutorialCheckInterval",
+ "__tutorialSecondCalcInterval",
+ ];
+
+ intervalKeys.forEach((key) => {
+ if ((window as any)[key]) {
+ clearInterval((window as any)[key]);
+ delete (window as any)[key];
+ }
+ });
+};
+
+export const handleTutorialCancel = (_tour?: any) => {
+ clearTutorialIntervals();
+ closeBlockMenu();
+ closeSaveControl();
+ forceSaveOpen(false);
+ removeAllHighlights();
+ enableAllBlocks();
+ storage.set(Key.SHEPHERD_TOUR, "canceled");
+};
+
+export const handleTutorialSkip = (_tour?: any) => {
+ clearTutorialIntervals();
+ closeBlockMenu();
+ closeSaveControl();
+ forceSaveOpen(false);
+ removeAllHighlights();
+ enableAllBlocks();
+ storage.set(Key.SHEPHERD_TOUR, "skipped");
+};
+
+export const handleTutorialComplete = () => {
+ clearTutorialIntervals();
+ closeBlockMenu();
+ closeSaveControl();
+ forceSaveOpen(false);
+ removeAllHighlights();
+ enableAllBlocks();
+ storage.set(Key.SHEPHERD_TOUR, "completed");
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/icons.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/icons.ts
new file mode 100644
index 0000000000..2c7a22b423
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/icons.ts
@@ -0,0 +1,32 @@
+type IconOptions = {
+ size?: number;
+ color?: string;
+};
+
+const DEFAULT_SIZE = 16;
+const DEFAULT_COLOR = "#52525b"; // zinc-600
+
+const iconPaths = {
+ ClickIcon: `M88,24V16a8,8,0,0,1,16,0v8a8,8,0,0,1-16,0ZM16,104h8a8,8,0,0,0,0-16H16a8,8,0,0,0,0,16ZM124.42,39.16a8,8,0,0,0,10.74-3.58l8-16a8,8,0,0,0-14.31-7.16l-8,16A8,8,0,0,0,124.42,39.16Zm-96,81.69-16,8a8,8,0,0,0,7.16,14.31l16-8a8,8,0,1,0-7.16-14.31ZM219.31,184a16,16,0,0,1,0,22.63l-12.68,12.68a16,16,0,0,1-22.63,0L132.7,168,115,214.09c0,.1-.08.21-.13.32a15.83,15.83,0,0,1-14.6,9.59l-.79,0a15.83,15.83,0,0,1-14.41-11L32.8,52.92A16,16,0,0,1,52.92,32.8L213,85.07a16,16,0,0,1,1.41,29.8l-.32.13L168,132.69ZM208,195.31,156.69,144h0a16,16,0,0,1,4.93-26l.32-.14,45.95-17.64L48,48l52.2,159.86,17.65-46c0-.11.08-.22.13-.33a16,16,0,0,1,11.69-9.34,16.72,16.72,0,0,1,3-.28,16,16,0,0,1,11.3,4.69L195.31,208Z`,
+ Keyboard: `M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z`,
+ Drag: `M188,80a27.79,27.79,0,0,0-13.36,3.4,28,28,0,0,0-46.64-11A28,28,0,0,0,80,92v20H68a28,28,0,0,0-28,28v12a88,88,0,0,0,176,0V108A28,28,0,0,0,188,80Zm12,72a72,72,0,0,1-144,0V140a12,12,0,0,1,12-12H80v24a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V108a12,12,0,0,1,24,0Z`,
+};
+
+function createIcon(path: string, options: IconOptions = {}): string {
+ const size = options.size ?? DEFAULT_SIZE;
+ const color = options.color ?? DEFAULT_COLOR;
+ return ` `;
+}
+
+export const ICONS = {
+ ClickIcon: createIcon(iconPaths.ClickIcon),
+ Keyboard: createIcon(iconPaths.Keyboard),
+ Drag: createIcon(iconPaths.Drag),
+};
+
+export function getIcon(
+ name: keyof typeof iconPaths,
+ options?: IconOptions,
+): string {
+ return createIcon(iconPaths[name], options);
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/index.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/index.ts
new file mode 100644
index 0000000000..49f505054b
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/index.ts
@@ -0,0 +1,84 @@
+import Shepherd from "shepherd.js";
+import { analytics } from "@/services/analytics";
+import { TUTORIAL_CONFIG } from "./constants";
+import { createTutorialSteps } from "./steps";
+import { injectTutorialStyles, removeTutorialStyles } from "./styles";
+import {
+ handleTutorialComplete,
+ handleTutorialCancel,
+ prefetchTutorialBlocks,
+ clearPrefetchedBlocks,
+} from "./helpers";
+import { useNodeStore } from "../../../stores/nodeStore";
+import { useEdgeStore } from "../../../stores/edgeStore";
+import { useTutorialStore } from "../../../stores/tutorialStore";
+
+let isTutorialLoading = false;
+let tutorialLoadingCallback: ((loading: boolean) => void) | null = null;
+
+export const setTutorialLoadingCallback = (
+ callback: (loading: boolean) => void,
+) => {
+ tutorialLoadingCallback = callback;
+};
+
+export const getTutorialLoadingState = () => isTutorialLoading;
+
+export const startTutorial = async () => {
+ isTutorialLoading = true;
+ tutorialLoadingCallback?.(true);
+
+ useNodeStore.getState().setNodes([]);
+ useEdgeStore.getState().setEdges([]);
+ useNodeStore.getState().setNodeCounter(0);
+
+ try {
+ await prefetchTutorialBlocks();
+ } finally {
+ isTutorialLoading = false;
+ tutorialLoadingCallback?.(false);
+ }
+
+ const tour = new Shepherd.Tour({
+ useModalOverlay: TUTORIAL_CONFIG.USE_MODAL_OVERLAY,
+ defaultStepOptions: {
+ cancelIcon: { enabled: true },
+ scrollTo: {
+ behavior: TUTORIAL_CONFIG.SCROLL_BEHAVIOR,
+ block: TUTORIAL_CONFIG.SCROLL_BLOCK,
+ },
+ classes: "new-builder-tour",
+ modalOverlayOpeningRadius: 4,
+ },
+ });
+
+ injectTutorialStyles();
+
+ const steps = createTutorialSteps(tour);
+ steps.forEach((step) => tour.addStep(step));
+
+ tour.on("complete", () => {
+ handleTutorialComplete();
+ removeTutorialStyles();
+ clearPrefetchedBlocks();
+ useTutorialStore.getState().setIsTutorialRunning(false);
+ });
+
+ tour.on("cancel", () => {
+ handleTutorialCancel(tour);
+ removeTutorialStyles();
+ clearPrefetchedBlocks();
+ useTutorialStore.getState().setIsTutorialRunning(false);
+ });
+
+ for (const step of tour.steps) {
+ step.on("show", () => {
+ console.debug("sendTutorialStep", step.id);
+ analytics.sendGAEvent("event", "tutorial_step_shown", {
+ value: step.id,
+ });
+ });
+ }
+
+ tour.start();
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-basics.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-basics.ts
new file mode 100644
index 0000000000..89be102a74
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-basics.ts
@@ -0,0 +1,114 @@
+import { StepOptions } from "shepherd.js";
+import { TUTORIAL_SELECTORS } from "../constants";
+import {
+ waitForElement,
+ waitForNodeOnCanvas,
+ closeBlockMenu,
+ fitViewToScreen,
+ highlightElement,
+ removeAllHighlights,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+
+export const createBlockBasicsSteps = (tour: any): StepOptions[] => [
+ {
+ id: "focus-new-block",
+ title: "Your First Block!",
+ text: `
+
+
Excellent! This is your Calculator Block .
+
Let's explore how blocks work.
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.REACT_FLOW_NODE,
+ on: "right",
+ },
+ beforeShowPromise: async () => {
+ closeBlockMenu();
+ await waitForNodeOnCanvas(5000);
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ fitViewToScreen();
+ },
+ when: {
+ show: () => {
+ const node = document.querySelector(TUTORIAL_SELECTORS.REACT_FLOW_NODE);
+ if (node) {
+ highlightElement(TUTORIAL_SELECTORS.REACT_FLOW_NODE);
+ }
+ },
+ hide: () => {
+ removeAllHighlights();
+ },
+ },
+ buttons: [
+ {
+ text: "Show me",
+ action: () => tour.next(),
+ },
+ ],
+ },
+
+ {
+ id: "input-handles",
+ title: "Input Handles",
+ text: `
+
+
On the left side of the block are input handles .
+
These are where data flows into the block from other blocks.
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.NODE_INPUT_HANDLE,
+ on: "bottom",
+ },
+ classes: "new-builder-tour input-handles-step",
+ beforeShowPromise: () =>
+ waitForElement(TUTORIAL_SELECTORS.NODE_INPUT_HANDLE, 3000).catch(
+ () => {},
+ ),
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.back(),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Next",
+ action: () => tour.next(),
+ },
+ ],
+ },
+
+ {
+ id: "output-handles",
+ title: "Output Handles",
+ text: `
+
+
On the right side is the output handle .
+
This is where the result flows out to connect to other blocks.
+ ${banner(ICONS.Drag, "You can drag from output to input handler to connect blocks", "info")}
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.NODE_OUTPUT_HANDLE,
+ on: "right",
+ },
+ beforeShowPromise: () =>
+ waitForElement(TUTORIAL_SELECTORS.NODE_OUTPUT_HANDLE, 3000).catch(
+ () => {},
+ ),
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.back(),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Next →",
+ action: () => tour.next(),
+ },
+ ],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-menu.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-menu.ts
new file mode 100644
index 0000000000..6a1eaf33d5
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-menu.ts
@@ -0,0 +1,198 @@
+import { StepOptions } from "shepherd.js";
+import { TUTORIAL_CONFIG, TUTORIAL_SELECTORS, BLOCK_IDS } from "../constants";
+import {
+ waitForElement,
+ forceBlockMenuOpen,
+ focusElement,
+ highlightElement,
+ removeAllHighlights,
+ disableOtherBlocks,
+ enableAllBlocks,
+ pulseElement,
+ highlightFirstBlockInSearch,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+import { useNodeStore } from "../../../../stores/nodeStore";
+
+export const createBlockMenuSteps = (tour: any): StepOptions[] => [
+ {
+ id: "open-block-menu",
+ title: "Open the Block Menu",
+ text: `
+
+
Let's start by opening the Block Menu.
+ ${banner(ICONS.ClickIcon, "Click this button to open the menu", "action")}
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.BLOCKS_TRIGGER,
+ on: "right",
+ },
+ advanceOn: {
+ selector: TUTORIAL_SELECTORS.BLOCKS_TRIGGER,
+ event: "click",
+ },
+ buttons: [],
+ when: {
+ show: () => {
+ highlightElement(TUTORIAL_SELECTORS.BLOCKS_TRIGGER);
+ },
+ hide: () => {
+ removeAllHighlights();
+ },
+ },
+ },
+
+ {
+ id: "block-menu-overview",
+ title: "The Block Menu",
+ text: `
+
+
This is the Block Menu — your toolbox for building agents.
+
Here you'll find:
+
+ Input Blocks — Entry points for data
+ Action Blocks — Processing and AI operations
+ Output Blocks — Results and responses
+ Integrations — Third-party service blocks
+ Library Agents — Your personal agents
+ Marketplace Agents — Community agents
+
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.BLOCKS_CONTENT,
+ on: "left",
+ },
+ beforeShowPromise: () => waitForElement(TUTORIAL_SELECTORS.BLOCKS_CONTENT),
+ when: {
+ show: () => forceBlockMenuOpen(true),
+ },
+ buttons: [
+ {
+ text: "Next",
+ action: () => tour.next(),
+ },
+ ],
+ },
+
+ {
+ id: "search-calculator",
+ title: "Search for a Block",
+ text: `
+
+
Let's add a Calculator block to start.
+ ${banner(ICONS.Keyboard, "Type Calculator in the search bar", "action")}
+
The search will filter blocks as you type.
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.BLOCKS_SEARCH_INPUT_BOX,
+ on: "bottom",
+ },
+ beforeShowPromise: () =>
+ waitForElement(TUTORIAL_SELECTORS.BLOCKS_SEARCH_INPUT_BOX),
+ when: {
+ show: () => {
+ forceBlockMenuOpen(true);
+ setTimeout(() => {
+ focusElement(TUTORIAL_SELECTORS.BLOCKS_SEARCH_INPUT_BOX);
+ }, 100);
+
+ const checkForCalculator = setInterval(() => {
+ const calcBlock = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR_IN_SEARCH,
+ );
+ if (calcBlock) {
+ clearInterval(checkForCalculator);
+
+ const searchInput = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCKS_SEARCH_INPUT,
+ ) as HTMLInputElement;
+ if (searchInput) {
+ searchInput.blur();
+ }
+
+ disableOtherBlocks(
+ TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR_IN_SEARCH,
+ );
+ pulseElement(TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR_IN_SEARCH);
+ calcBlock.scrollIntoView({ behavior: "smooth", block: "center" });
+ setTimeout(() => {
+ tour.next();
+ }, 300);
+ }
+ }, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL);
+
+ (window as any).__tutorialCalcInterval = checkForCalculator;
+ },
+ hide: () => {
+ if ((window as any).__tutorialCalcInterval) {
+ clearInterval((window as any).__tutorialCalcInterval);
+ delete (window as any).__tutorialCalcInterval;
+ }
+ enableAllBlocks();
+ },
+ },
+ buttons: [],
+ },
+
+ {
+ id: "select-calculator",
+ title: "Add the Calculator Block",
+ text: `
+
+
You should see the Calculator block in the results.
+ ${banner(ICONS.ClickIcon, "Click on the Calculator block to add it", "action")}
+
+
+ ${ICONS.Drag}
+ You can also drag blocks onto the canvas
+
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR,
+ on: "left",
+ },
+ beforeShowPromise: async () => {
+ forceBlockMenuOpen(true);
+ await waitForElement(TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR, 5000);
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ },
+ when: {
+ show: () => {
+ const calcBlock = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR,
+ );
+ if (calcBlock) {
+ disableOtherBlocks(TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR);
+ } else {
+ highlightFirstBlockInSearch();
+ }
+
+ const CALCULATOR_BLOCK_ID = BLOCK_IDS.CALCULATOR;
+
+ const initialNodeCount = useNodeStore.getState().nodes.length;
+
+ const unsubscribe = useNodeStore.subscribe((state) => {
+ if (state.nodes.length > initialNodeCount) {
+ const calculatorNode = state.nodes.find(
+ (node) => node.data?.block_id === CALCULATOR_BLOCK_ID,
+ );
+
+ if (calculatorNode) {
+ unsubscribe();
+ enableAllBlocks();
+ forceBlockMenuOpen(false);
+ tour.next();
+ }
+ }
+ });
+
+ (tour.getCurrentStep() as any)._nodeUnsubscribe = unsubscribe;
+ },
+ },
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/completion.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/completion.ts
new file mode 100644
index 0000000000..12d3f3fd92
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/completion.ts
@@ -0,0 +1,51 @@
+import { StepOptions } from "shepherd.js";
+
+export const createCompletionSteps = (tour: any): StepOptions[] => [
+ {
+ id: "congratulations",
+ title: "Congratulations! 🎉",
+ text: `
+
+
You have successfully created and run your first agent flow!
+
+
+
You learned how to:
+
+ • Add blocks from the Block Menu
+ • Understand input and output handles
+ • Configure block values
+ • Connect blocks together
+ • Save and run your agent
+ • View execution status and output
+
+
+
+
Happy building! 🚀
+
+ `,
+ when: {
+ show: () => {
+ const modal = document.querySelector(
+ ".shepherd-modal-overlay-container",
+ );
+ if (modal) {
+ (modal as HTMLElement).style.opacity = "0.3";
+ }
+ },
+ },
+ buttons: [
+ {
+ text: "Restart Tutorial",
+ action: () => {
+ tour.cancel();
+ setTimeout(() => tour.start(), 100);
+ },
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Finish",
+ action: () => tour.complete(),
+ },
+ ],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/configure-calculator.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/configure-calculator.ts
new file mode 100644
index 0000000000..9a4a542437
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/configure-calculator.ts
@@ -0,0 +1,197 @@
+import { StepOptions } from "shepherd.js";
+import { TUTORIAL_SELECTORS } from "../constants";
+import {
+ fitViewToScreen,
+ highlightElement,
+ removeAllHighlights,
+ getFirstNode,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+
+const getRequirementsHtml = () => `
+
+
⚠️ Required to continue:
+
+
+ ○ Enter a number in field A (e.g., 10)
+
+
+ ○ Enter a number in field B (e.g., 5)
+
+
+ ○ Select an Operation (Add, Multiply, etc.)
+
+
+
+`;
+
+const updateToSuccessState = () => {
+ const reqBox = document.querySelector("#requirements-box");
+ const reqTitle = document.querySelector("#requirements-title");
+ const reqList = document.querySelector("#requirements-list");
+
+ if (reqBox && reqTitle) {
+ reqBox.classList.remove("bg-amber-50", "ring-amber-200");
+ reqBox.classList.add("bg-green-50", "ring-green-200");
+ reqTitle.classList.remove("text-amber-600");
+ reqTitle.classList.add("text-green-600");
+ reqTitle.innerHTML = "🎉 Hurray! All values are completed!";
+ if (reqList) {
+ reqList.classList.add("hidden");
+ }
+ }
+};
+
+const updateToWarningState = () => {
+ const reqBox = document.querySelector("#requirements-box");
+ const reqTitle = document.querySelector("#requirements-title");
+ const reqList = document.querySelector("#requirements-list");
+
+ if (reqBox && reqTitle) {
+ reqBox.classList.remove("bg-green-50", "ring-green-200");
+ reqBox.classList.add("bg-amber-50", "ring-amber-200");
+ reqTitle.classList.remove("text-green-600");
+ reqTitle.classList.add("text-amber-600");
+ reqTitle.innerHTML = "⚠️ Required to continue:";
+ if (reqList) {
+ reqList.classList.remove("hidden");
+ }
+ }
+};
+
+export const createConfigureCalculatorSteps = (tour: any): StepOptions[] => [
+ {
+ id: "enter-values",
+ title: "Enter Values",
+ text: `
+
+
Now let's configure the block with actual values.
+ ${getRequirementsHtml()}
+ ${banner(ICONS.ClickIcon, "Fill in all the required fields above", "action")}
+
+ `,
+ beforeShowPromise: () => {
+ fitViewToScreen();
+ return Promise.resolve();
+ },
+ attachTo: {
+ element: TUTORIAL_SELECTORS.CALCULATOR_NODE_FORM_CONTAINER,
+ on: "right",
+ },
+ when: {
+ show: () => {
+ const node = getFirstNode();
+ if (node) {
+ highlightElement(`[data-id="custom-node-${node.id}"]`);
+ }
+
+ let wasComplete = false;
+
+ const checkInterval = setInterval(() => {
+ const node = getFirstNode();
+ if (!node) return;
+
+ const hardcodedValues = node.data?.hardcodedValues || {};
+ const hasA =
+ hardcodedValues.a !== undefined &&
+ hardcodedValues.a !== null &&
+ hardcodedValues.a !== "";
+ const hasB =
+ hardcodedValues.b !== undefined &&
+ hardcodedValues.b !== null &&
+ hardcodedValues.b !== "";
+ const hasOp =
+ hardcodedValues.operation !== undefined &&
+ hardcodedValues.operation !== null &&
+ hardcodedValues.operation !== "";
+
+ const allComplete = hasA && hasB && hasOp;
+
+ const reqA = document.querySelector("#req-a .req-icon");
+ const reqB = document.querySelector("#req-b .req-icon");
+ const reqOp = document.querySelector("#req-op .req-icon");
+
+ if (reqA) reqA.textContent = hasA ? "✓" : "○";
+ if (reqB) reqB.textContent = hasB ? "✓" : "○";
+ if (reqOp) reqOp.textContent = hasOp ? "✓" : "○";
+
+ const reqAEl = document.querySelector("#req-a");
+ const reqBEl = document.querySelector("#req-b");
+ const reqOpEl = document.querySelector("#req-op");
+
+ if (reqAEl) {
+ reqAEl.classList.toggle("text-green-600", hasA);
+ reqAEl.classList.toggle("text-amber-600", !hasA);
+ }
+ if (reqBEl) {
+ reqBEl.classList.toggle("text-green-600", hasB);
+ reqBEl.classList.toggle("text-amber-600", !hasB);
+ }
+ if (reqOpEl) {
+ reqOpEl.classList.toggle("text-green-600", hasOp);
+ reqOpEl.classList.toggle("text-amber-600", !hasOp);
+ }
+
+ if (allComplete && !wasComplete) {
+ updateToSuccessState();
+ wasComplete = true;
+ } else if (!allComplete && wasComplete) {
+ updateToWarningState();
+ wasComplete = false;
+ }
+
+ const nextBtn = document.querySelector(
+ ".shepherd-button-primary",
+ ) as HTMLButtonElement;
+ if (nextBtn) {
+ nextBtn.style.opacity = allComplete ? "1" : "0.5";
+ nextBtn.style.pointerEvents = allComplete ? "auto" : "none";
+ }
+ }, 300);
+
+ (window as any).__tutorialCheckInterval = checkInterval;
+ },
+ hide: () => {
+ removeAllHighlights();
+ if ((window as any).__tutorialCheckInterval) {
+ clearInterval((window as any).__tutorialCheckInterval);
+ delete (window as any).__tutorialCheckInterval;
+ }
+ },
+ },
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.back(),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Continue",
+ action: () => {
+ const node = getFirstNode();
+ if (!node) return;
+
+ const hardcodedValues = node.data?.hardcodedValues || {};
+ const hasA =
+ hardcodedValues.a !== undefined &&
+ hardcodedValues.a !== null &&
+ hardcodedValues.a !== "";
+ const hasB =
+ hardcodedValues.b !== undefined &&
+ hardcodedValues.b !== null &&
+ hardcodedValues.b !== "";
+ const hasOp =
+ hardcodedValues.operation !== undefined &&
+ hardcodedValues.operation !== null &&
+ hardcodedValues.operation !== "";
+
+ if (hasA && hasB && hasOp) {
+ tour.next();
+ }
+ },
+ classes: "shepherd-button-primary",
+ },
+ ],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/connections.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/connections.ts
new file mode 100644
index 0000000000..7d0ebc9116
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/connections.ts
@@ -0,0 +1,276 @@
+import { StepOptions } from "shepherd.js";
+import {
+ fitViewToScreen,
+ highlightElement,
+ removeAllHighlights,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+import { useEdgeStore } from "../../../../stores/edgeStore";
+import { TUTORIAL_SELECTORS } from "../constants";
+
+const getConnectionStatusHtml = (id: string, isConnected: boolean = false) => `
+
+ ${isConnected ? "✅ Connected!" : "Waiting for connection..."}
+
+`;
+
+const updateConnectionStatus = (
+ id: string,
+ isConnected: boolean,
+ message?: string,
+) => {
+ const statusEl = document.querySelector(`#${id}`);
+ if (statusEl) {
+ statusEl.innerHTML =
+ message || (isConnected ? "✅ Connected!" : "Waiting for connection...");
+ statusEl.classList.remove(
+ "bg-amber-50",
+ "ring-amber-200",
+ "text-amber-600",
+ "bg-green-50",
+ "ring-green-200",
+ "text-green-600",
+ );
+ if (isConnected) {
+ statusEl.classList.add("bg-green-50", "ring-green-200", "text-green-600");
+ } else {
+ statusEl.classList.add("bg-amber-50", "ring-amber-200", "text-amber-600");
+ }
+ }
+};
+
+const hasAnyEdge = (): boolean => {
+ return useEdgeStore.getState().edges.length > 0;
+};
+
+export const createConnectionSteps = (tour: any): StepOptions[] => {
+ let isConnecting = false;
+
+ const handleMouseDown = () => {
+ isConnecting = true;
+
+ const inputSelector =
+ TUTORIAL_SELECTORS.FIRST_CALCULATOR_RESULT_OUTPUT_HANDLER;
+ if (inputSelector) {
+ highlightElement(inputSelector);
+ }
+
+ setTimeout(() => {
+ if (isConnecting) {
+ tour.next();
+ }
+ }, 100);
+ };
+
+ const resetConnectionState = () => {
+ isConnecting = false;
+ };
+
+ return [
+ {
+ id: "connect-blocks-output",
+ title: "Connect the Blocks: Output",
+ text: `
+
+
Now, let's connect the Result output of the first Calculator to the input (A) of the second Calculator.
+
+
+
Drag from the Result output:
+
Click and drag from the Result output pin (right side) of the first Calculator block .
+
+ ${getConnectionStatusHtml("connection-status-output", false)}
+ ${banner(ICONS.Drag, "Drag from the Result output pin", "action")}
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.FIRST_CALCULATOR_RESULT_OUTPUT_HANDLER,
+ on: "left",
+ },
+
+ when: {
+ show: () => {
+ resetConnectionState();
+
+ if (hasAnyEdge()) {
+ updateConnectionStatus(
+ "connection-status-output",
+ true,
+ "✅ Connection already exists!",
+ );
+ setTimeout(() => {
+ tour.next();
+ }, 1000);
+ return;
+ }
+
+ const outputSelector =
+ TUTORIAL_SELECTORS.FIRST_CALCULATOR_RESULT_OUTPUT_HANDLER;
+ if (outputSelector) {
+ const outputHandle = document.querySelector(outputSelector);
+ if (outputHandle) {
+ highlightElement(outputSelector);
+ outputHandle.addEventListener("mousedown", handleMouseDown);
+ }
+ }
+
+ const unsubscribe = useEdgeStore.subscribe(() => {
+ if (hasAnyEdge()) {
+ updateConnectionStatus("connection-status-output", true);
+ setTimeout(() => {
+ unsubscribe();
+ tour.next();
+ }, 500);
+ }
+ });
+
+ (tour.getCurrentStep() as any)._edgeUnsubscribe = unsubscribe;
+ },
+ hide: () => {
+ removeAllHighlights();
+ const step = tour.getCurrentStep() as any;
+ if (step?._edgeUnsubscribe) {
+ step._edgeUnsubscribe();
+ }
+ const outputSelector =
+ TUTORIAL_SELECTORS.FIRST_CALCULATOR_RESULT_OUTPUT_HANDLER;
+ if (outputSelector) {
+ const outputHandle = document.querySelector(outputSelector);
+ if (outputHandle) {
+ outputHandle.removeEventListener("mousedown", handleMouseDown);
+ }
+ }
+ },
+ },
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.back(),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Skip (already connected)",
+ action: () => tour.show("connection-complete"),
+ classes: "shepherd-button-secondary",
+ },
+ ],
+ },
+
+ {
+ id: "connect-blocks-input",
+ title: "Connect the Blocks: Input",
+ text: `
+
+
Now, connect to the input (A) of the second Calculator block.
+
+
+
Drop on the A input:
+
Drag to the A input handle (left side) of the second Calculator block .
+
+ ${getConnectionStatusHtml("connection-status-input", false)}
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.SECOND_CALCULATOR_NUMBER_A_INPUT_HANDLER,
+ on: "right",
+ },
+ when: {
+ show: () => {
+ const inputSelector =
+ TUTORIAL_SELECTORS.SECOND_CALCULATOR_NUMBER_A_INPUT_HANDLER;
+ if (inputSelector) {
+ highlightElement(inputSelector);
+ }
+
+ if (hasAnyEdge()) {
+ updateConnectionStatus(
+ "connection-status-input",
+ true,
+ "✅ Connected!",
+ );
+ setTimeout(() => {
+ tour.next();
+ }, 500);
+ return;
+ }
+
+ const unsubscribe = useEdgeStore.subscribe(() => {
+ if (hasAnyEdge()) {
+ updateConnectionStatus("connection-status-input", true);
+ setTimeout(() => {
+ unsubscribe();
+ tour.next();
+ }, 500);
+ }
+ });
+
+ (tour.getCurrentStep() as any)._edgeUnsubscribe = unsubscribe;
+
+ const handleMouseUp = () => {
+ setTimeout(() => {
+ if (!hasAnyEdge()) {
+ isConnecting = false;
+ tour.show("connect-blocks-output");
+ }
+ }, 200);
+ };
+ document.addEventListener("mouseup", handleMouseUp, true);
+ (tour.getCurrentStep() as any)._mouseUpHandler = handleMouseUp;
+ },
+ hide: () => {
+ removeAllHighlights();
+ const step = tour.getCurrentStep() as any;
+ if (step?._edgeUnsubscribe) {
+ step._edgeUnsubscribe();
+ }
+ if (step?._mouseUpHandler) {
+ document.removeEventListener("mouseup", step._mouseUpHandler, true);
+ }
+ },
+ },
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.show("connect-blocks-output"),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Skip (already connected)",
+ action: () => tour.next(),
+ classes: "shepherd-button-secondary",
+ },
+ ],
+ },
+
+ {
+ id: "connection-complete",
+ title: "Blocks Connected! 🎉",
+ text: `
+
+
Excellent! Your Calculator blocks are now connected:
+
+
+
+ Calculator 1
+ →
+ Calculator 2
+
+
The result of Calculator 1 flows into Calculator 2's input A
+
+
+
Now let's save and run your agent!
+
+ `,
+ beforeShowPromise: async () => {
+ fitViewToScreen();
+ return Promise.resolve();
+ },
+ buttons: [
+ {
+ text: "Save My Agent",
+ action: () => tour.next(),
+ },
+ ],
+ },
+ ];
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/index.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/index.ts
new file mode 100644
index 0000000000..657dbf76bb
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/index.ts
@@ -0,0 +1,22 @@
+import { StepOptions } from "shepherd.js";
+import { createWelcomeSteps } from "./welcome";
+import { createBlockMenuSteps } from "./block-menu";
+import { createBlockBasicsSteps } from "./block-basics";
+import { createConfigureCalculatorSteps } from "./configure-calculator";
+import { createSecondCalculatorSteps } from "./second-calculator";
+import { createConnectionSteps } from "./connections";
+import { createSaveSteps } from "./save";
+import { createRunSteps } from "./run";
+import { createCompletionSteps } from "./completion";
+
+export const createTutorialSteps = (tour: any): StepOptions[] => [
+ ...createWelcomeSteps(tour),
+ ...createBlockMenuSteps(tour),
+ ...createBlockBasicsSteps(tour),
+ ...createConfigureCalculatorSteps(tour),
+ ...createSecondCalculatorSteps(tour),
+ ...createConnectionSteps(tour),
+ ...createSaveSteps(),
+ ...createRunSteps(tour),
+ ...createCompletionSteps(tour),
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/run.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/run.ts
new file mode 100644
index 0000000000..bebe69480d
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/run.ts
@@ -0,0 +1,97 @@
+import { StepOptions } from "shepherd.js";
+import { TUTORIAL_SELECTORS } from "../constants";
+import {
+ waitForElement,
+ fitViewToScreen,
+ highlightElement,
+ removeAllHighlights,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+
+export const createRunSteps = (tour: any): StepOptions[] => [
+ {
+ id: "press-run",
+ title: "Run Your Agent",
+ text: `
+
+
Your agent is saved and ready! Now let's run it to see it in action.
+ ${banner(ICONS.ClickIcon, "Click the Run button", "action")}
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.RUN_BUTTON,
+ on: "top",
+ },
+ advanceOn: {
+ selector: TUTORIAL_SELECTORS.RUN_BUTTON,
+ event: "click",
+ },
+ beforeShowPromise: () =>
+ waitForElement(TUTORIAL_SELECTORS.RUN_BUTTON, 3000).catch(() => {}),
+ when: {
+ show: () => {
+ highlightElement(TUTORIAL_SELECTORS.RUN_BUTTON);
+ },
+ hide: () => {
+ removeAllHighlights();
+ setTimeout(() => {
+ fitViewToScreen();
+ }, 500);
+ },
+ },
+ buttons: [],
+ },
+
+ {
+ id: "show-output",
+ title: "View the Output",
+ text: `
+
+
Here's the output of your block!
+
+
+
Latest Output:
+
After each run, you can see the result of each block at the bottom of the block.
+
+
+
+
The output shows:
+
+ • The calculated result
+ • Execution timestamp
+
+
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.FIRST_CALCULATOR_NODE_OUTPUT,
+ on: "top",
+ },
+ beforeShowPromise: () =>
+ new Promise((resolve) => {
+ setTimeout(() => {
+ waitForElement(TUTORIAL_SELECTORS.FIRST_CALCULATOR_NODE_OUTPUT, 5000)
+ .then(() => {
+ fitViewToScreen();
+ resolve(undefined);
+ })
+ .catch(resolve);
+ }, 300);
+ }),
+ when: {
+ show: () => {
+ highlightElement(TUTORIAL_SELECTORS.FIRST_CALCULATOR_NODE_OUTPUT);
+ },
+ hide: () => {
+ removeAllHighlights();
+ },
+ },
+ buttons: [
+ {
+ text: "Finish Tutorial",
+ action: () => tour.next(),
+ },
+ ],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/save.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/save.ts
new file mode 100644
index 0000000000..32c13844ff
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/save.ts
@@ -0,0 +1,71 @@
+import { StepOptions } from "shepherd.js";
+import { TUTORIAL_SELECTORS } from "../constants";
+import {
+ waitForElement,
+ highlightElement,
+ removeAllHighlights,
+ forceSaveOpen,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+export const createSaveSteps = (): StepOptions[] => [
+ {
+ id: "open-save",
+ title: "Save Your Agent",
+ text: `
+
+
Before running, we need to save your agent.
+ ${banner(ICONS.ClickIcon, "Click the Save button", "action")}
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.SAVE_TRIGGER,
+ on: "right",
+ },
+ advanceOn: {
+ selector: TUTORIAL_SELECTORS.SAVE_TRIGGER,
+ event: "click",
+ },
+ beforeShowPromise: () =>
+ waitForElement(TUTORIAL_SELECTORS.SAVE_TRIGGER, 3000).catch(() => {}),
+ buttons: [],
+ when: {
+ show: () => {
+ highlightElement(TUTORIAL_SELECTORS.SAVE_TRIGGER);
+ },
+ hide: () => {
+ removeAllHighlights();
+ },
+ },
+ },
+
+ {
+ id: "save-details",
+ title: "Name Your Agent",
+ text: `
+
+
Give your agent a name and optional description.
+ ${banner(ICONS.ClickIcon, 'Enter a name and click "Save Agent"', "action")}
+
Example: "My Calculator Agent"
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.SAVE_CONTENT,
+ on: "right",
+ },
+ advanceOn: {
+ selector: TUTORIAL_SELECTORS.SAVE_AGENT_BUTTON,
+ event: "click",
+ },
+ beforeShowPromise: () => waitForElement(TUTORIAL_SELECTORS.SAVE_CONTENT),
+ when: {
+ show: () => {
+ forceSaveOpen(true);
+ },
+ hide: () => {
+ forceSaveOpen(false);
+ },
+ },
+ buttons: [],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/second-calculator.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/second-calculator.ts
new file mode 100644
index 0000000000..276b2f88b9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/second-calculator.ts
@@ -0,0 +1,272 @@
+import { StepOptions } from "shepherd.js";
+import {
+ waitForNodesCount,
+ fitViewToScreen,
+ highlightElement,
+ removeAllHighlights,
+ addSecondCalculatorBlock,
+ getSecondCalculatorNode,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+
+const getSecondCalculatorFormSelector = (): string | HTMLElement => {
+ const secondNode = getSecondCalculatorNode();
+ if (secondNode) {
+ const selector = `[data-id="form-creator-container-${secondNode.id}-node"]`;
+ const element = document.querySelector(selector);
+ if (element) {
+ return element as HTMLElement;
+ }
+ return selector;
+ }
+ const formContainers = document.querySelectorAll(
+ '[data-id^="form-creator-container-"]',
+ );
+ if (formContainers.length >= 2) {
+ return formContainers[1] as HTMLElement;
+ }
+ return '[data-id^="form-creator-container-"]';
+};
+
+const getSecondCalcRequirementsHtml = () => `
+
+
⚠️ Required to continue:
+
+
+ ○ Enter a number in field B (e.g., 2)
+
+
+ ○ Select an Operation (e.g., Multiply)
+
+
+
Note: Field A will be connected from the first Calculator's output
+
+`;
+
+const updateSecondCalcToSuccessState = () => {
+ const reqBox = document.querySelector("#second-calc-requirements-box");
+ const reqTitle = document.querySelector("#second-calc-requirements-title");
+ const reqList = document.querySelector("#second-calc-requirements-list");
+
+ if (reqBox && reqTitle) {
+ reqBox.classList.remove("bg-amber-50", "ring-amber-200");
+ reqBox.classList.add("bg-green-50", "ring-green-200");
+ reqTitle.classList.remove("text-amber-600");
+ reqTitle.classList.add("text-green-600");
+ reqTitle.innerHTML = "🎉 Hurray! All values are completed!";
+ if (reqList) {
+ reqList.classList.add("hidden");
+ }
+ }
+};
+
+const updateSecondCalcToWarningState = () => {
+ const reqBox = document.querySelector("#second-calc-requirements-box");
+ const reqTitle = document.querySelector("#second-calc-requirements-title");
+ const reqList = document.querySelector("#second-calc-requirements-list");
+
+ if (reqBox && reqTitle) {
+ reqBox.classList.remove("bg-green-50", "ring-green-200");
+ reqBox.classList.add("bg-amber-50", "ring-amber-200");
+ reqTitle.classList.remove("text-green-600");
+ reqTitle.classList.add("text-amber-600");
+ reqTitle.innerHTML = "⚠️ Required to continue:";
+ if (reqList) {
+ reqList.classList.remove("hidden");
+ }
+ }
+};
+
+export const createSecondCalculatorSteps = (tour: any): StepOptions[] => [
+ {
+ id: "adding-second-calculator",
+ title: "Adding Second Calculator",
+ text: `
+
+
Great job configuring the first Calculator!
+
Now let's add a second Calculator block and connect them together.
+
+
+
We'll create a chain:
+
Calculator 1 → Calculator 2
+
The output of the first will feed into the second!
+
+
+ `,
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.back(),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Add Second Calculator",
+ action: () => tour.next(),
+ },
+ ],
+ },
+
+ {
+ id: "second-calculator-added",
+ title: "Second Calculator Added! ✅",
+ text: `
+
+
I've added a second Calculator block to your canvas.
+
Now let's configure it and connect them together.
+
+
+
You now have 2 Calculator blocks!
+
+
+ `,
+ beforeShowPromise: async () => {
+ addSecondCalculatorBlock();
+ await waitForNodesCount(2, 5000);
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ fitViewToScreen();
+ },
+ buttons: [
+ {
+ text: "Let's configure it",
+ action: () => tour.next(),
+ },
+ ],
+ },
+
+ {
+ id: "configure-second-calculator",
+ title: "Configure Second Calculator",
+ text: `
+
+
Now configure the second Calculator block .
+ ${getSecondCalcRequirementsHtml()}
+ ${banner(ICONS.ClickIcon, "Fill in field B and select an Operation", "action")}
+
+ `,
+ beforeShowPromise: async () => {
+ fitViewToScreen();
+ await new Promise((resolve) => {
+ const checkNode = () => {
+ const secondNode = getSecondCalculatorNode();
+ if (secondNode) {
+ const formContainer = document.querySelector(
+ `[data-id="form-creator-container-${secondNode.id}-node"]`,
+ );
+ if (formContainer) {
+ resolve();
+ } else {
+ setTimeout(checkNode, 100);
+ }
+ } else {
+ setTimeout(checkNode, 100);
+ }
+ };
+ checkNode();
+ });
+ },
+ attachTo: {
+ element: getSecondCalculatorFormSelector,
+ on: "right",
+ },
+ when: {
+ show: () => {
+ const secondNode = getSecondCalculatorNode();
+ if (secondNode) {
+ highlightElement(`[data-id="custom-node-${secondNode.id}"]`);
+ }
+
+ let wasComplete = false;
+
+ const checkInterval = setInterval(() => {
+ const secondNode = getSecondCalculatorNode();
+ if (!secondNode) return;
+
+ const hardcodedValues = secondNode.data?.hardcodedValues || {};
+ const hasB =
+ hardcodedValues.b !== undefined &&
+ hardcodedValues.b !== null &&
+ hardcodedValues.b !== "";
+ const hasOp =
+ hardcodedValues.operation !== undefined &&
+ hardcodedValues.operation !== null &&
+ hardcodedValues.operation !== "";
+
+ const allComplete = hasB && hasOp;
+
+ const reqB = document.querySelector("#req2-b .req-icon");
+ const reqOp = document.querySelector("#req2-op .req-icon");
+
+ if (reqB) reqB.textContent = hasB ? "✓" : "○";
+ if (reqOp) reqOp.textContent = hasOp ? "✓" : "○";
+
+ const reqBEl = document.querySelector("#req2-b");
+ const reqOpEl = document.querySelector("#req2-op");
+
+ if (reqBEl) {
+ reqBEl.classList.toggle("text-green-600", hasB);
+ reqBEl.classList.toggle("text-amber-600", !hasB);
+ }
+ if (reqOpEl) {
+ reqOpEl.classList.toggle("text-green-600", hasOp);
+ reqOpEl.classList.toggle("text-amber-600", !hasOp);
+ }
+
+ if (allComplete && !wasComplete) {
+ updateSecondCalcToSuccessState();
+ wasComplete = true;
+ } else if (!allComplete && wasComplete) {
+ updateSecondCalcToWarningState();
+ wasComplete = false;
+ }
+
+ const nextBtn = document.querySelector(
+ ".shepherd-button-primary",
+ ) as HTMLButtonElement;
+ if (nextBtn) {
+ nextBtn.style.opacity = allComplete ? "1" : "0.5";
+ nextBtn.style.pointerEvents = allComplete ? "auto" : "none";
+ }
+ }, 300);
+
+ (window as any).__tutorialSecondCalcInterval = checkInterval;
+ },
+ hide: () => {
+ removeAllHighlights();
+ if ((window as any).__tutorialSecondCalcInterval) {
+ clearInterval((window as any).__tutorialSecondCalcInterval);
+ delete (window as any).__tutorialSecondCalcInterval;
+ }
+ },
+ },
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.back(),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Continue",
+ action: () => {
+ const secondNode = getSecondCalculatorNode();
+ if (!secondNode) return;
+
+ const hardcodedValues = secondNode.data?.hardcodedValues || {};
+ const hasB =
+ hardcodedValues.b !== undefined &&
+ hardcodedValues.b !== null &&
+ hardcodedValues.b !== "";
+ const hasOp =
+ hardcodedValues.operation !== undefined &&
+ hardcodedValues.operation !== null &&
+ hardcodedValues.operation !== "";
+
+ if (hasB && hasOp) {
+ tour.next();
+ }
+ },
+ classes: "shepherd-button-primary",
+ },
+ ],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/welcome.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/welcome.ts
new file mode 100644
index 0000000000..78c7d078b9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/welcome.ts
@@ -0,0 +1,33 @@
+import { StepOptions } from "shepherd.js";
+import { handleTutorialSkip } from "../helpers";
+
+export const createWelcomeSteps = (tour: any): StepOptions[] => [
+ {
+ id: "welcome",
+ title: "Welcome to AutoGPT Builder! 👋🏻",
+ text: `
+
+
This interactive tutorial will teach you how to build your first AI agent.
+
You'll learn how to:
+
+ - Add blocks to your workflow
+ - Understand block inputs and outputs
+ - Save and run your agent
+ - and much more...
+
+
Estimated time: 3-4 minutes
+
+ `,
+ buttons: [
+ {
+ text: "Skip Tutorial",
+ action: () => handleTutorialSkip(tour),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Let's Begin",
+ action: () => tour.next(),
+ },
+ ],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/styles.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/styles.ts
new file mode 100644
index 0000000000..1b0cc5359e
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/styles.ts
@@ -0,0 +1,101 @@
+/**
+ * Tutorial Styles for New Builder
+ *
+ * CSS file contains:
+ * - Dynamic classes: .new-builder-tutorial-disable, .new-builder-tutorial-highlight, .new-builder-tutorial-pulse
+ * - Shepherd.js overrides
+ *
+ * Typography (body, small, action, info, tip, warning) uses Tailwind utilities directly in steps.ts
+ */
+import "./tutorial.css";
+
+export const injectTutorialStyles = () => {
+ if (typeof window !== "undefined") {
+ document.documentElement.setAttribute("data-tutorial-styles", "loaded");
+ }
+};
+
+export const removeTutorialStyles = () => {
+ if (typeof window !== "undefined") {
+ document.documentElement.removeAttribute("data-tutorial-styles");
+ }
+};
+
+// Reusable banner components with consistent styling
+
+type BannerVariant = "action" | "info" | "warning" | "success";
+
+const bannerStyles: Record<
+ BannerVariant,
+ { bg: string; ring: string; text: string }
+> = {
+ action: {
+ bg: "bg-violet-50",
+ ring: "ring-violet-200",
+ text: "text-violet-800",
+ },
+ info: {
+ bg: "bg-blue-50",
+ ring: "ring-blue-200",
+ text: "text-blue-800",
+ },
+ warning: {
+ bg: "bg-amber-50",
+ ring: "ring-amber-200",
+ text: "text-amber-800",
+ },
+ success: {
+ bg: "bg-green-50",
+ ring: "ring-green-200",
+ text: "text-green-800",
+ },
+};
+
+export const banner = (
+ icon: string,
+ content: string,
+ variant: BannerVariant = "action",
+ className?: string,
+) => {
+ const styles = bannerStyles[variant];
+ return `
+
+ ${icon}
+ ${content}
+
+`;
+};
+
+// Requirement box components
+export const requirementBox = (
+ title: string,
+ items: string,
+ variant: "warning" | "success" = "warning",
+) => {
+ const isSuccess = variant === "success";
+ return `
+
+`;
+};
+
+export const requirementItem = (id: string, content: string) => `
+
+ ○ ${content}
+
+`;
+
+// Connection status box
+export const connectionStatusBox = (
+ id: string,
+ variant: "waiting" | "connected" = "waiting",
+) => {
+ const isConnected = variant === "connected";
+ return `
+
+ ${isConnected ? "✅ Connection already exists!" : "Waiting for connection..."}
+
+`;
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/tutorial.css b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/tutorial.css
new file mode 100644
index 0000000000..6564a61888
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/tutorial.css
@@ -0,0 +1,149 @@
+.new-builder-tutorial-highlight * {
+ opacity: 1 !important;
+ filter: none !important;
+}
+
+.new-builder-tutorial-pulse {
+ animation: new-builder-tutorial-pulse 2s ease-in-out infinite;
+}
+
+@keyframes new-builder-tutorial-pulse {
+ 0%,
+ 100% {
+ box-shadow:
+ 0 0 0 4px white,
+ 0 0 0 6px #7c3aed,
+ 0 0 30px 8px rgba(124, 58, 237, 0.4);
+ }
+ 50% {
+ box-shadow:
+ 0 0 0 4px white,
+ 0 0 0 8px #8b5cf6,
+ 0 0 40px 12px rgba(124, 58, 237, 0.55);
+ }
+}
+
+.shepherd-element.new-builder-tour {
+ max-width: 420px !important;
+ border-radius: 1rem !important;
+ box-shadow:
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
+ 0px 4px 6px -1px rgba(0, 0, 0, 0.1),
+ 0 20px 25px -5px rgba(0, 0, 0, 0.1) !important;
+ background: white !important;
+ font-family: var(--font-geist-sans), system-ui, sans-serif !important;
+}
+
+.shepherd-element.new-builder-tour .shepherd-header {
+ padding: 1rem 1.25rem 0.5rem !important;
+ border-radius: 1rem 1rem 0 0 !important;
+ background: transparent !important;
+}
+
+.shepherd-element.new-builder-tour .shepherd-title {
+ font-family: var(--font-poppins), system-ui, sans-serif !important;
+ font-size: 1rem !important;
+ font-weight: 600 !important;
+ line-height: 1.5rem !important;
+ color: #18181b !important; /* zinc-900 */
+}
+
+.shepherd-element.new-builder-tour .shepherd-text {
+ padding: 0 1.25rem 1rem !important;
+ color: #52525b !important; /* zinc-600 */
+}
+
+.shepherd-element.new-builder-tour .shepherd-footer {
+ padding: 0.75rem 1.25rem 1rem !important;
+ border-top: 1px solid #e4e4e7 !important; /* zinc-200 */
+ gap: 0.5rem !important;
+ display: flex !important;
+ justify-content: flex-end !important;
+}
+
+.shepherd-element.new-builder-tour .shepherd-button {
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ white-space: nowrap !important;
+ font-family: var(--font-geist-sans), system-ui, sans-serif !important;
+ font-weight: 500 !important;
+ font-size: 0.875rem !important;
+ line-height: 1.25rem !important;
+ transition: all 150ms ease !important;
+ border-radius: 9999px !important; /* rounded-full */
+ min-width: 5rem !important;
+ padding: 0.5rem 1rem !important;
+ height: 2.25rem !important;
+ gap: 0.375rem !important;
+ cursor: pointer !important;
+}
+
+.shepherd-element.new-builder-tour
+ .shepherd-button:not(.shepherd-button-secondary) {
+ background-color: #27272a !important; /* zinc-800 */
+ border: 1px solid #27272a !important;
+ color: white !important;
+}
+
+.shepherd-element.new-builder-tour
+ .shepherd-button:not(.shepherd-button-secondary):hover {
+ background-color: #18181b !important; /* zinc-900 */
+ border-color: #18181b !important;
+}
+
+.shepherd-element.new-builder-tour
+ .shepherd-button:not(.shepherd-button-secondary):active {
+ transform: scale(0.98);
+}
+
+.shepherd-element.new-builder-tour .shepherd-button-secondary {
+ background-color: #f4f4f5 !important; /* zinc-100 */
+ border: 1px solid #f4f4f5 !important;
+ color: #52525b !important; /* zinc-600 */
+}
+
+.shepherd-element.new-builder-tour .shepherd-button-secondary:hover {
+ background-color: #e4e4e7 !important; /* zinc-200 */
+ border-color: #e4e4e7 !important;
+ color: #27272a !important; /* zinc-800 */
+}
+
+.shepherd-element.new-builder-tour .shepherd-button-secondary:active {
+ transform: scale(0.98);
+}
+
+.shepherd-element.new-builder-tour .shepherd-cancel-icon {
+ color: #a1a1aa !important; /* zinc-400 */
+ transition: color 150ms ease !important;
+ width: 1.5rem !important;
+ height: 1.5rem !important;
+}
+
+.shepherd-element.new-builder-tour .shepherd-cancel-icon:hover {
+ color: #52525b !important; /* zinc-600 */
+}
+
+.shepherd-element.new-builder-tour .shepherd-arrow {
+ transform: scale(1.2) !important;
+}
+
+.shepherd-element.new-builder-tour .shepherd-arrow:before {
+ background: white !important;
+}
+
+.shepherd-element.new-builder-tour[data-popper-placement^="top"] {
+ margin-bottom: 40px !important;
+}
+
+.shepherd-element.new-builder-tour[data-popper-placement^="bottom"] {
+ margin-top: 40px !important;
+}
+
+.shepherd-element.new-builder-tour[data-popper-placement^="left"] {
+ margin-right: 30px !important;
+}
+
+.shepherd-element.new-builder-tour[data-popper-placement^="right"] {
+ margin-left: 30px !important;
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/Block.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/Block.tsx
index 435aa62c61..10f4fc8a44 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/Block.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/Block.tsx
@@ -65,9 +65,15 @@ export const Block: BlockComponent = ({
setTimeout(() => document.body.removeChild(dragPreview), 0);
};
+ // Generate a data-id from the block id (e.g., "AgentInputBlock" -> "block-card-AgentInputBlock")
+ const blockDataId = blockData.id
+ ? `block-card-${blockData.id.replace(/[^a-zA-Z0-9]/g, "")}`
+ : undefined;
+
return (
{
- const { blockMenuOpen, setBlockMenuOpen } = useControlPanelStore();
+ const { blockMenuOpen, setBlockMenuOpen, forceOpenBlockMenu } =
+ useControlPanelStore();
return (
- // pinBlocksPopover ? true : open
-
+ {
+ if (!forceOpenBlockMenu || open) {
+ setBlockMenuOpen(open);
+ }
+ }}
+ open={forceOpenBlockMenu ? true : blockMenuOpen}
+ >
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/BlockMenuFilters.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/BlockMenuFilters.tsx
new file mode 100644
index 0000000000..ebcea9eee6
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/BlockMenuFilters.tsx
@@ -0,0 +1,57 @@
+import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
+import { FilterChip } from "../FilterChip";
+import { categories } from "./constants";
+import { FilterSheet } from "../FilterSheet/FilterSheet";
+import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
+
+export const BlockMenuFilters = () => {
+ const {
+ filters,
+ addFilter,
+ removeFilter,
+ categoryCounts,
+ creators,
+ addCreator,
+ removeCreator,
+ } = useBlockMenuStore();
+
+ const handleFilterClick = (filter: GetV2BuilderSearchFilterAnyOfItem) => {
+ if (filters.includes(filter)) {
+ removeFilter(filter);
+ } else {
+ addFilter(filter);
+ }
+ };
+
+ const handleCreatorClick = (creator: string) => {
+ if (creators.includes(creator)) {
+ removeCreator(creator);
+ } else {
+ addCreator(creator);
+ }
+ };
+
+ return (
+
+
+ {creators.length > 0 &&
+ creators.map((creator) => (
+ handleCreatorClick(creator)}
+ />
+ ))}
+ {categories.map((category) => (
+ handleFilterClick(category.key)}
+ number={categoryCounts[category.key] ?? 0}
+ />
+ ))}
+
+ );
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/constants.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/constants.ts
new file mode 100644
index 0000000000..b438aae91b
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/constants.ts
@@ -0,0 +1,15 @@
+import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
+import { CategoryKey } from "./types";
+
+export const categories: Array<{ key: CategoryKey; name: string }> = [
+ { key: GetV2BuilderSearchFilterAnyOfItem.blocks, name: "Blocks" },
+ {
+ key: GetV2BuilderSearchFilterAnyOfItem.integrations,
+ name: "Integrations",
+ },
+ {
+ key: GetV2BuilderSearchFilterAnyOfItem.marketplace_agents,
+ name: "Marketplace agents",
+ },
+ { key: GetV2BuilderSearchFilterAnyOfItem.my_agents, name: "My agents" },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/types.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/types.ts
new file mode 100644
index 0000000000..8fec9ef64d
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/types.ts
@@ -0,0 +1,26 @@
+import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
+
+export type DefaultStateType =
+ | "suggestion"
+ | "all_blocks"
+ | "input_blocks"
+ | "action_blocks"
+ | "output_blocks"
+ | "integrations"
+ | "marketplace_agents"
+ | "my_agents";
+
+export type CategoryKey = GetV2BuilderSearchFilterAnyOfItem;
+
+export interface Filters {
+ categories: {
+ blocks: boolean;
+ integrations: boolean;
+ marketplace_agents: boolean;
+ my_agents: boolean;
+ providers: boolean;
+ };
+ createdBy: string[];
+}
+
+export type CategoryCounts = Record;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/BlockMenuSearch.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/BlockMenuSearch.tsx
index de339431e8..70055b77a9 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/BlockMenuSearch.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/BlockMenuSearch.tsx
@@ -1,111 +1,17 @@
import { Text } from "@/components/atoms/Text/Text";
-import { useBlockMenuSearch } from "./useBlockMenuSearch";
-import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
-import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
-import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
-import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
-import { Block } from "../Block";
-import { UGCAgentBlock } from "../UGCAgentBlock";
-import { getSearchItemType } from "./helper";
-import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { blockMenuContainerStyle } from "../style";
-import { cn } from "@/lib/utils";
-import { NoSearchResult } from "../NoSearchResult";
+import { BlockMenuFilters } from "../BlockMenuFilters/BlockMenuFilters";
+import { BlockMenuSearchContent } from "../BlockMenuSearchContent/BlockMenuSearchContent";
export const BlockMenuSearch = () => {
- const {
- searchResults,
- isFetchingNextPage,
- fetchNextPage,
- hasNextPage,
- searchLoading,
- handleAddLibraryAgent,
- handleAddMarketplaceAgent,
- addingLibraryAgentId,
- addingMarketplaceAgentSlug,
- } = useBlockMenuSearch();
- const { searchQuery } = useBlockMenuStore();
-
- if (searchLoading) {
- return (
-
-
-
- );
- }
-
- if (searchResults.length === 0) {
- return ;
- }
-
return (
-
+
+
Search results
- }
- className="space-y-2.5"
- >
- {searchResults.map((item: SearchResponseItemsItem, index: number) => {
- const { type, data } = getSearchItemType(item);
- // backend give support to these 3 types only [right now] - we need to give support to integration and ai agent types in follow up PRs
- switch (type) {
- case "store_agent":
- return (
-
- handleAddMarketplaceAgent({
- creator_name: data.creator,
- slug: data.slug,
- })
- }
- />
- );
- case "block":
- return (
-
- );
-
- case "library_agent":
- return (
- handleAddLibraryAgent(data)}
- />
- );
-
- default:
- return null;
- }
- })}
-
+
);
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchBar/BlockMenuSearchBar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchBar/BlockMenuSearchBar.tsx
index 165a3f05ea..57a7b711b0 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchBar/BlockMenuSearchBar.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchBar/BlockMenuSearchBar.tsx
@@ -22,6 +22,7 @@ export const BlockMenuSearchBar: React.FC
= ({
return (
{
+ const {
+ searchResults,
+ isFetchingNextPage,
+ fetchNextPage,
+ hasNextPage,
+ searchLoading,
+ handleAddLibraryAgent,
+ handleAddMarketplaceAgent,
+ addingLibraryAgentId,
+ addingMarketplaceAgentSlug,
+ } = useBlockMenuSearchContent();
+
+ const { searchQuery } = useBlockMenuStore();
+
+ if (searchLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (searchResults.length === 0) {
+ return
;
+ }
+
+ return (
+
}
+ className="space-y-2.5"
+ >
+ {searchResults.map((item: SearchResponseItemsItem, index: number) => {
+ const { type, data } = getSearchItemType(item);
+ // backend give support to these 3 types only [right now] - we need to give support to integration and ai agent types in follow up PRs
+ switch (type) {
+ case "store_agent":
+ return (
+
+ handleAddMarketplaceAgent({
+ creator_name: data.creator,
+ slug: data.slug,
+ })
+ }
+ />
+ );
+ case "block":
+ return (
+
+ );
+
+ case "library_agent":
+ return (
+ handleAddLibraryAgent(data)}
+ />
+ );
+
+ default:
+ return null;
+ }
+ })}
+
+ );
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/helper.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/helper.ts
similarity index 100%
rename from autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/helper.ts
rename to autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/helper.ts
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/useBlockMenuSearch.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/useBlockMenuSearchContent.tsx
similarity index 83%
rename from autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/useBlockMenuSearch.ts
rename to autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/useBlockMenuSearchContent.tsx
index beff80a984..9da9cb4cbc 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/useBlockMenuSearch.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/useBlockMenuSearchContent.tsx
@@ -23,9 +23,19 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useToast } from "@/components/molecules/Toast/use-toast";
import * as Sentry from "@sentry/nextjs";
+import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
+
+export const useBlockMenuSearchContent = () => {
+ const {
+ searchQuery,
+ searchId,
+ setSearchId,
+ filters,
+ setCreatorsList,
+ creators,
+ setCategoryCounts,
+ } = useBlockMenuStore();
-export const useBlockMenuSearch = () => {
- const { searchQuery, searchId, setSearchId } = useBlockMenuStore();
const { toast } = useToast();
const { addAgentToBuilder, addLibraryAgentToBuilder } =
useAddAgentToBuilder();
@@ -57,6 +67,8 @@ export const useBlockMenuSearch = () => {
page_size: 8,
search_query: searchQuery,
search_id: searchId,
+ filter: filters.length > 0 ? filters : undefined,
+ by_creator: creators.length > 0 ? creators : undefined,
},
{
query: { getNextPageParam: getPaginationNextPageNumber },
@@ -98,6 +110,26 @@ export const useBlockMenuSearch = () => {
}
}, [searchQueryData, searchId, setSearchId]);
+ // from all the results, we need to get all the unique creators
+ useEffect(() => {
+ if (!searchQueryData?.pages?.length) {
+ return;
+ }
+ const latestData = okData(searchQueryData.pages.at(-1));
+ setCategoryCounts(
+ (latestData?.total_items as Record<
+ GetV2BuilderSearchFilterAnyOfItem,
+ number
+ >) || {
+ blocks: 0,
+ integrations: 0,
+ marketplace_agents: 0,
+ my_agents: 0,
+ },
+ );
+ setCreatorsList(latestData?.items || []);
+ }, [searchQueryData]);
+
useEffect(() => {
if (searchId && !searchQuery) {
resetSearchSession();
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSidebar/BlockMenuSidebar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSidebar/BlockMenuSidebar.tsx
index 16b0ccb8fb..1872e610b6 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSidebar/BlockMenuSidebar.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSidebar/BlockMenuSidebar.tsx
@@ -101,6 +101,7 @@ export const BlockMenuSidebar = () => {
key={item.type}
name={item.name}
number={item.number}
+ menuItemType={item.type}
selected={defaultState === item.type}
onClick={() => setDefaultState(item.type as DefaultStateType)}
/>
@@ -111,6 +112,7 @@ export const BlockMenuSidebar = () => {
key={item.type}
name={item.name}
number={item.number}
+ menuItemType={item.type}
className="max-w-[11.5339rem]"
selected={defaultState === item.type}
onClick={() => setDefaultState(item.type as DefaultStateType)}
@@ -122,6 +124,7 @@ export const BlockMenuSidebar = () => {
key={item.type}
name={item.name}
number={item.number}
+ menuItemType={item.type}
selected={defaultState === item.type}
onClick={
item.onClick ||
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterChip.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterChip.tsx
index 69931958b3..23197ab612 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterChip.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterChip.tsx
@@ -1,7 +1,9 @@
import { Button } from "@/components/__legacy__/ui/button";
import { cn } from "@/lib/utils";
-import { X } from "lucide-react";
-import React, { ButtonHTMLAttributes } from "react";
+import { XIcon } from "@phosphor-icons/react";
+import { AnimatePresence, motion } from "framer-motion";
+
+import React, { ButtonHTMLAttributes, useState } from "react";
interface Props extends ButtonHTMLAttributes {
selected?: boolean;
@@ -16,39 +18,51 @@ export const FilterChip: React.FC = ({
className,
...rest
}) => {
+ const [isHovered, setIsHovered] = useState(false);
return (
-
-
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
className={cn(
- "font-sans text-sm font-medium leading-[1.375rem] text-zinc-600 group-hover:text-zinc-600 group-disabled:text-zinc-400",
- selected && "text-zinc-50",
+ "group w-fit space-x-1 rounded-[1.5rem] border border-zinc-300 bg-transparent px-[0.625rem] py-[0.375rem] shadow-none",
+ "hover:border-violet-500 hover:bg-transparent focus:ring-0 disabled:cursor-not-allowed",
+ selected && "border-0 bg-violet-700 hover:border",
+ className,
)}
+ {...rest}
>
- {name}
-
- {selected && (
- <>
-
-
-
- {number !== undefined && (
-
- {number > 100 ? "100+" : number}
-
+
- )}
-
+ >
+ {name}
+
+ {selected && !isHovered && (
+
+
+
+ )}
+ {number !== undefined && isHovered && (
+
+ {number > 100 ? "100+" : number}
+
+ )}
+
+
);
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/FilterSheet.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/FilterSheet.tsx
new file mode 100644
index 0000000000..dc7c428245
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/FilterSheet.tsx
@@ -0,0 +1,156 @@
+import { FilterChip } from "../FilterChip";
+import { cn } from "@/lib/utils";
+import { CategoryKey } from "../BlockMenuFilters/types";
+import { AnimatePresence, motion } from "framer-motion";
+import { XIcon } from "@phosphor-icons/react";
+import { Button } from "@/components/atoms/Button/Button";
+import { Text } from "@/components/atoms/Text/Text";
+import { Separator } from "@/components/__legacy__/ui/separator";
+import { Checkbox } from "@/components/__legacy__/ui/checkbox";
+import { useFilterSheet } from "./useFilterSheet";
+import { INITIAL_CREATORS_TO_SHOW } from "./constant";
+
+export function FilterSheet({
+ categories,
+}: {
+ categories: Array<{ key: CategoryKey; name: string }>;
+}) {
+ const {
+ isOpen,
+ localCategories,
+ localCreators,
+ displayedCreatorsCount,
+ handleLocalCategoryChange,
+ handleToggleShowMoreCreators,
+ handleLocalCreatorChange,
+ handleClearFilters,
+ handleCloseButton,
+ handleApplyFilters,
+ hasLocalActiveFilters,
+ visibleCreators,
+ creators,
+ handleOpenFilters,
+ hasActiveFilters,
+ } = useFilterSheet();
+
+ return (
+
+
+
+
+ {isOpen && (
+
+ {/* Top section */}
+
+ Filters
+
+
+
+
+
+
+
+ {/* Category section */}
+
+
Categories
+
+ {categories.map((category) => (
+
+
+ handleLocalCategoryChange(category.key)
+ }
+ className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
+ />
+
+ {category.name}
+
+
+ ))}
+
+
+
+ {/* Created by section */}
+
+
+ Created by
+
+
+ {visibleCreators.map((creator, i) => (
+
+ handleLocalCreatorChange(creator)}
+ className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
+ />
+
+ {creator}
+
+
+ ))}
+
+ {creators.length > INITIAL_CREATORS_TO_SHOW && (
+
+ {displayedCreatorsCount < creators.length ? "More" : "Less"}
+
+ )}
+
+
+ {/* Footer section */}
+
+
+ Clear
+
+
+
+ Apply filters
+
+
+
+ )}
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/constant.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/constant.ts
new file mode 100644
index 0000000000..8e05dc1037
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/constant.ts
@@ -0,0 +1 @@
+export const INITIAL_CREATORS_TO_SHOW = 5;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/useFilterSheet.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/useFilterSheet.ts
new file mode 100644
index 0000000000..200671f4e7
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/useFilterSheet.ts
@@ -0,0 +1,100 @@
+import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
+import { useState } from "react";
+import { INITIAL_CREATORS_TO_SHOW } from "./constant";
+import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
+
+export const useFilterSheet = () => {
+ const { filters, creators_list, creators, setFilters, setCreators } =
+ useBlockMenuStore();
+
+ const [isOpen, setIsOpen] = useState(false);
+ const [localCategories, setLocalCategories] =
+ useState(filters);
+ const [localCreators, setLocalCreators] = useState(creators);
+ const [displayedCreatorsCount, setDisplayedCreatorsCount] = useState(
+ INITIAL_CREATORS_TO_SHOW,
+ );
+
+ const handleLocalCategoryChange = (
+ category: GetV2BuilderSearchFilterAnyOfItem,
+ ) => {
+ setLocalCategories((prev) => {
+ if (prev.includes(category)) {
+ return prev.filter((c) => c !== category);
+ }
+ return [...prev, category];
+ });
+ };
+
+ const hasActiveFilters = () => {
+ return filters.length > 0 || creators.length > 0;
+ };
+
+ const handleToggleShowMoreCreators = () => {
+ if (displayedCreatorsCount < creators.length) {
+ setDisplayedCreatorsCount(creators.length);
+ } else {
+ setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
+ }
+ };
+
+ const handleLocalCreatorChange = (creator: string) => {
+ setLocalCreators((prev) => {
+ if (prev.includes(creator)) {
+ return prev.filter((c) => c !== creator);
+ }
+ return [...prev, creator];
+ });
+ };
+
+ const handleClearFilters = () => {
+ setLocalCategories([]);
+ setLocalCreators([]);
+ setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
+ };
+
+ const handleCloseButton = () => {
+ setIsOpen(false);
+ setLocalCategories(filters);
+ setLocalCreators(creators);
+ setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
+ };
+
+ const handleApplyFilters = () => {
+ setFilters(localCategories);
+ setCreators(localCreators);
+ setIsOpen(false);
+ };
+
+ const handleOpenFilters = () => {
+ setIsOpen(true);
+ setLocalCategories(filters);
+ setLocalCreators(creators);
+ };
+
+ const hasLocalActiveFilters = () => {
+ return localCategories.length > 0 || localCreators.length > 0;
+ };
+
+ const visibleCreators = creators_list.slice(0, displayedCreatorsCount);
+
+ return {
+ creators,
+ isOpen,
+ setIsOpen,
+ localCategories,
+ localCreators,
+ displayedCreatorsCount,
+ setDisplayedCreatorsCount,
+ handleLocalCategoryChange,
+ handleToggleShowMoreCreators,
+ handleLocalCreatorChange,
+ handleClearFilters,
+ handleCloseButton,
+ handleOpenFilters,
+ handleApplyFilters,
+ hasLocalActiveFilters,
+ visibleCreators,
+ hasActiveFilters,
+ };
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/MenuItem.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/MenuItem.tsx
index a1dbbb4c6a..b846c02cb7 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/MenuItem.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/MenuItem.tsx
@@ -8,6 +8,7 @@ interface Props extends ButtonHTMLAttributes {
selected?: boolean;
number?: number;
name?: string;
+ menuItemType?: string;
}
export const MenuItem: React.FC = ({
@@ -15,10 +16,12 @@ export const MenuItem: React.FC = ({
number,
name,
className,
+ menuItemType,
...rest
}) => {
return (
{
const { form, isSaving, graphVersion, handleSave } = useNewSaveControl();
- const { saveControlOpen, setSaveControlOpen } = useControlPanelStore();
+ const { saveControlOpen, setSaveControlOpen, forceOpenSave } =
+ useControlPanelStore();
return (
-
+ {
+ if (!forceOpenSave || open) {
+ setSaveControlOpen(open);
+ }
+ }}
+ open={forceOpenSave ? true : saveControlOpen}
+ >
@@ -94,6 +102,7 @@ export const NewSaveControl = () => {
value={graphVersion || "-"}
disabled
data-testid="save-control-version-output"
+ data-tutorial-id="save-control-version-output"
label="Version"
wrapperClassName="!mb-0"
/>
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/UndoRedoButtons.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/UndoRedoButtons.tsx
index 5510335104..379f2743f8 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/UndoRedoButtons.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/UndoRedoButtons.tsx
@@ -42,7 +42,12 @@ export const UndoRedoButtons = () => {
<>
-
+
@@ -51,7 +56,12 @@ export const UndoRedoButtons = () => {
-
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/RIghtSidebar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/RIghtSidebar.tsx
index 5e4211ce5d..cc0c7ff765 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/RIghtSidebar.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/RIghtSidebar.tsx
@@ -25,7 +25,7 @@ export const RightSidebar = () => {
>
- Flow Debug Panel
+ Graph Debug Panel
@@ -65,7 +65,7 @@ export const RightSidebar = () => {
{l.source_id}[{l.source_name}] → {l.sink_id}[{l.sink_name}]
- edge_id: {l.id}
+ edge.id: {l.id}
))}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/helper.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/helper.ts
index 7b3c5b1d01..00c151d35b 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/helper.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/helper.ts
@@ -61,12 +61,18 @@ export const convertNodesPlusBlockInfoIntoCustomNodes = (
return customNode;
};
+const isToolSourceName = (sourceName: string): boolean =>
+ sourceName.startsWith("tools_^_");
+
+const cleanupSourceName = (sourceName: string): string =>
+ isToolSourceName(sourceName) ? "tools" : sourceName;
+
export const linkToCustomEdge = (link: Link): CustomEdge => ({
id: link.id ?? "",
type: "custom" as const,
source: link.source_id,
target: link.sink_id,
- sourceHandle: link.source_name,
+ sourceHandle: cleanupSourceName(link.source_name),
targetHandle: link.sink_name,
data: {
isStatic: link.is_static,
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BlocksControl.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BlocksControl.tsx
index d3383cdc4f..f5451e6d4d 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BlocksControl.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BlocksControl.tsx
@@ -12,7 +12,14 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/__legacy__/ui/popover";
-import { Block, BlockUIType, SpecialBlockID } from "@/lib/autogpt-server-api";
+import {
+ Block,
+ BlockIORootSchema,
+ BlockUIType,
+ GraphInputSchema,
+ GraphOutputSchema,
+ SpecialBlockID,
+} from "@/lib/autogpt-server-api";
import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons";
import { IconToyBrick } from "@/components/__legacy__/ui/icons";
import { getPrimaryCategoryColor } from "@/lib/utils";
@@ -24,8 +31,10 @@ import {
import { GraphMeta } from "@/lib/autogpt-server-api";
import jaro from "jaro-winkler";
-type _Block = Block & {
+type _Block = Omit & {
uiKey?: string;
+ inputSchema: BlockIORootSchema | GraphInputSchema;
+ outputSchema: BlockIORootSchema | GraphOutputSchema;
hardcodedValues?: Record;
_cached?: {
blockName: string;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BuildActionBar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BuildActionBar.tsx
index 9671109422..9d12439d8d 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BuildActionBar.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BuildActionBar.tsx
@@ -2,7 +2,7 @@ import React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/__legacy__/ui/button";
import { LogOut } from "lucide-react";
-import { ClockIcon } from "@phosphor-icons/react";
+import { ClockIcon, WarningIcon } from "@phosphor-icons/react";
import { IconPlay, IconSquare } from "@/components/__legacy__/ui/icons";
interface Props {
@@ -13,6 +13,7 @@ interface Props {
isRunning: boolean;
isDisabled: boolean;
className?: string;
+ resolutionModeActive?: boolean;
}
export const BuildActionBar: React.FC = ({
@@ -23,9 +24,30 @@ export const BuildActionBar: React.FC = ({
isRunning,
isDisabled,
className,
+ resolutionModeActive = false,
}) => {
const buttonClasses =
"flex items-center gap-2 text-sm font-medium md:text-lg";
+
+ // Show resolution mode message instead of action buttons
+ if (resolutionModeActive) {
+ return (
+
+
+
+
+ Remove incompatible connections to continue
+
+
+
+ );
+ }
+
return (
= ({
title="Run the agent"
aria-label="Run the agent"
data-testid="primary-action-run-agent"
+ data-tutorial-id="primary-action-run-agent"
>
Run
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomEdge/CustomEdge.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomEdge/CustomEdge.tsx
index ce14add315..5ca5393d69 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomEdge/CustomEdge.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomEdge/CustomEdge.tsx
@@ -60,10 +60,16 @@ export function CustomEdge({
targetY - 5,
);
const { deleteElements } = useReactFlow
();
- const { visualizeBeads } = useContext(BuilderContext) ?? {
+ const builderContext = useContext(BuilderContext);
+ const { visualizeBeads } = builderContext ?? {
visualizeBeads: "no",
};
+ // Check if this edge is broken (during resolution mode)
+ const isBroken =
+ builderContext?.resolutionMode?.active &&
+ builderContext?.resolutionMode?.brokenEdgeIds?.includes(id);
+
const onEdgeRemoveClick = () => {
deleteElements({ edges: [{ id }] });
};
@@ -171,12 +177,27 @@ export function CustomEdge({
const middle = getPointForT(0.5);
+ // Determine edge color - red for broken edges
+ const baseColor = data?.edgeColor ?? "#555555";
+ const edgeColor = isBroken ? "#ef4444" : baseColor;
+ // Add opacity to hex color (99 = 60% opacity, 80 = 50% opacity)
+ const strokeColor = isBroken
+ ? `${edgeColor}99`
+ : selected
+ ? edgeColor
+ : `${edgeColor}80`;
+
return (
<>
;
+};
export type CustomNodeData = {
blockType: string;
@@ -80,7 +89,7 @@ export type CustomNodeData = {
inputSchema: BlockIORootSchema;
outputSchema: BlockIORootSchema;
hardcodedValues: { [key: string]: any };
- connections: ConnectionData;
+ connections: ConnectedEdge[];
isOutputOpen: boolean;
status?: NodeExecutionResult["status"];
/** executionResults contains outputs across multiple executions
@@ -127,20 +136,199 @@ export const CustomNode = React.memo(
let subGraphID = "";
- if (data.uiType === BlockUIType.AGENT) {
- // Display the graph's schema instead AgentExecutorBlock's schema.
- data.inputSchema = data.hardcodedValues?.input_schema || {};
- data.outputSchema = data.hardcodedValues?.output_schema || {};
- subGraphID = data.hardcodedValues?.graph_id || subGraphID;
- }
-
if (!builderContext) {
throw new Error(
"BuilderContext consumer must be inside FlowEditor component",
);
}
- const { libraryAgent, setIsAnyModalOpen, getNextNodeId } = builderContext;
+ const {
+ libraryAgent,
+ setIsAnyModalOpen,
+ getNextNodeId,
+ availableFlows,
+ resolutionMode,
+ enterResolutionMode,
+ } = builderContext;
+
+ // Check if this node is in resolution mode (moved up for schema merge logic)
+ const isInResolutionMode =
+ resolutionMode.active && resolutionMode.nodeId === id;
+
+ if (data.uiType === BlockUIType.AGENT) {
+ // Display the graph's schema instead AgentExecutorBlock's schema.
+ const currentInputSchema = data.hardcodedValues?.input_schema || {};
+ const currentOutputSchema = data.hardcodedValues?.output_schema || {};
+ subGraphID = data.hardcodedValues?.graph_id || subGraphID;
+
+ // During resolution mode, merge old connected inputs/outputs with new schema
+ if (isInResolutionMode && resolutionMode.pendingUpdate) {
+ const newInputSchema =
+ (resolutionMode.pendingUpdate.input_schema as BlockIORootSchema) ||
+ {};
+ const newOutputSchema =
+ (resolutionMode.pendingUpdate.output_schema as BlockIORootSchema) ||
+ {};
+
+ // Merge input schemas: start with new schema, add old connected inputs that are missing
+ const mergedInputProps = { ...newInputSchema.properties };
+ const incomp = resolutionMode.incompatibilities;
+ if (incomp && currentInputSchema.properties) {
+ // Add back missing inputs that have connections (so user can see/delete them)
+ incomp.missingInputs.forEach((inputName) => {
+ if (currentInputSchema.properties[inputName]) {
+ mergedInputProps[inputName] =
+ currentInputSchema.properties[inputName];
+ }
+ });
+ // Add back inputs with type mismatches (keep old type so connection still works visually)
+ incomp.inputTypeMismatches.forEach((mismatch) => {
+ if (currentInputSchema.properties[mismatch.name]) {
+ mergedInputProps[mismatch.name] =
+ currentInputSchema.properties[mismatch.name];
+ }
+ });
+ }
+
+ // Merge output schemas: start with new schema, add old connected outputs that are missing
+ const mergedOutputProps = { ...newOutputSchema.properties };
+ if (incomp && currentOutputSchema.properties) {
+ incomp.missingOutputs.forEach((outputName) => {
+ if (currentOutputSchema.properties[outputName]) {
+ mergedOutputProps[outputName] =
+ currentOutputSchema.properties[outputName];
+ }
+ });
+ }
+
+ data.inputSchema = {
+ ...newInputSchema,
+ properties: mergedInputProps,
+ };
+ data.outputSchema = {
+ ...newOutputSchema,
+ properties: mergedOutputProps,
+ };
+ } else {
+ data.inputSchema = currentInputSchema;
+ data.outputSchema = currentOutputSchema;
+ }
+ }
+
+ const setHardcodedValues = useCallback(
+ (values: any) => {
+ updateNodeData(id, { hardcodedValues: values });
+ },
+ [id, updateNodeData],
+ );
+
+ // Sub-agent update detection
+ const isAgentBlock = data.uiType === BlockUIType.AGENT;
+ const graphId = isAgentBlock ? data.hardcodedValues?.graph_id : undefined;
+ const graphVersion = isAgentBlock
+ ? data.hardcodedValues?.graph_version
+ : undefined;
+
+ const subAgentUpdate = useSubAgentUpdate(
+ id,
+ graphId,
+ graphVersion,
+ isAgentBlock
+ ? (data.hardcodedValues?.input_schema as GraphInputSchema)
+ : undefined,
+ isAgentBlock
+ ? (data.hardcodedValues?.output_schema as GraphOutputSchema)
+ : undefined,
+ data.connections,
+ availableFlows,
+ );
+
+ const [showIncompatibilityDialog, setShowIncompatibilityDialog] =
+ useState(false);
+
+ // Helper to check if a handle is broken (for resolution mode)
+ const isInputHandleBroken = useCallback(
+ (handleName: string): boolean => {
+ if (!isInResolutionMode || !resolutionMode.incompatibilities) {
+ return false;
+ }
+ const incomp = resolutionMode.incompatibilities;
+ return (
+ incomp.missingInputs.includes(handleName) ||
+ incomp.inputTypeMismatches.some((m) => m.name === handleName)
+ );
+ },
+ [isInResolutionMode, resolutionMode.incompatibilities],
+ );
+
+ const isOutputHandleBroken = useCallback(
+ (handleName: string): boolean => {
+ if (!isInResolutionMode || !resolutionMode.incompatibilities) {
+ return false;
+ }
+ return resolutionMode.incompatibilities.missingOutputs.includes(
+ handleName,
+ );
+ },
+ [isInResolutionMode, resolutionMode.incompatibilities],
+ );
+
+ // Handle update button click
+ const handleUpdateClick = useCallback(() => {
+ if (!subAgentUpdate.latestGraph) return;
+
+ if (subAgentUpdate.isCompatible) {
+ // Compatible update - directly apply
+ const updatedValues = createUpdatedAgentNodeInputs(
+ data.hardcodedValues,
+ subAgentUpdate.latestGraph,
+ );
+ setHardcodedValues(updatedValues);
+ toast({
+ title: "Agent updated",
+ description: `Updated to version ${subAgentUpdate.latestVersion}`,
+ });
+ } else {
+ // Incompatible update - show dialog
+ setShowIncompatibilityDialog(true);
+ }
+ }, [subAgentUpdate, data.hardcodedValues, setHardcodedValues]);
+
+ // Handle confirm incompatible update
+ const handleConfirmIncompatibleUpdate = useCallback(() => {
+ if (!subAgentUpdate.latestGraph || !subAgentUpdate.incompatibilities) {
+ return;
+ }
+
+ // Create the updated values but DON'T apply them yet
+ const updatedValues = createUpdatedAgentNodeInputs(
+ data.hardcodedValues,
+ subAgentUpdate.latestGraph,
+ );
+
+ // Get broken edge IDs
+ const brokenEdgeIds = getBrokenEdgeIDs(
+ data.connections,
+ subAgentUpdate.incompatibilities,
+ id,
+ );
+
+ // Enter resolution mode with pending update (don't apply schema yet)
+ enterResolutionMode(
+ id,
+ subAgentUpdate.incompatibilities,
+ brokenEdgeIds,
+ updatedValues,
+ );
+
+ setShowIncompatibilityDialog(false);
+ }, [
+ subAgentUpdate,
+ data.hardcodedValues,
+ data.connections,
+ id,
+ enterResolutionMode,
+ ]);
useEffect(() => {
if (data.executionResults || data.status) {
@@ -156,13 +344,6 @@ export const CustomNode = React.memo(
setIsAnyModalOpen?.(isModalOpen || isOutputModalOpen);
}, [isModalOpen, isOutputModalOpen, data, setIsAnyModalOpen]);
- const setHardcodedValues = useCallback(
- (values: any) => {
- updateNodeData(id, { hardcodedValues: values });
- },
- [id, updateNodeData],
- );
-
const handleTitleEdit = useCallback(() => {
setIsEditingTitle(true);
setTimeout(() => {
@@ -255,6 +436,7 @@ export const CustomNode = React.memo(
isConnected={isOutputHandleConnected(propKey)}
schema={fieldSchema}
side="right"
+ isBroken={isOutputHandleBroken(propKey)}
/>
{"properties" in fieldSchema &&
renderHandles(
@@ -385,6 +567,7 @@ export const CustomNode = React.memo(
isRequired={isRequired}
schema={propSchema}
side="left"
+ isBroken={isInputHandleBroken(propKey)}
/>
) : (
propKey !== "credentials" &&
@@ -674,7 +857,7 @@ export const CustomNode = React.memo(
})();
const hasAdvancedFields =
- data.inputSchema &&
+ data.inputSchema?.properties &&
Object.entries(data.inputSchema.properties).some(([key, value]) => {
return (
value.advanced === true && !data.inputSchema.required?.includes(key)
@@ -873,6 +1056,22 @@ export const CustomNode = React.memo(
+ {/* Sub-agent Update Bar - shown below header */}
+ {isAgentBlock && (subAgentUpdate.hasUpdate || isInResolutionMode) && (
+
+ )}
+
{/* Body */}
{/* Input Handles */}
@@ -1044,9 +1243,24 @@ export const CustomNode = React.memo(
);
return (
-
- {nodeContent()}
-
+ <>
+
+ {nodeContent()}
+
+
+ {/* Incompatibility Dialog for sub-agent updates */}
+ {isAgentBlock && subAgentUpdate.incompatibilities && (
+
setShowIncompatibilityDialog(false)}
+ onConfirm={handleConfirmIncompatibleUpdate}
+ currentVersion={subAgentUpdate.currentVersion}
+ latestVersion={subAgentUpdate.latestVersion}
+ agentName={data.blockType || "Agent"}
+ incompatibilities={subAgentUpdate.incompatibilities}
+ />
+ )}
+ >
);
},
(prevProps, nextProps) => {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/IncompatibilityDialog.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/IncompatibilityDialog.tsx
new file mode 100644
index 0000000000..951fe2eab5
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/IncompatibilityDialog.tsx
@@ -0,0 +1,244 @@
+import React from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/__legacy__/ui/dialog";
+import { Button } from "@/components/__legacy__/ui/button";
+import { AlertTriangle, XCircle, PlusCircle } from "lucide-react";
+import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
+import { beautifyString } from "@/lib/utils";
+import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
+
+interface IncompatibilityDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ currentVersion: number;
+ latestVersion: number;
+ agentName: string;
+ incompatibilities: IncompatibilityInfo;
+}
+
+export const IncompatibilityDialog: React.FC = ({
+ isOpen,
+ onClose,
+ onConfirm,
+ currentVersion,
+ latestVersion,
+ agentName,
+ incompatibilities,
+}) => {
+ const hasMissingInputs = incompatibilities.missingInputs.length > 0;
+ const hasMissingOutputs = incompatibilities.missingOutputs.length > 0;
+ const hasNewInputs = incompatibilities.newInputs.length > 0;
+ const hasNewOutputs = incompatibilities.newOutputs.length > 0;
+ const hasNewRequired = incompatibilities.newRequiredInputs.length > 0;
+ const hasTypeMismatches = incompatibilities.inputTypeMismatches.length > 0;
+
+ const hasInputChanges = hasMissingInputs || hasNewInputs;
+ const hasOutputChanges = hasMissingOutputs || hasNewOutputs;
+
+ return (
+ !open && onClose()}>
+
+
+
+
+ Incompatible Update
+
+
+ Updating {beautifyString(agentName)} from v
+ {currentVersion} to v{latestVersion} will break some connections.
+
+
+
+
+ {/* Input changes - two column layout */}
+ {hasInputChanges && (
+
}
+ leftTitle="Removed"
+ leftItems={incompatibilities.missingInputs}
+ rightIcon={
}
+ rightTitle="Added"
+ rightItems={incompatibilities.newInputs}
+ />
+ )}
+
+ {/* Output changes - two column layout */}
+ {hasOutputChanges && (
+
}
+ leftTitle="Removed"
+ leftItems={incompatibilities.missingOutputs}
+ rightIcon={
}
+ rightTitle="Added"
+ rightItems={incompatibilities.newOutputs}
+ />
+ )}
+
+ {hasTypeMismatches && (
+
}
+ title="Type Changed"
+ description="These connected inputs have a different type:"
+ items={incompatibilities.inputTypeMismatches.map(
+ (m) => `${m.name} (${m.oldType} → ${m.newType})`,
+ )}
+ />
+ )}
+
+ {hasNewRequired && (
+
}
+ title="New Required Inputs"
+ description="These inputs are now required:"
+ items={incompatibilities.newRequiredInputs}
+ />
+ )}
+
+
+
+
+ If you proceed, you'll need to remove the broken connections
+ before you can save or run your agent.
+
+
+
+
+
+ Cancel
+
+
+ Update Anyway
+
+
+
+
+ );
+};
+
+interface TwoColumnSectionProps {
+ title: string;
+ leftIcon: React.ReactNode;
+ leftTitle: string;
+ leftItems: string[];
+ rightIcon: React.ReactNode;
+ rightTitle: string;
+ rightItems: string[];
+}
+
+const TwoColumnSection: React.FC = ({
+ title,
+ leftIcon,
+ leftTitle,
+ leftItems,
+ rightIcon,
+ rightTitle,
+ rightItems,
+}) => (
+
+
{title}
+
+ {/* Left column - Breaking changes */}
+
+
+ {leftIcon}
+ {leftTitle}
+
+
+ {leftItems.length > 0 ? (
+ leftItems.map((item) => (
+
+
+ {item}
+
+
+ ))
+ ) : (
+
+ None
+
+ )}
+
+
+
+ {/* Right column - Possible solutions */}
+
+
+ {rightIcon}
+ {rightTitle}
+
+
+ {rightItems.length > 0 ? (
+ rightItems.map((item) => (
+
+
+ {item}
+
+
+ ))
+ ) : (
+
+ None
+
+ )}
+
+
+
+
+);
+
+interface SingleColumnSectionProps {
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+ items: string[];
+}
+
+const SingleColumnSection: React.FC = ({
+ icon,
+ title,
+ description,
+ items,
+}) => (
+
+
+ {icon}
+ {title}
+
+
+ {description}
+
+
+ {items.map((item) => (
+
+
+ {item}
+
+
+ ))}
+
+
+);
+
+export default IncompatibilityDialog;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/SubAgentUpdateBar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/SubAgentUpdateBar.tsx
new file mode 100644
index 0000000000..5f421d90d8
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/SubAgentUpdateBar.tsx
@@ -0,0 +1,130 @@
+import React from "react";
+import { Button } from "@/components/__legacy__/ui/button";
+import { ArrowUp, AlertTriangle, Info } from "lucide-react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/atoms/Tooltip/BaseTooltip";
+import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
+import { cn } from "@/lib/utils";
+
+interface SubAgentUpdateBarProps {
+ currentVersion: number;
+ latestVersion: number;
+ isCompatible: boolean;
+ incompatibilities: IncompatibilityInfo | null;
+ onUpdate: () => void;
+ isInResolutionMode?: boolean;
+}
+
+export const SubAgentUpdateBar: React.FC = ({
+ currentVersion,
+ latestVersion,
+ isCompatible,
+ incompatibilities,
+ onUpdate,
+ isInResolutionMode = false,
+}) => {
+ if (isInResolutionMode) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ Update available (v{currentVersion} → v{latestVersion})
+
+ {!isCompatible && (
+
+
+
+
+
+ Incompatible changes detected
+
+ Click Update to see details
+
+
+
+ )}
+
+
+ Update
+
+
+ );
+};
+
+interface ResolutionModeBarProps {
+ incompatibilities: IncompatibilityInfo | null;
+}
+
+const ResolutionModeBar: React.FC = ({
+ incompatibilities,
+}) => {
+ const formatIncompatibilities = () => {
+ if (!incompatibilities) return "No incompatibilities";
+
+ const items: string[] = [];
+
+ if (incompatibilities.missingInputs.length > 0) {
+ items.push(
+ `Missing inputs: ${incompatibilities.missingInputs.join(", ")}`,
+ );
+ }
+ if (incompatibilities.missingOutputs.length > 0) {
+ items.push(
+ `Missing outputs: ${incompatibilities.missingOutputs.join(", ")}`,
+ );
+ }
+ if (incompatibilities.newRequiredInputs.length > 0) {
+ items.push(
+ `New required inputs: ${incompatibilities.newRequiredInputs.join(", ")}`,
+ );
+ }
+ if (incompatibilities.inputTypeMismatches.length > 0) {
+ const mismatches = incompatibilities.inputTypeMismatches
+ .map((m) => `${m.name} (${m.oldType} → ${m.newType})`)
+ .join(", ");
+ items.push(`Type changed: ${mismatches}`);
+ }
+
+ return items.join("\n");
+ };
+
+ return (
+
+
+
+
+ Remove incompatible connections
+
+
+
+
+
+
+ Incompatible changes:
+ {formatIncompatibilities()}
+
+ Delete the red connections to continue
+
+
+
+
+
+ );
+};
+
+export default SubAgentUpdateBar;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/ExpandableOutputDialog.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/ExpandableOutputDialog.tsx
index 98edbca2fb..1ccb3d1261 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/ExpandableOutputDialog.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/ExpandableOutputDialog.tsx
@@ -1,9 +1,9 @@
-import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
+import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
import {
globalRegistry,
OutputActions,
OutputItem,
-} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
+} from "@/components/contextual/OutputRenderers";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { beautifyString } from "@/lib/utils";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/Flow/Flow.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/Flow/Flow.tsx
index 80a6fb022d..a54a9ef386 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/Flow/Flow.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/Flow/Flow.tsx
@@ -26,15 +26,17 @@ import {
applyNodeChanges,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
-import { CustomNode } from "../CustomNode/CustomNode";
+import { ConnectedEdge, CustomNode } from "../CustomNode/CustomNode";
import "./flow.css";
import {
BlockUIType,
formatEdgeID,
GraphExecutionID,
GraphID,
+ GraphMeta,
LibraryAgent,
} from "@/lib/autogpt-server-api";
+import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
import { Key, storage } from "@/services/storage/local-storage";
import { findNewlyAddedBlockCoordinates, getTypeColor } from "@/lib/utils";
import { history } from "../history";
@@ -72,12 +74,30 @@ import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block
const MINIMUM_MOVE_BEFORE_LOG = 50;
+export type ResolutionModeState = {
+ active: boolean;
+ nodeId: string | null;
+ incompatibilities: IncompatibilityInfo | null;
+ brokenEdgeIds: string[];
+ pendingUpdate: Record | null; // The hardcoded values to apply after resolution
+};
+
type BuilderContextType = {
libraryAgent: LibraryAgent | null;
visualizeBeads: "no" | "static" | "animate";
setIsAnyModalOpen: (isOpen: boolean) => void;
getNextNodeId: () => string;
getNodeTitle: (nodeID: string) => string | null;
+ availableFlows: GraphMeta[];
+ resolutionMode: ResolutionModeState;
+ enterResolutionMode: (
+ nodeId: string,
+ incompatibilities: IncompatibilityInfo,
+ brokenEdgeIds: string[],
+ pendingUpdate: Record,
+ ) => void;
+ exitResolutionMode: () => void;
+ applyPendingUpdate: () => void;
};
export type NodeDimension = {
@@ -172,6 +192,92 @@ const FlowEditor: React.FC<{
// It stores the dimension of all nodes with position as well
const [nodeDimensions, setNodeDimensions] = useState({});
+ // Resolution mode state for sub-agent incompatible updates
+ const [resolutionMode, setResolutionMode] = useState({
+ active: false,
+ nodeId: null,
+ incompatibilities: null,
+ brokenEdgeIds: [],
+ pendingUpdate: null,
+ });
+
+ const enterResolutionMode = useCallback(
+ (
+ nodeId: string,
+ incompatibilities: IncompatibilityInfo,
+ brokenEdgeIds: string[],
+ pendingUpdate: Record,
+ ) => {
+ setResolutionMode({
+ active: true,
+ nodeId,
+ incompatibilities,
+ brokenEdgeIds,
+ pendingUpdate,
+ });
+ },
+ [],
+ );
+
+ const exitResolutionMode = useCallback(() => {
+ setResolutionMode({
+ active: false,
+ nodeId: null,
+ incompatibilities: null,
+ brokenEdgeIds: [],
+ pendingUpdate: null,
+ });
+ }, []);
+
+ // Apply pending update after resolution mode completes
+ const applyPendingUpdate = useCallback(() => {
+ if (!resolutionMode.nodeId || !resolutionMode.pendingUpdate) return;
+
+ const node = nodes.find((n) => n.id === resolutionMode.nodeId);
+ if (node) {
+ const pendingUpdate = resolutionMode.pendingUpdate as {
+ [key: string]: any;
+ };
+ setNodes((nds) =>
+ nds.map((n) =>
+ n.id === resolutionMode.nodeId
+ ? { ...n, data: { ...n.data, hardcodedValues: pendingUpdate } }
+ : n,
+ ),
+ );
+ }
+ exitResolutionMode();
+ toast({
+ title: "Update complete",
+ description: "Agent has been updated to the new version.",
+ });
+ }, [resolutionMode, nodes, setNodes, exitResolutionMode, toast]);
+
+ // Check if all broken edges have been removed and auto-apply pending update
+ useEffect(() => {
+ if (!resolutionMode.active || resolutionMode.brokenEdgeIds.length === 0) {
+ return;
+ }
+
+ const currentEdgeIds = new Set(edges.map((e) => e.id));
+ const remainingBrokenEdges = resolutionMode.brokenEdgeIds.filter((id) =>
+ currentEdgeIds.has(id),
+ );
+
+ if (remainingBrokenEdges.length === 0) {
+ // All broken edges have been removed, apply pending update
+ applyPendingUpdate();
+ } else if (
+ remainingBrokenEdges.length !== resolutionMode.brokenEdgeIds.length
+ ) {
+ // Update the list of broken edges
+ setResolutionMode((prev) => ({
+ ...prev,
+ brokenEdgeIds: remainingBrokenEdges,
+ }));
+ }
+ }, [edges, resolutionMode, applyPendingUpdate]);
+
// Set page title with or without graph name
useEffect(() => {
document.title = savedAgent
@@ -431,17 +537,19 @@ const FlowEditor: React.FC<{
...node.data.connections.filter(
(conn) =>
!removedEdges.some(
- (removedEdge) => removedEdge.id === conn.edge_id,
+ (removedEdge) => removedEdge.id === conn.id,
),
),
// Add node connections for added edges
- ...addedEdges.map((addedEdge) => ({
- edge_id: addedEdge.item.id,
- source: addedEdge.item.source,
- target: addedEdge.item.target,
- sourceHandle: addedEdge.item.sourceHandle!,
- targetHandle: addedEdge.item.targetHandle!,
- })),
+ ...addedEdges.map(
+ (addedEdge): ConnectedEdge => ({
+ id: addedEdge.item.id,
+ source: addedEdge.item.source,
+ target: addedEdge.item.target,
+ sourceHandle: addedEdge.item.sourceHandle!,
+ targetHandle: addedEdge.item.targetHandle!,
+ }),
+ ),
],
},
}));
@@ -467,13 +575,15 @@ const FlowEditor: React.FC<{
data: {
...node.data,
connections: [
- ...replaceEdges.map((replaceEdge) => ({
- edge_id: replaceEdge.item.id,
- source: replaceEdge.item.source,
- target: replaceEdge.item.target,
- sourceHandle: replaceEdge.item.sourceHandle!,
- targetHandle: replaceEdge.item.targetHandle!,
- })),
+ ...replaceEdges.map(
+ (replaceEdge): ConnectedEdge => ({
+ id: replaceEdge.item.id,
+ source: replaceEdge.item.source,
+ target: replaceEdge.item.target,
+ sourceHandle: replaceEdge.item.sourceHandle!,
+ targetHandle: replaceEdge.item.targetHandle!,
+ }),
+ ),
],
},
})),
@@ -890,8 +1000,23 @@ const FlowEditor: React.FC<{
setIsAnyModalOpen,
getNextNodeId,
getNodeTitle,
+ availableFlows,
+ resolutionMode,
+ enterResolutionMode,
+ exitResolutionMode,
+ applyPendingUpdate,
}),
- [libraryAgent, visualizeBeads, getNextNodeId, getNodeTitle],
+ [
+ libraryAgent,
+ visualizeBeads,
+ getNextNodeId,
+ getNodeTitle,
+ availableFlows,
+ resolutionMode,
+ enterResolutionMode,
+ applyPendingUpdate,
+ exitResolutionMode,
+ ],
);
return (
@@ -991,6 +1116,7 @@ const FlowEditor: React.FC<{
onClickScheduleButton={handleScheduleButton}
isDisabled={!savedAgent}
isRunning={isRunning}
+ resolutionModeActive={resolutionMode.active}
/>
) : (
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeHandle.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeHandle.tsx
index 1377df62a0..932e50c499 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeHandle.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeHandle.tsx
@@ -1,6 +1,11 @@
import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types";
-import { cn } from "@/lib/utils";
-import { beautifyString, getTypeBgColor, getTypeTextColor } from "@/lib/utils";
+import {
+ cn,
+ beautifyString,
+ getTypeBgColor,
+ getTypeTextColor,
+ getEffectiveType,
+} from "@/lib/utils";
import { FC, memo, useCallback } from "react";
import { Handle, Position } from "@xyflow/react";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
@@ -13,6 +18,7 @@ type HandleProps = {
side: "left" | "right";
title?: string;
className?: string;
+ isBroken?: boolean;
};
// Move the constant out of the component to avoid re-creation on every render.
@@ -27,18 +33,23 @@ const TYPE_NAME: Record = {
};
// Extract and memoize the Dot component so that it doesn't re-render unnecessarily.
-const Dot: FC<{ isConnected: boolean; type?: string }> = memo(
- ({ isConnected, type }) => {
- const color = isConnected
- ? getTypeBgColor(type || "any")
- : "border-gray-300 dark:border-gray-600";
+const Dot: FC<{ isConnected: boolean; type?: string; isBroken?: boolean }> =
+ memo(({ isConnected, type, isBroken }) => {
+ const color = isBroken
+ ? "border-red-500 bg-red-100 dark:bg-red-900/30"
+ : isConnected
+ ? getTypeBgColor(type || "any")
+ : "border-gray-300 dark:border-gray-600";
return (
);
- },
-);
+ });
Dot.displayName = "Dot";
const NodeHandle: FC = ({
@@ -49,24 +60,34 @@ const NodeHandle: FC = ({
side,
title,
className,
+ isBroken = false,
}) => {
- const typeClass = `text-sm ${getTypeTextColor(schema.type || "any")} ${
+ // Extract effective type from schema (handles anyOf/oneOf/allOf wrappers)
+ const effectiveType = getEffectiveType(schema);
+
+ const typeClass = `text-sm ${getTypeTextColor(effectiveType || "any")} ${
side === "left" ? "text-left" : "text-right"
}`;
const label = (
-
+
{title || schema.title || beautifyString(keyName.toLowerCase())}
{isRequired ? "*" : ""}
-
- ({TYPE_NAME[schema.type as keyof typeof TYPE_NAME] || "any"})
+
+ ({TYPE_NAME[effectiveType as keyof typeof TYPE_NAME] || "any"})
);
@@ -84,7 +105,7 @@ const NodeHandle: FC
= ({
return (
= ({
data-testid={`input-handle-${keyName}`}
position={Position.Left}
id={keyName}
- className="group -ml-[38px]"
+ className={cn("group -ml-[38px]", isBroken && "cursor-not-allowed")}
+ isConnectable={!isBroken}
>
-
+
{label}
@@ -106,7 +132,10 @@ const NodeHandle: FC
= ({
return (
= ({
data-testid={`output-handle-${keyName}`}
position={Position.Right}
id={keyName}
- className="group -mr-[38px]"
+ className={cn("group -mr-[38px]", isBroken && "cursor-not-allowed")}
+ isConnectable={!isBroken}
>
{label}
-
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx
index e9d077bde1..51fed5bef1 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx
@@ -1,9 +1,8 @@
import {
- ConnectionData,
+ ConnectedEdge,
CustomNodeData,
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput";
-import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { Button } from "@/components/__legacy__/ui/button";
import { Calendar } from "@/components/__legacy__/ui/calendar";
import { LocalValuedInput } from "@/components/__legacy__/ui/input";
@@ -28,6 +27,7 @@ import {
SelectValue,
} from "@/components/__legacy__/ui/select";
import { Switch } from "@/components/atoms/Switch/Switch";
+import { CredentialsInput } from "@/components/contextual/CredentialsInput/CredentialsInput";
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
import {
BlockIOArraySubSchema,
@@ -65,7 +65,7 @@ type NodeObjectInputTreeProps = {
selfKey?: string;
schema: BlockIORootSchema | BlockIOObjectSubSchema;
object?: { [key: string]: any };
- connections: ConnectionData;
+ connections: ConnectedEdge[];
handleInputClick: (key: string) => void;
handleInputChange: (key: string, value: any) => void;
errors: { [key: string]: string | undefined };
@@ -585,7 +585,7 @@ const NodeOneOfDiscriminatorField: FC<{
currentValue?: any;
defaultValue?: any;
errors: { [key: string]: string | undefined };
- connections: ConnectionData;
+ connections: ConnectedEdge[];
handleInputChange: (key: string, value: any) => void;
handleInputClick: (key: string) => void;
className?: string;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeTableInput.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeTableInput.tsx
index 0e27d98ba2..07a5b38520 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeTableInput.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeTableInput.tsx
@@ -1,15 +1,16 @@
import { FC, useCallback, useEffect, useState } from "react";
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
-import {
+import type {
BlockIOTableSubSchema,
TableCellValue,
TableRow,
} from "@/lib/autogpt-server-api/types";
+import type { ConnectedEdge } from "./CustomNode/CustomNode";
import { cn } from "@/lib/utils";
import { PlusIcon, XIcon } from "@phosphor-icons/react";
-import { Button } from "../../../../../components/atoms/Button/Button";
-import { Input } from "../../../../../components/atoms/Input/Input";
+import { Button } from "@/components/atoms/Button/Button";
+import { Input } from "@/components/atoms/Input/Input";
interface NodeTableInputProps {
/** Unique identifier for the node in the builder graph */
@@ -25,13 +26,7 @@ interface NodeTableInputProps {
/** Validation errors mapped by field key */
errors: { [key: string]: string | undefined };
/** Graph connections between nodes in the builder */
- connections: {
- edge_id: string;
- source: string;
- sourceHandle: string;
- target: string;
- targetHandle: string;
- }[];
+ connections: ConnectedEdge[];
/** Callback when table data changes */
handleInputChange: (key: string, value: TableRow[]) => void;
/** Callback when input field is clicked (for builder selection) */
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/tutorial.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/tutorial.ts
index 7adcfd9c1e..418d18782a 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/tutorial.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/tutorial.ts
@@ -328,16 +328,16 @@ export const startTutorial = (
title: "Press Run",
text: "Start your first flow by pressing the Run button!",
attachTo: {
- element: '[data-testid="primary-action-run-agent"]',
+ element: '[data-tutorial-id="primary-action-run-agent"]',
on: "top",
},
advanceOn: {
- selector: '[data-testid="primary-action-run-agent"]',
+ selector: '[data-tutorial-id="primary-action-run-agent"]',
event: "click",
},
buttons: [],
beforeShowPromise: () =>
- waitForElement('[data-testid="primary-action-run-agent"]'),
+ waitForElement('[data-tutorial-id="primary-action-run-agent"]'),
when: {
hide: () => {
setTimeout(() => {
@@ -508,16 +508,16 @@ export const startTutorial = (
title: "Press Run Again",
text: "Now, press the Run button again to execute the flow with the new Calculator Block added!",
attachTo: {
- element: '[data-testid="primary-action-run-agent"]',
+ element: '[data-tutorial-id="primary-action-run-agent"]',
on: "top",
},
advanceOn: {
- selector: '[data-testid="primary-action-run-agent"]',
+ selector: '[data-tutorial-id="primary-action-run-agent"]',
event: "click",
},
buttons: [],
beforeShowPromise: () =>
- waitForElement('[data-testid="primary-action-run-agent"]'),
+ waitForElement('[data-tutorial-id="primary-action-run-agent"]'),
when: {
hide: () => {
setTimeout(() => {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/useCopyPaste.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/useCopyPaste.ts
index a4f604506f..13628441fa 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/useCopyPaste.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/useCopyPaste.ts
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { Node, Edge, useReactFlow } from "@xyflow/react";
import { Key, storage } from "@/services/storage/local-storage";
+import { ConnectedEdge } from "./CustomNode/CustomNode";
interface CopyableData {
nodes: Node[];
@@ -111,13 +112,15 @@ export function useCopyPaste(getNextNodeId: () => string) {
(edge: Edge) =>
edge.source === node.id || edge.target === node.id,
)
- .map((edge: Edge) => ({
- edge_id: edge.id,
- source: edge.source,
- target: edge.target,
- sourceHandle: edge.sourceHandle,
- targetHandle: edge.targetHandle,
- }));
+ .map(
+ (edge: Edge): ConnectedEdge => ({
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ sourceHandle: edge.sourceHandle!,
+ targetHandle: edge.targetHandle!,
+ }),
+ );
return {
...node,
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/helpers.ts
new file mode 100644
index 0000000000..aece7e9811
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/helpers.ts
@@ -0,0 +1,104 @@
+import { GraphInputSchema } from "@/lib/autogpt-server-api";
+import { GraphMetaLike, IncompatibilityInfo } from "./types";
+
+// Helper type for schema properties - the generated types are too loose
+type SchemaProperties = Record;
+type SchemaRequired = string[];
+
+// Helper to safely extract schema properties
+export function getSchemaProperties(schema: unknown): SchemaProperties {
+ if (
+ schema &&
+ typeof schema === "object" &&
+ "properties" in schema &&
+ typeof schema.properties === "object" &&
+ schema.properties !== null
+ ) {
+ return schema.properties as SchemaProperties;
+ }
+ return {};
+}
+
+export function getSchemaRequired(schema: unknown): SchemaRequired {
+ if (
+ schema &&
+ typeof schema === "object" &&
+ "required" in schema &&
+ Array.isArray(schema.required)
+ ) {
+ return schema.required as SchemaRequired;
+ }
+ return [];
+}
+
+/**
+ * Creates the updated agent node inputs for a sub-agent node
+ */
+export function createUpdatedAgentNodeInputs(
+ currentInputs: Record,
+ latestSubGraphVersion: GraphMetaLike,
+): Record {
+ return {
+ ...currentInputs,
+ graph_version: latestSubGraphVersion.version,
+ input_schema: latestSubGraphVersion.input_schema,
+ output_schema: latestSubGraphVersion.output_schema,
+ };
+}
+
+/** Generic edge type that works with both builders:
+ * - New builder uses CustomEdge with (formally) optional handles
+ * - Legacy builder uses ConnectedEdge type with required handles */
+export type EdgeLike = {
+ id: string;
+ source: string;
+ target: string;
+ sourceHandle?: string | null;
+ targetHandle?: string | null;
+};
+
+/**
+ * Determines which edges are broken after an incompatible update.
+ * Works with both legacy ConnectedEdge and new CustomEdge.
+ */
+export function getBrokenEdgeIDs(
+ connections: EdgeLike[],
+ incompatibilities: IncompatibilityInfo,
+ nodeID: string,
+): string[] {
+ const brokenEdgeIDs: string[] = [];
+ const typeMismatchInputNames = new Set(
+ incompatibilities.inputTypeMismatches.map((m) => m.name),
+ );
+
+ connections.forEach((conn) => {
+ // Check if this connection uses a missing input (node is target)
+ if (
+ conn.target === nodeID &&
+ conn.targetHandle &&
+ incompatibilities.missingInputs.includes(conn.targetHandle)
+ ) {
+ brokenEdgeIDs.push(conn.id);
+ }
+
+ // Check if this connection uses an input with a type mismatch (node is target)
+ if (
+ conn.target === nodeID &&
+ conn.targetHandle &&
+ typeMismatchInputNames.has(conn.targetHandle)
+ ) {
+ brokenEdgeIDs.push(conn.id);
+ }
+
+ // Check if this connection uses a missing output (node is source)
+ if (
+ conn.source === nodeID &&
+ conn.sourceHandle &&
+ incompatibilities.missingOutputs.includes(conn.sourceHandle)
+ ) {
+ brokenEdgeIDs.push(conn.id);
+ }
+ });
+
+ return brokenEdgeIDs;
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/index.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/index.ts
new file mode 100644
index 0000000000..7dd1ffa3b0
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/index.ts
@@ -0,0 +1,2 @@
+export { useSubAgentUpdate } from "./useSubAgentUpdate";
+export { createUpdatedAgentNodeInputs, getBrokenEdgeIDs } from "./helpers";
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/types.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/types.ts
new file mode 100644
index 0000000000..83f83155db
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/types.ts
@@ -0,0 +1,27 @@
+import type { GraphMeta as LegacyGraphMeta } from "@/lib/autogpt-server-api";
+import type { GraphMeta as GeneratedGraphMeta } from "@/app/api/__generated__/models/graphMeta";
+
+export type SubAgentUpdateInfo = {
+ hasUpdate: boolean;
+ currentVersion: number;
+ latestVersion: number;
+ latestGraph: T | null;
+ isCompatible: boolean;
+ incompatibilities: IncompatibilityInfo | null;
+};
+
+// Union type for GraphMeta that works with both legacy and new builder
+export type GraphMetaLike = LegacyGraphMeta | GeneratedGraphMeta;
+
+export type IncompatibilityInfo = {
+ missingInputs: string[]; // Connected inputs that no longer exist
+ missingOutputs: string[]; // Connected outputs that no longer exist
+ newInputs: string[]; // Inputs that exist in new version but not in current
+ newOutputs: string[]; // Outputs that exist in new version but not in current
+ newRequiredInputs: string[]; // New required inputs not in current version or not required
+ inputTypeMismatches: Array<{
+ name: string;
+ oldType: string;
+ newType: string;
+ }>; // Connected inputs where the type has changed
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/useSubAgentUpdate.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/useSubAgentUpdate.ts
new file mode 100644
index 0000000000..315e337cd6
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/useSubAgentUpdate.ts
@@ -0,0 +1,160 @@
+import { useMemo } from "react";
+import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
+import { getEffectiveType } from "@/lib/utils";
+import { EdgeLike, getSchemaProperties, getSchemaRequired } from "./helpers";
+import {
+ GraphMetaLike,
+ IncompatibilityInfo,
+ SubAgentUpdateInfo,
+} from "./types";
+
+/**
+ * Checks if a newer version of a sub-agent is available and determines compatibility
+ */
+export function useSubAgentUpdate(
+ nodeID: string,
+ graphID: string | undefined,
+ graphVersion: number | undefined,
+ currentInputSchema: GraphInputSchema | undefined,
+ currentOutputSchema: GraphOutputSchema | undefined,
+ connections: EdgeLike[],
+ availableGraphs: T[],
+): SubAgentUpdateInfo {
+ // Find the latest version of the same graph
+ const latestGraph = useMemo(() => {
+ if (!graphID) return null;
+ return availableGraphs.find((graph) => graph.id === graphID) || null;
+ }, [graphID, availableGraphs]);
+
+ // Check if there's an update available
+ const hasUpdate = useMemo(() => {
+ if (!latestGraph || graphVersion === undefined) return false;
+ return latestGraph.version! > graphVersion;
+ }, [latestGraph, graphVersion]);
+
+ // Get connected input and output handles for this specific node
+ const connectedHandles = useMemo(() => {
+ const inputHandles = new Set();
+ const outputHandles = new Set();
+
+ connections.forEach((conn) => {
+ // If this node is the target, the targetHandle is an input on this node
+ if (conn.target === nodeID && conn.targetHandle) {
+ inputHandles.add(conn.targetHandle);
+ }
+ // If this node is the source, the sourceHandle is an output on this node
+ if (conn.source === nodeID && conn.sourceHandle) {
+ outputHandles.add(conn.sourceHandle);
+ }
+ });
+
+ return { inputHandles, outputHandles };
+ }, [connections, nodeID]);
+
+ // Check schema compatibility
+ const compatibilityResult = useMemo((): {
+ isCompatible: boolean;
+ incompatibilities: IncompatibilityInfo | null;
+ } => {
+ if (!hasUpdate || !latestGraph) {
+ return { isCompatible: true, incompatibilities: null };
+ }
+
+ const newInputProps = getSchemaProperties(latestGraph.input_schema);
+ const newOutputProps = getSchemaProperties(latestGraph.output_schema);
+ const newRequiredInputs = getSchemaRequired(latestGraph.input_schema);
+
+ const currentInputProps = getSchemaProperties(currentInputSchema);
+ const currentOutputProps = getSchemaProperties(currentOutputSchema);
+ const currentRequiredInputs = getSchemaRequired(currentInputSchema);
+
+ const incompatibilities: IncompatibilityInfo = {
+ missingInputs: [],
+ missingOutputs: [],
+ newInputs: [],
+ newOutputs: [],
+ newRequiredInputs: [],
+ inputTypeMismatches: [],
+ };
+
+ // Check for missing connected inputs and type mismatches
+ connectedHandles.inputHandles.forEach((inputHandle) => {
+ if (!(inputHandle in newInputProps)) {
+ incompatibilities.missingInputs.push(inputHandle);
+ } else {
+ // Check for type mismatch on connected inputs
+ const currentProp = currentInputProps[inputHandle];
+ const newProp = newInputProps[inputHandle];
+ const currentType = getEffectiveType(currentProp);
+ const newType = getEffectiveType(newProp);
+
+ if (currentType && newType && currentType !== newType) {
+ incompatibilities.inputTypeMismatches.push({
+ name: inputHandle,
+ oldType: currentType,
+ newType: newType,
+ });
+ }
+ }
+ });
+
+ // Check for missing connected outputs
+ connectedHandles.outputHandles.forEach((outputHandle) => {
+ if (!(outputHandle in newOutputProps)) {
+ incompatibilities.missingOutputs.push(outputHandle);
+ }
+ });
+
+ // Check for new required inputs that didn't exist or weren't required before
+ newRequiredInputs.forEach((requiredInput) => {
+ const existedBefore = requiredInput in currentInputProps;
+ const wasRequiredBefore = currentRequiredInputs.includes(
+ requiredInput as string,
+ );
+
+ if (!existedBefore || !wasRequiredBefore) {
+ incompatibilities.newRequiredInputs.push(requiredInput as string);
+ }
+ });
+
+ // Check for new inputs that don't exist in the current version
+ Object.keys(newInputProps).forEach((inputName) => {
+ if (!(inputName in currentInputProps)) {
+ incompatibilities.newInputs.push(inputName);
+ }
+ });
+
+ // Check for new outputs that don't exist in the current version
+ Object.keys(newOutputProps).forEach((outputName) => {
+ if (!(outputName in currentOutputProps)) {
+ incompatibilities.newOutputs.push(outputName);
+ }
+ });
+
+ const hasIncompatibilities =
+ incompatibilities.missingInputs.length > 0 ||
+ incompatibilities.missingOutputs.length > 0 ||
+ incompatibilities.newRequiredInputs.length > 0 ||
+ incompatibilities.inputTypeMismatches.length > 0;
+
+ return {
+ isCompatible: !hasIncompatibilities,
+ incompatibilities: hasIncompatibilities ? incompatibilities : null,
+ };
+ }, [
+ hasUpdate,
+ latestGraph,
+ currentInputSchema,
+ currentOutputSchema,
+ connectedHandles,
+ ]);
+
+ return {
+ hasUpdate,
+ currentVersion: graphVersion || 0,
+ latestVersion: latestGraph?.version || 0,
+ latestGraph,
+ isCompatible: compatibilityResult.isCompatible,
+ incompatibilities: compatibilityResult.incompatibilities,
+ };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/blockMenuStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/blockMenuStore.ts
index ea50a03979..31b9eda338 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/blockMenuStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/blockMenuStore.ts
@@ -1,12 +1,30 @@
import { create } from "zustand";
import { DefaultStateType } from "../components/NewControlPanel/NewBlockMenu/types";
+import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
+import { getSearchItemType } from "../components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/helper";
+import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
+import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
type BlockMenuStore = {
searchQuery: string;
searchId: string | undefined;
defaultState: DefaultStateType;
integration: string | undefined;
+ filters: GetV2BuilderSearchFilterAnyOfItem[];
+ creators: string[];
+ creators_list: string[];
+ categoryCounts: Record;
+ setCategoryCounts: (
+ counts: Record,
+ ) => void;
+ setCreatorsList: (searchData: SearchResponseItemsItem[]) => void;
+ addCreator: (creator: string) => void;
+ setCreators: (creators: string[]) => void;
+ removeCreator: (creator: string) => void;
+ addFilter: (filter: GetV2BuilderSearchFilterAnyOfItem) => void;
+ setFilters: (filters: GetV2BuilderSearchFilterAnyOfItem[]) => void;
+ removeFilter: (filter: GetV2BuilderSearchFilterAnyOfItem) => void;
setSearchQuery: (query: string) => void;
setSearchId: (id: string | undefined) => void;
setDefaultState: (state: DefaultStateType) => void;
@@ -19,11 +37,44 @@ export const useBlockMenuStore = create((set) => ({
searchId: undefined,
defaultState: DefaultStateType.SUGGESTION,
integration: undefined,
+ filters: [],
+ creators: [], // creator filters that are applied to the search results
+ creators_list: [], // all creators that are available to filter by
+ categoryCounts: {
+ blocks: 0,
+ integrations: 0,
+ marketplace_agents: 0,
+ my_agents: 0,
+ },
+ setCategoryCounts: (counts) => set({ categoryCounts: counts }),
+ setCreatorsList: (searchData) => {
+ const marketplaceAgents = searchData.filter((item) => {
+ return getSearchItemType(item).type === "store_agent";
+ }) as StoreAgent[];
+
+ const newCreators = marketplaceAgents.map((agent) => agent.creator);
+
+ set((state) => ({
+ creators_list: Array.from(
+ new Set([...state.creators_list, ...newCreators]),
+ ),
+ }));
+ },
+ setCreators: (creators) => set({ creators }),
+ setFilters: (filters) => set({ filters }),
setSearchQuery: (query) => set({ searchQuery: query }),
setSearchId: (id) => set({ searchId: id }),
setDefaultState: (state) => set({ defaultState: state }),
setIntegration: (integration) => set({ integration }),
+ addFilter: (filter) =>
+ set((state) => ({ filters: [...state.filters, filter] })),
+ removeFilter: (filter) =>
+ set((state) => ({ filters: state.filters.filter((f) => f !== filter) })),
+ addCreator: (creator) =>
+ set((state) => ({ creators: [...state.creators, creator] })),
+ removeCreator: (creator) =>
+ set((state) => ({ creators: state.creators.filter((c) => c !== creator) })),
reset: () =>
set({
searchQuery: "",
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/controlPanelStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/controlPanelStore.ts
index 6847d769bd..5dcb11c121 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/controlPanelStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/controlPanelStore.ts
@@ -3,20 +3,32 @@ import { create } from "zustand";
type ControlPanelStore = {
blockMenuOpen: boolean;
saveControlOpen: boolean;
+ forceOpenBlockMenu: boolean;
+ forceOpenSave: boolean;
+
setBlockMenuOpen: (open: boolean) => void;
setSaveControlOpen: (open: boolean) => void;
+ setForceOpenBlockMenu: (force: boolean) => void;
+ setForceOpenSave: (force: boolean) => void;
+
reset: () => void;
};
export const useControlPanelStore = create((set) => ({
blockMenuOpen: false,
saveControlOpen: false,
+ forceOpenBlockMenu: false,
+ forceOpenSave: false,
+ setForceOpenBlockMenu: (force) => set({ forceOpenBlockMenu: force }),
+ setForceOpenSave: (force) => set({ forceOpenSave: force }),
setBlockMenuOpen: (open) => set({ blockMenuOpen: open }),
setSaveControlOpen: (open) => set({ saveControlOpen: open }),
reset: () =>
set({
blockMenuOpen: false,
saveControlOpen: false,
+ forceOpenBlockMenu: false,
+ forceOpenSave: false,
}),
}));
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts
index 7b17eecfb3..6a45b9e1e2 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts
@@ -5,6 +5,8 @@ import { customEdgeToLink, linkToCustomEdge } from "../components/helper";
import { MarkerType } from "@xyflow/react";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
+import { useHistoryStore } from "./historyStore";
+import { useNodeStore } from "./nodeStore";
type EdgeStore = {
edges: CustomEdge[];
@@ -53,25 +55,36 @@ export const useEdgeStore = create((set, get) => ({
id,
};
- set((state) => {
- const exists = state.edges.some(
- (e) =>
- e.source === newEdge.source &&
- e.target === newEdge.target &&
- e.sourceHandle === newEdge.sourceHandle &&
- e.targetHandle === newEdge.targetHandle,
- );
- if (exists) return state;
- return { edges: [...state.edges, newEdge] };
- });
+ const exists = get().edges.some(
+ (e) =>
+ e.source === newEdge.source &&
+ e.target === newEdge.target &&
+ e.sourceHandle === newEdge.sourceHandle &&
+ e.targetHandle === newEdge.targetHandle,
+ );
+ if (exists) return newEdge;
+ const prevState = {
+ nodes: useNodeStore.getState().nodes,
+ edges: get().edges,
+ };
+
+ set((state) => ({ edges: [...state.edges, newEdge] }));
+ useHistoryStore.getState().pushState(prevState);
return newEdge;
},
- removeEdge: (edgeId) =>
+ removeEdge: (edgeId) => {
+ const prevState = {
+ nodes: useNodeStore.getState().nodes,
+ edges: get().edges,
+ };
+
set((state) => ({
edges: state.edges.filter((e) => e.id !== edgeId),
- })),
+ }));
+ useHistoryStore.getState().pushState(prevState);
+ },
upsertMany: (edges) =>
set((state) => {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/graphStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/graphStore.ts
index 44142881a8..6961884732 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/graphStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/graphStore.ts
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
+import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
interface GraphStore {
graphExecutionStatus: AgentExecutionStatus | undefined;
@@ -17,6 +18,10 @@ interface GraphStore {
outputSchema: Record | null,
) => void;
+ // Available graphs; used for sub-graph updates
+ availableSubGraphs: GraphMeta[];
+ setAvailableSubGraphs: (graphs: GraphMeta[]) => void;
+
hasInputs: () => boolean;
hasCredentials: () => boolean;
hasOutputs: () => boolean;
@@ -29,6 +34,7 @@ export const useGraphStore = create((set, get) => ({
inputSchema: null,
credentialsInputSchema: null,
outputSchema: null,
+ availableSubGraphs: [],
setGraphExecutionStatus: (status: AgentExecutionStatus | undefined) => {
set({
@@ -46,6 +52,8 @@ export const useGraphStore = create((set, get) => ({
setGraphSchemas: (inputSchema, credentialsInputSchema, outputSchema) =>
set({ inputSchema, credentialsInputSchema, outputSchema }),
+ setAvailableSubGraphs: (graphs) => set({ availableSubGraphs: graphs }),
+
hasOutputs: () => {
const { outputSchema } = get();
return Object.keys(outputSchema?.properties ?? {}).length > 0;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/helpers.ts
new file mode 100644
index 0000000000..bcdfd4c313
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/helpers.ts
@@ -0,0 +1,16 @@
+export const accumulateExecutionData = (
+ accumulated: Record,
+ data: Record | undefined,
+) => {
+ if (!data) return { ...accumulated };
+ const next = { ...accumulated };
+ Object.entries(data).forEach(([key, values]) => {
+ const nextValues = Array.isArray(values) ? values : [values];
+ if (next[key]) {
+ next[key] = [...next[key], ...nextValues];
+ } else {
+ next[key] = [...nextValues];
+ }
+ });
+ return next;
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/historyStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/historyStore.ts
index 4eea5741a4..3a67bb8dcd 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/historyStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/historyStore.ts
@@ -37,6 +37,15 @@ export const useHistoryStore = create((set, get) => ({
return;
}
+ const actualCurrentState = {
+ nodes: useNodeStore.getState().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+
+ if (isEqual(state, actualCurrentState)) {
+ return;
+ }
+
set((prev) => ({
past: [...prev.past.slice(-MAX_HISTORY + 1), state],
future: [],
@@ -55,18 +64,25 @@ export const useHistoryStore = create((set, get) => ({
undo: () => {
const { past, future } = get();
- if (past.length <= 1) return;
+ if (past.length === 0) return;
- const currentState = past[past.length - 1];
+ const actualCurrentState = {
+ nodes: useNodeStore.getState().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
- const previousState = past[past.length - 2];
+ const previousState = past[past.length - 1];
+
+ if (isEqual(actualCurrentState, previousState)) {
+ return;
+ }
useNodeStore.getState().setNodes(previousState.nodes);
useEdgeStore.getState().setEdges(previousState.edges);
set({
- past: past.slice(0, -1),
- future: [currentState, ...future],
+ past: past.length > 1 ? past.slice(0, -1) : past,
+ future: [actualCurrentState, ...future],
});
},
@@ -74,18 +90,36 @@ export const useHistoryStore = create((set, get) => ({
const { past, future } = get();
if (future.length === 0) return;
+ const actualCurrentState = {
+ nodes: useNodeStore.getState().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+
const nextState = future[0];
useNodeStore.getState().setNodes(nextState.nodes);
useEdgeStore.getState().setEdges(nextState.edges);
+ const lastPast = past[past.length - 1];
+ const shouldPushToPast =
+ !lastPast || !isEqual(actualCurrentState, lastPast);
+
set({
- past: [...past, nextState],
+ past: shouldPushToPast ? [...past, actualCurrentState] : past,
future: future.slice(1),
});
},
- canUndo: () => get().past.length > 1,
+ canUndo: () => {
+ const { past } = get();
+ if (past.length === 0) return false;
+
+ const actualCurrentState = {
+ nodes: useNodeStore.getState().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+ return !isEqual(actualCurrentState, past[past.length - 1]);
+ },
canRedo: () => get().future.length > 0,
clear: () => set({ past: [{ nodes: [], edges: [] }], future: [] }),
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts
index 96478c5b6f..f7a52636f3 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { NodeChange, XYPosition, applyNodeChanges } from "@xyflow/react";
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
+import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import {
convertBlockInfoIntoCustomNodeData,
@@ -9,6 +10,8 @@ import {
import { Node } from "@/app/api/__generated__/models/node";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
+import { NodeExecutionResultInputData } from "@/app/api/__generated__/models/nodeExecutionResultInputData";
+import { NodeExecutionResultOutputData } from "@/app/api/__generated__/models/nodeExecutionResultOutputData";
import { useHistoryStore } from "./historyStore";
import { useEdgeStore } from "./edgeStore";
import { BlockUIType } from "../components/types";
@@ -17,18 +20,28 @@ import {
ensurePathExists,
parseHandleIdToPath,
} from "@/components/renderers/InputRenderer/helpers";
+import { accumulateExecutionData } from "./helpers";
+import { NodeResolutionData } from "./types";
-// Minimum movement (in pixels) required before logging position change to history
-// Prevents spamming history with small movements when clicking on inputs inside blocks
const MINIMUM_MOVE_BEFORE_LOG = 50;
-
-// Track initial positions when drag starts (outside store to avoid re-renders)
const dragStartPositions: Record = {};
+let dragStartState: { nodes: CustomNode[]; edges: CustomEdge[] } | null = null;
+
type NodeStore = {
nodes: CustomNode[];
nodeCounter: number;
+ setNodeCounter: (nodeCounter: number) => void;
nodeAdvancedStates: Record;
+
+ latestNodeInputData: Record;
+ latestNodeOutputData: Record<
+ string,
+ NodeExecutionResultOutputData | undefined
+ >;
+ accumulatedNodeInputData: Record>;
+ accumulatedNodeOutputData: Record>;
+
setNodes: (nodes: CustomNode[]) => void;
onNodesChange: (changes: NodeChange[]) => void;
addNode: (node: CustomNode) => void;
@@ -49,12 +62,26 @@ type NodeStore = {
updateNodeStatus: (nodeId: string, status: AgentExecutionStatus) => void;
getNodeStatus: (nodeId: string) => AgentExecutionStatus | undefined;
+ cleanNodesStatuses: () => void;
updateNodeExecutionResult: (
nodeId: string,
result: NodeExecutionResult,
) => void;
- getNodeExecutionResult: (nodeId: string) => NodeExecutionResult | undefined;
+ getNodeExecutionResults: (nodeId: string) => NodeExecutionResult[];
+ getLatestNodeInputData: (
+ nodeId: string,
+ ) => NodeExecutionResultInputData | undefined;
+ getLatestNodeOutputData: (
+ nodeId: string,
+ ) => NodeExecutionResultOutputData | undefined;
+ getAccumulatedNodeInputData: (nodeId: string) => Record;
+ getAccumulatedNodeOutputData: (nodeId: string) => Record;
+ getLatestNodeExecutionResult: (
+ nodeId: string,
+ ) => NodeExecutionResult | undefined;
+ clearAllNodeExecutionResults: () => void;
+
getNodeBlockUIType: (nodeId: string) => BlockUIType;
hasWebhookNodes: () => boolean;
@@ -65,29 +92,63 @@ type NodeStore = {
backendId: string,
errors: { [key: string]: string },
) => void;
- clearAllNodeErrors: () => void; // Add this
syncHardcodedValuesWithHandleIds: (nodeId: string) => void;
+
+ setCredentialsOptional: (nodeId: string, optional: boolean) => void;
+ clearAllNodeErrors: () => void;
+
+ nodesInResolutionMode: Set;
+ brokenEdgeIDs: Map>;
+ nodeResolutionData: Map;
+ setNodeResolutionMode: (
+ nodeID: string,
+ inResolution: boolean,
+ resolutionData?: NodeResolutionData,
+ ) => void;
+ isNodeInResolutionMode: (nodeID: string) => boolean;
+ getNodeResolutionData: (nodeID: string) => NodeResolutionData | undefined;
+ setBrokenEdgeIDs: (nodeID: string, edgeIDs: string[]) => void;
+ removeBrokenEdgeID: (nodeID: string, edgeID: string) => void;
+ isEdgeBroken: (edgeID: string) => boolean;
+ clearResolutionState: () => void;
+
+ isInputBroken: (nodeID: string, handleID: string) => boolean;
+ getInputTypeMismatch: (
+ nodeID: string,
+ handleID: string,
+ ) => string | undefined;
};
export const useNodeStore = create((set, get) => ({
nodes: [],
setNodes: (nodes) => set({ nodes }),
nodeCounter: 0,
+ setNodeCounter: (nodeCounter) => set({ nodeCounter }),
nodeAdvancedStates: {},
+ latestNodeInputData: {},
+ latestNodeOutputData: {},
+ accumulatedNodeInputData: {},
+ accumulatedNodeOutputData: {},
incrementNodeCounter: () =>
set((state) => ({
nodeCounter: state.nodeCounter + 1,
})),
onNodesChange: (changes) => {
- const prevState = {
- nodes: get().nodes,
- edges: useEdgeStore.getState().edges,
- };
-
- // Track initial positions when drag starts
changes.forEach((change) => {
if (change.type === "position" && change.dragging === true) {
+ if (!dragStartState) {
+ const currentNodes = get().nodes;
+ const currentEdges = useEdgeStore.getState().edges;
+ dragStartState = {
+ nodes: currentNodes.map((n) => ({
+ ...n,
+ position: { ...n.position },
+ data: { ...n.data },
+ })),
+ edges: currentEdges.map((e) => ({ ...e })),
+ };
+ }
if (!dragStartPositions[change.id]) {
const node = get().nodes.find((n) => n.id === change.id);
if (node) {
@@ -97,12 +158,17 @@ export const useNodeStore = create((set, get) => ({
}
});
- // Check if we should track this change in history
- let shouldTrack = changes.some(
- (change) => change.type === "remove" || change.type === "add",
- );
+ let shouldTrack = changes.some((change) => change.type === "remove");
+ let stateToTrack: { nodes: CustomNode[]; edges: CustomEdge[] } | null =
+ null;
+
+ if (shouldTrack) {
+ stateToTrack = {
+ nodes: get().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+ }
- // For position changes, only track if movement exceeds threshold
if (!shouldTrack) {
changes.forEach((change) => {
if (change.type === "position" && change.dragging === false) {
@@ -114,20 +180,23 @@ export const useNodeStore = create((set, get) => ({
);
if (distanceMoved > MINIMUM_MOVE_BEFORE_LOG) {
shouldTrack = true;
+ stateToTrack = dragStartState;
}
}
- // Clean up tracked position after drag ends
delete dragStartPositions[change.id];
}
});
+ if (Object.keys(dragStartPositions).length === 0) {
+ dragStartState = null;
+ }
}
set((state) => ({
nodes: applyNodeChanges(changes, state.nodes),
}));
- if (shouldTrack) {
- useHistoryStore.getState().pushState(prevState);
+ if (shouldTrack && stateToTrack) {
+ useHistoryStore.getState().pushState(stateToTrack);
}
},
@@ -141,6 +210,11 @@ export const useNodeStore = create((set, get) => ({
hardcodedValues?: Record,
position?: XYPosition,
) => {
+ const prevState = {
+ nodes: get().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+
const customNodeData = convertBlockInfoIntoCustomNodeData(
block,
hardcodedValues,
@@ -174,21 +248,24 @@ export const useNodeStore = create((set, get) => ({
set((state) => ({
nodes: [...state.nodes, customNode],
}));
+
+ useHistoryStore.getState().pushState(prevState);
+
return customNode;
},
updateNodeData: (nodeId, data) => {
+ const prevState = {
+ nodes: get().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n,
),
}));
- const newState = {
- nodes: get().nodes,
- edges: useEdgeStore.getState().edges,
- };
-
- useHistoryStore.getState().pushState(newState);
+ useHistoryStore.getState().pushState(prevState);
},
toggleAdvanced: (nodeId: string) =>
set((state) => ({
@@ -226,6 +303,9 @@ export const useNodeStore = create((set, get) => ({
...(node.data.metadata?.customized_name !== undefined && {
customized_name: node.data.metadata.customized_name,
}),
+ ...(node.data.metadata?.credentials_optional !== undefined && {
+ credentials_optional: node.data.metadata.credentials_optional,
+ }),
},
};
},
@@ -245,17 +325,162 @@ export const useNodeStore = create((set, get) => ({
return get().nodes.find((n) => n.id === nodeId)?.data?.status;
},
- updateNodeExecutionResult: (nodeId: string, result: NodeExecutionResult) => {
+ cleanNodesStatuses: () => {
set((state) => ({
- nodes: state.nodes.map((n) =>
- n.id === nodeId
- ? { ...n, data: { ...n.data, nodeExecutionResult: result } }
- : n,
- ),
+ nodes: state.nodes.map((n) => ({
+ ...n,
+ data: { ...n.data, status: undefined },
+ })),
}));
},
- getNodeExecutionResult: (nodeId: string) => {
- return get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResult;
+
+ updateNodeExecutionResult: (nodeId: string, result: NodeExecutionResult) => {
+ set((state) => {
+ let latestNodeInputData = state.latestNodeInputData;
+ let latestNodeOutputData = state.latestNodeOutputData;
+ let accumulatedNodeInputData = state.accumulatedNodeInputData;
+ let accumulatedNodeOutputData = state.accumulatedNodeOutputData;
+
+ const nodes = state.nodes.map((n) => {
+ if (n.id !== nodeId) return n;
+
+ const existingResults = n.data.nodeExecutionResults || [];
+ const duplicateIndex = existingResults.findIndex(
+ (r) => r.node_exec_id === result.node_exec_id,
+ );
+
+ if (duplicateIndex !== -1) {
+ const oldResult = existingResults[duplicateIndex];
+ const inputDataChanged =
+ JSON.stringify(oldResult.input_data) !==
+ JSON.stringify(result.input_data);
+ const outputDataChanged =
+ JSON.stringify(oldResult.output_data) !==
+ JSON.stringify(result.output_data);
+
+ if (!inputDataChanged && !outputDataChanged) {
+ return n;
+ }
+
+ const updatedResults = [...existingResults];
+ updatedResults[duplicateIndex] = result;
+
+ const recomputedAccumulatedInput = updatedResults.reduce(
+ (acc, r) => accumulateExecutionData(acc, r.input_data),
+ {} as Record,
+ );
+ const recomputedAccumulatedOutput = updatedResults.reduce(
+ (acc, r) => accumulateExecutionData(acc, r.output_data),
+ {} as Record,
+ );
+
+ const mostRecentResult = updatedResults[updatedResults.length - 1];
+ latestNodeInputData = {
+ ...latestNodeInputData,
+ [nodeId]: mostRecentResult.input_data,
+ };
+ latestNodeOutputData = {
+ ...latestNodeOutputData,
+ [nodeId]: mostRecentResult.output_data,
+ };
+
+ accumulatedNodeInputData = {
+ ...accumulatedNodeInputData,
+ [nodeId]: recomputedAccumulatedInput,
+ };
+ accumulatedNodeOutputData = {
+ ...accumulatedNodeOutputData,
+ [nodeId]: recomputedAccumulatedOutput,
+ };
+
+ return {
+ ...n,
+ data: {
+ ...n.data,
+ nodeExecutionResults: updatedResults,
+ },
+ };
+ }
+
+ accumulatedNodeInputData = {
+ ...accumulatedNodeInputData,
+ [nodeId]: accumulateExecutionData(
+ accumulatedNodeInputData[nodeId] || {},
+ result.input_data,
+ ),
+ };
+ accumulatedNodeOutputData = {
+ ...accumulatedNodeOutputData,
+ [nodeId]: accumulateExecutionData(
+ accumulatedNodeOutputData[nodeId] || {},
+ result.output_data,
+ ),
+ };
+
+ latestNodeInputData = {
+ ...latestNodeInputData,
+ [nodeId]: result.input_data,
+ };
+ latestNodeOutputData = {
+ ...latestNodeOutputData,
+ [nodeId]: result.output_data,
+ };
+
+ return {
+ ...n,
+ data: {
+ ...n.data,
+ nodeExecutionResults: [...existingResults, result],
+ },
+ };
+ });
+
+ return {
+ nodes,
+ latestNodeInputData,
+ latestNodeOutputData,
+ accumulatedNodeInputData,
+ accumulatedNodeOutputData,
+ };
+ });
+ },
+ getNodeExecutionResults: (nodeId: string) => {
+ return (
+ get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResults || []
+ );
+ },
+ getLatestNodeInputData: (nodeId: string) => {
+ return get().latestNodeInputData[nodeId];
+ },
+ getLatestNodeOutputData: (nodeId: string) => {
+ return get().latestNodeOutputData[nodeId];
+ },
+ getAccumulatedNodeInputData: (nodeId: string) => {
+ return get().accumulatedNodeInputData[nodeId] || {};
+ },
+ getAccumulatedNodeOutputData: (nodeId: string) => {
+ return get().accumulatedNodeOutputData[nodeId] || {};
+ },
+ getLatestNodeExecutionResult: (nodeId: string) => {
+ const results =
+ get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResults ||
+ [];
+ return results.length > 0 ? results[results.length - 1] : undefined;
+ },
+ clearAllNodeExecutionResults: () => {
+ set((state) => ({
+ nodes: state.nodes.map((n) => ({
+ ...n,
+ data: {
+ ...n.data,
+ nodeExecutionResults: [],
+ },
+ })),
+ latestNodeInputData: {},
+ latestNodeOutputData: {},
+ accumulatedNodeInputData: {},
+ accumulatedNodeOutputData: {},
+ }));
},
getNodeBlockUIType: (nodeId: string) => {
return (
@@ -342,4 +567,125 @@ export const useNodeStore = create((set, get) => ({
}));
}
},
+
+ setCredentialsOptional: (nodeId: string, optional: boolean) => {
+ const prevState = {
+ nodes: get().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+
+ set((state) => ({
+ nodes: state.nodes.map((n) =>
+ n.id === nodeId
+ ? {
+ ...n,
+ data: {
+ ...n.data,
+ metadata: {
+ ...n.data.metadata,
+ credentials_optional: optional,
+ },
+ },
+ }
+ : n,
+ ),
+ }));
+
+ useHistoryStore.getState().pushState(prevState);
+ },
+
+ // Sub-agent resolution mode state
+ nodesInResolutionMode: new Set(),
+ brokenEdgeIDs: new Map>(),
+ nodeResolutionData: new Map(),
+
+ setNodeResolutionMode: (
+ nodeID: string,
+ inResolution: boolean,
+ resolutionData?: NodeResolutionData,
+ ) => {
+ set((state) => {
+ const newNodesSet = new Set(state.nodesInResolutionMode);
+ const newResolutionDataMap = new Map(state.nodeResolutionData);
+ const newBrokenEdgeIDs = new Map(state.brokenEdgeIDs);
+
+ if (inResolution) {
+ newNodesSet.add(nodeID);
+ if (resolutionData) {
+ newResolutionDataMap.set(nodeID, resolutionData);
+ }
+ } else {
+ newNodesSet.delete(nodeID);
+ newResolutionDataMap.delete(nodeID);
+ newBrokenEdgeIDs.delete(nodeID); // Clean up broken edges when exiting resolution mode
+ }
+
+ return {
+ nodesInResolutionMode: newNodesSet,
+ nodeResolutionData: newResolutionDataMap,
+ brokenEdgeIDs: newBrokenEdgeIDs,
+ };
+ });
+ },
+
+ isNodeInResolutionMode: (nodeID: string) => {
+ return get().nodesInResolutionMode.has(nodeID);
+ },
+
+ getNodeResolutionData: (nodeID: string) => {
+ return get().nodeResolutionData.get(nodeID);
+ },
+
+ setBrokenEdgeIDs: (nodeID: string, edgeIDs: string[]) => {
+ set((state) => {
+ const newMap = new Map(state.brokenEdgeIDs);
+ newMap.set(nodeID, new Set(edgeIDs));
+ return { brokenEdgeIDs: newMap };
+ });
+ },
+
+ removeBrokenEdgeID: (nodeID: string, edgeID: string) => {
+ set((state) => {
+ const newMap = new Map(state.brokenEdgeIDs);
+ const nodeSet = new Set(newMap.get(nodeID) || []);
+ nodeSet.delete(edgeID);
+ newMap.set(nodeID, nodeSet);
+ return { brokenEdgeIDs: newMap };
+ });
+ },
+
+ isEdgeBroken: (edgeID: string) => {
+ // Check across all nodes
+ const brokenEdgeIDs = get().brokenEdgeIDs;
+ for (const edgeSet of brokenEdgeIDs.values()) {
+ if (edgeSet.has(edgeID)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ clearResolutionState: () => {
+ set({
+ nodesInResolutionMode: new Set(),
+ brokenEdgeIDs: new Map>(),
+ nodeResolutionData: new Map(),
+ });
+ },
+
+ // Helper functions for input renderers
+ isInputBroken: (nodeID: string, handleID: string) => {
+ const resolutionData = get().nodeResolutionData.get(nodeID);
+ if (!resolutionData) return false;
+ return resolutionData.incompatibilities.missingInputs.includes(handleID);
+ },
+
+ getInputTypeMismatch: (nodeID: string, handleID: string) => {
+ const resolutionData = get().nodeResolutionData.get(nodeID);
+ if (!resolutionData) return undefined;
+ const mismatch = resolutionData.incompatibilities.inputTypeMismatches.find(
+ (m) => m.name === handleID,
+ );
+ return mismatch?.newType;
+ },
}));
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/tutorialStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/tutorialStore.ts
new file mode 100644
index 0000000000..581dda44c9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/tutorialStore.ts
@@ -0,0 +1,32 @@
+import { create } from "zustand";
+
+type TutorialStore = {
+ isTutorialRunning: boolean;
+ setIsTutorialRunning: (isTutorialRunning: boolean) => void;
+
+ currentStep: number;
+ setCurrentStep: (currentStep: number) => void;
+
+ // Force open the run input dialog from the tutorial
+ forceOpenRunInputDialog: boolean;
+ setForceOpenRunInputDialog: (forceOpen: boolean) => void;
+
+ // Track input values filled in the dialog
+ tutorialInputValues: Record;
+ setTutorialInputValues: (values: Record) => void;
+};
+
+export const useTutorialStore = create((set) => ({
+ isTutorialRunning: false,
+ setIsTutorialRunning: (isTutorialRunning) => set({ isTutorialRunning }),
+
+ currentStep: 0,
+ setCurrentStep: (currentStep) => set({ currentStep }),
+
+ forceOpenRunInputDialog: false,
+ setForceOpenRunInputDialog: (forceOpen) =>
+ set({ forceOpenRunInputDialog: forceOpen }),
+
+ tutorialInputValues: {},
+ setTutorialInputValues: (values) => set({ tutorialInputValues: values }),
+}));
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/types.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/types.ts
new file mode 100644
index 0000000000..f0ec7e6c1c
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/types.ts
@@ -0,0 +1,14 @@
+import { IncompatibilityInfo } from "../hooks/useSubAgentUpdate/types";
+
+export type NodeResolutionData = {
+ incompatibilities: IncompatibilityInfo;
+ pendingUpdate: {
+ input_schema: Record;
+ output_schema: Record;
+ };
+ currentSchema: {
+ input_schema: Record;
+ output_schema: Record;
+ };
+ pendingHardcodedValues: Record;
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/ChatContainer.tsx
deleted file mode 100644
index 32f9d6c6eb..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/ChatContainer.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { cn } from "@/lib/utils";
-import { ChatInput } from "@/app/(platform)/chat/components/ChatInput/ChatInput";
-import { MessageList } from "@/app/(platform)/chat/components/MessageList/MessageList";
-import { QuickActionsWelcome } from "@/app/(platform)/chat/components/QuickActionsWelcome/QuickActionsWelcome";
-import { useChatContainer } from "./useChatContainer";
-import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
-
-export interface ChatContainerProps {
- sessionId: string | null;
- initialMessages: SessionDetailResponse["messages"];
- onRefreshSession: () => Promise;
- className?: string;
-}
-
-export function ChatContainer({
- sessionId,
- initialMessages,
- onRefreshSession,
- className,
-}: ChatContainerProps) {
- const { messages, streamingChunks, isStreaming, sendMessage } =
- useChatContainer({
- sessionId,
- initialMessages,
- onRefreshSession,
- });
-
- const quickActions = [
- "Find agents for social media management",
- "Show me agents for content creation",
- "Help me automate my business",
- "What can you help me with?",
- ];
-
- return (
-
- {/* Messages or Welcome Screen */}
- {messages.length === 0 ? (
-
- ) : (
-
- )}
-
- {/* Input - Always visible */}
-
-
-
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts
deleted file mode 100644
index 3a94dab1ea..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts
+++ /dev/null
@@ -1,279 +0,0 @@
-import type { ChatMessageData } from "@/app/(platform)/chat/components/ChatMessage/useChatMessage";
-import type { ToolResult } from "@/types/chat";
-
-export function createUserMessage(content: string): ChatMessageData {
- return {
- type: "message",
- role: "user",
- content,
- timestamp: new Date(),
- };
-}
-
-export function filterAuthMessages(
- messages: ChatMessageData[],
-): ChatMessageData[] {
- return messages.filter(
- (msg) => msg.type !== "credentials_needed" && msg.type !== "login_needed",
- );
-}
-
-export function isValidMessage(msg: unknown): msg is Record {
- if (typeof msg !== "object" || msg === null) {
- return false;
- }
- const m = msg as Record;
- if (typeof m.role !== "string") {
- return false;
- }
- if (m.content !== undefined && typeof m.content !== "string") {
- return false;
- }
- return true;
-}
-
-export function isToolCallArray(value: unknown): value is Array<{
- id: string;
- type: string;
- function: { name: string; arguments: string };
-}> {
- if (!Array.isArray(value)) {
- return false;
- }
- return value.every(
- (item) =>
- typeof item === "object" &&
- item !== null &&
- "id" in item &&
- typeof item.id === "string" &&
- "type" in item &&
- typeof item.type === "string" &&
- "function" in item &&
- typeof item.function === "object" &&
- item.function !== null &&
- "name" in item.function &&
- typeof item.function.name === "string" &&
- "arguments" in item.function &&
- typeof item.function.arguments === "string",
- );
-}
-
-export function isAgentArray(value: unknown): value is Array<{
- id: string;
- name: string;
- description: string;
- version?: number;
-}> {
- if (!Array.isArray(value)) {
- return false;
- }
- return value.every(
- (item) =>
- typeof item === "object" &&
- item !== null &&
- "id" in item &&
- typeof item.id === "string" &&
- "name" in item &&
- typeof item.name === "string" &&
- "description" in item &&
- typeof item.description === "string" &&
- (!("version" in item) || typeof item.version === "number"),
- );
-}
-
-export function extractJsonFromErrorMessage(
- message: string,
-): Record | null {
- try {
- const start = message.indexOf("{");
- if (start === -1) {
- return null;
- }
- let depth = 0;
- let end = -1;
- for (let i = start; i < message.length; i++) {
- const ch = message[i];
- if (ch === "{") {
- depth++;
- } else if (ch === "}") {
- depth--;
- if (depth === 0) {
- end = i;
- break;
- }
- }
- }
- if (end === -1) {
- return null;
- }
- const jsonStr = message.slice(start, end + 1);
- return JSON.parse(jsonStr) as Record;
- } catch {
- return null;
- }
-}
-
-export function parseToolResponse(
- result: ToolResult,
- toolId: string,
- toolName: string,
- timestamp?: Date,
-): ChatMessageData | null {
- let parsedResult: Record | null = null;
- try {
- parsedResult =
- typeof result === "string"
- ? JSON.parse(result)
- : (result as Record);
- } catch {
- parsedResult = null;
- }
- if (parsedResult && typeof parsedResult === "object") {
- const responseType = parsedResult.type as string | undefined;
- if (responseType === "no_results") {
- return {
- type: "tool_response",
- toolId,
- toolName,
- result: (parsedResult.message as string) || "No results found",
- success: true,
- timestamp: timestamp || new Date(),
- };
- }
- if (responseType === "agent_carousel") {
- const agentsData = parsedResult.agents;
- if (isAgentArray(agentsData)) {
- return {
- type: "agent_carousel",
- toolName: "agent_carousel",
- agents: agentsData,
- totalCount: parsedResult.total_count as number | undefined,
- timestamp: timestamp || new Date(),
- };
- } else {
- console.warn("Invalid agents array in agent_carousel response");
- }
- }
- if (responseType === "execution_started") {
- return {
- type: "execution_started",
- toolName: "execution_started",
- executionId: (parsedResult.execution_id as string) || "",
- agentName: (parsedResult.graph_name as string) || undefined,
- message: parsedResult.message as string | undefined,
- libraryAgentLink: parsedResult.library_agent_link as string | undefined,
- timestamp: timestamp || new Date(),
- };
- }
- if (responseType === "need_login") {
- return {
- type: "login_needed",
- toolName: "login_needed",
- message:
- (parsedResult.message as string) ||
- "Please sign in to use chat and agent features",
- sessionId: (parsedResult.session_id as string) || "",
- agentInfo: parsedResult.agent_info as
- | {
- graph_id: string;
- name: string;
- trigger_type: string;
- }
- | undefined,
- timestamp: timestamp || new Date(),
- };
- }
- if (responseType === "setup_requirements") {
- return null;
- }
- }
- return {
- type: "tool_response",
- toolId,
- toolName,
- result,
- success: true,
- timestamp: timestamp || new Date(),
- };
-}
-
-export function isUserReadiness(
- value: unknown,
-): value is { missing_credentials?: Record } {
- return (
- typeof value === "object" &&
- value !== null &&
- (!("missing_credentials" in value) ||
- typeof (value as any).missing_credentials === "object")
- );
-}
-
-export function isMissingCredentials(
- value: unknown,
-): value is Record> {
- if (typeof value !== "object" || value === null) {
- return false;
- }
- return Object.values(value).every((v) => typeof v === "object" && v !== null);
-}
-
-export function isSetupInfo(value: unknown): value is {
- user_readiness?: Record;
- agent_name?: string;
-} {
- return (
- typeof value === "object" &&
- value !== null &&
- (!("user_readiness" in value) ||
- typeof (value as any).user_readiness === "object") &&
- (!("agent_name" in value) || typeof (value as any).agent_name === "string")
- );
-}
-
-export function extractCredentialsNeeded(
- parsedResult: Record,
-): ChatMessageData | null {
- try {
- const setupInfo = parsedResult?.setup_info as
- | Record
- | undefined;
- const userReadiness = setupInfo?.user_readiness as
- | Record
- | undefined;
- const missingCreds = userReadiness?.missing_credentials as
- | Record>
- | undefined;
- if (missingCreds && Object.keys(missingCreds).length > 0) {
- const agentName = (setupInfo?.agent_name as string) || "this agent";
- const credentials = Object.values(missingCreds).map((credInfo) => ({
- provider: (credInfo.provider as string) || "unknown",
- providerName:
- (credInfo.provider_name as string) ||
- (credInfo.provider as string) ||
- "Unknown Provider",
- credentialType:
- (credInfo.type as
- | "api_key"
- | "oauth2"
- | "user_password"
- | "host_scoped") || "api_key",
- title:
- (credInfo.title as string) ||
- `${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials`,
- scopes: credInfo.scopes as string[] | undefined,
- }));
- return {
- type: "credentials_needed",
- toolName: "run_agent",
- credentials,
- message: `To run ${agentName}, you need to add ${credentials.length === 1 ? "credentials" : `${credentials.length} credentials`}.`,
- agentName,
- timestamp: new Date(),
- };
- }
- return null;
- } catch (err) {
- console.error("Failed to extract credentials from setup info:", err);
- return null;
- }
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.handlers.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.handlers.ts
deleted file mode 100644
index fdbecb5d61..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.handlers.ts
+++ /dev/null
@@ -1,206 +0,0 @@
-import type { Dispatch, SetStateAction, MutableRefObject } from "react";
-import type { StreamChunk } from "@/app/(platform)/chat/useChatStream";
-import type { ChatMessageData } from "@/app/(platform)/chat/components/ChatMessage/useChatMessage";
-import { parseToolResponse, extractCredentialsNeeded } from "./helpers";
-
-export interface HandlerDependencies {
- setHasTextChunks: Dispatch>;
- setStreamingChunks: Dispatch>;
- streamingChunksRef: MutableRefObject;
- setMessages: Dispatch>;
- sessionId: string;
-}
-
-export function handleTextChunk(chunk: StreamChunk, deps: HandlerDependencies) {
- if (!chunk.content) return;
- deps.setHasTextChunks(true);
- deps.setStreamingChunks((prev) => {
- const updated = [...prev, chunk.content!];
- deps.streamingChunksRef.current = updated;
- return updated;
- });
-}
-
-export function handleTextEnded(
- _chunk: StreamChunk,
- deps: HandlerDependencies,
-) {
- console.log("[Text Ended] Saving streamed text as assistant message");
- const completedText = deps.streamingChunksRef.current.join("");
- if (completedText.trim()) {
- const assistantMessage: ChatMessageData = {
- type: "message",
- role: "assistant",
- content: completedText,
- timestamp: new Date(),
- };
- deps.setMessages((prev) => [...prev, assistantMessage]);
- }
- deps.setStreamingChunks([]);
- deps.streamingChunksRef.current = [];
- deps.setHasTextChunks(false);
-}
-
-export function handleToolCallStart(
- chunk: StreamChunk,
- deps: HandlerDependencies,
-) {
- const toolCallMessage: ChatMessageData = {
- type: "tool_call",
- toolId: chunk.tool_id || `tool-${Date.now()}-${chunk.idx || 0}`,
- toolName: chunk.tool_name || "Executing...",
- arguments: chunk.arguments || {},
- timestamp: new Date(),
- };
- deps.setMessages((prev) => [...prev, toolCallMessage]);
- console.log("[Tool Call Start]", {
- toolId: toolCallMessage.toolId,
- toolName: toolCallMessage.toolName,
- timestamp: new Date().toISOString(),
- });
-}
-
-export function handleToolResponse(
- chunk: StreamChunk,
- deps: HandlerDependencies,
-) {
- console.log("[Tool Response] Received:", {
- toolId: chunk.tool_id,
- toolName: chunk.tool_name,
- timestamp: new Date().toISOString(),
- });
- let toolName = chunk.tool_name || "unknown";
- if (!chunk.tool_name || chunk.tool_name === "unknown") {
- deps.setMessages((prev) => {
- const matchingToolCall = [...prev]
- .reverse()
- .find(
- (msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id,
- );
- if (matchingToolCall && matchingToolCall.type === "tool_call") {
- toolName = matchingToolCall.toolName;
- }
- return prev;
- });
- }
- const responseMessage = parseToolResponse(
- chunk.result!,
- chunk.tool_id!,
- toolName,
- new Date(),
- );
- if (!responseMessage) {
- let parsedResult: Record | null = null;
- try {
- parsedResult =
- typeof chunk.result === "string"
- ? JSON.parse(chunk.result)
- : (chunk.result as Record);
- } catch {
- parsedResult = null;
- }
- if (
- chunk.tool_name === "run_agent" &&
- chunk.success &&
- parsedResult?.type === "setup_requirements"
- ) {
- const credentialsMessage = extractCredentialsNeeded(parsedResult);
- if (credentialsMessage) {
- deps.setMessages((prev) => [...prev, credentialsMessage]);
- }
- }
- return;
- }
- deps.setMessages((prev) => {
- const toolCallIndex = prev.findIndex(
- (msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id,
- );
- if (toolCallIndex !== -1) {
- const newMessages = [...prev];
- newMessages[toolCallIndex] = responseMessage;
- console.log(
- "[Tool Response] Replaced tool_call with matching tool_id:",
- chunk.tool_id,
- "at index:",
- toolCallIndex,
- );
- return newMessages;
- }
- console.warn(
- "[Tool Response] No tool_call found with tool_id:",
- chunk.tool_id,
- "appending instead",
- );
- return [...prev, responseMessage];
- });
-}
-
-export function handleLoginNeeded(
- chunk: StreamChunk,
- deps: HandlerDependencies,
-) {
- const loginNeededMessage: ChatMessageData = {
- type: "login_needed",
- toolName: "login_needed",
- message: chunk.message || "Please sign in to use chat and agent features",
- sessionId: chunk.session_id || deps.sessionId,
- agentInfo: chunk.agent_info,
- timestamp: new Date(),
- };
- deps.setMessages((prev) => [...prev, loginNeededMessage]);
-}
-
-export function handleStreamEnd(
- _chunk: StreamChunk,
- deps: HandlerDependencies,
-) {
- const completedContent = deps.streamingChunksRef.current.join("");
- // Only save message if there are uncommitted chunks
- // (text_ended already saved if there were tool calls)
- if (completedContent.trim()) {
- console.log(
- "[Stream End] Saving remaining streamed text as assistant message",
- );
- const assistantMessage: ChatMessageData = {
- type: "message",
- role: "assistant",
- content: completedContent,
- timestamp: new Date(),
- };
- deps.setMessages((prev) => {
- const updated = [...prev, assistantMessage];
- console.log("[Stream End] Final state:", {
- localMessages: updated.map((m) => ({
- type: m.type,
- ...(m.type === "message" && {
- role: m.role,
- contentLength: m.content.length,
- }),
- ...(m.type === "tool_call" && {
- toolId: m.toolId,
- toolName: m.toolName,
- }),
- ...(m.type === "tool_response" && {
- toolId: m.toolId,
- toolName: m.toolName,
- success: m.success,
- }),
- })),
- streamingChunks: deps.streamingChunksRef.current,
- timestamp: new Date().toISOString(),
- });
- return updated;
- });
- } else {
- console.log("[Stream End] No uncommitted chunks, message already saved");
- }
- deps.setStreamingChunks([]);
- deps.streamingChunksRef.current = [];
- deps.setHasTextChunks(false);
- console.log("[Stream End] Stream complete, messages in local state");
-}
-
-export function handleError(chunk: StreamChunk, _deps: HandlerDependencies) {
- const errorMessage = chunk.message || chunk.content || "An error occurred";
- console.error("Stream error:", errorMessage);
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.ts
deleted file mode 100644
index c75ad587a5..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { useState, useCallback, useRef, useMemo } from "react";
-import { toast } from "sonner";
-import { useChatStream } from "@/app/(platform)/chat/useChatStream";
-import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
-import type { ChatMessageData } from "@/app/(platform)/chat/components/ChatMessage/useChatMessage";
-import {
- parseToolResponse,
- isValidMessage,
- isToolCallArray,
- createUserMessage,
- filterAuthMessages,
-} from "./helpers";
-import { createStreamEventDispatcher } from "./createStreamEventDispatcher";
-
-interface UseChatContainerArgs {
- sessionId: string | null;
- initialMessages: SessionDetailResponse["messages"];
- onRefreshSession: () => Promise;
-}
-
-export function useChatContainer({
- sessionId,
- initialMessages,
-}: UseChatContainerArgs) {
- const [messages, setMessages] = useState([]);
- const [streamingChunks, setStreamingChunks] = useState([]);
- const [hasTextChunks, setHasTextChunks] = useState(false);
- const streamingChunksRef = useRef([]);
- const { error, sendMessage: sendStreamMessage } = useChatStream();
- const isStreaming = hasTextChunks;
-
- const allMessages = useMemo(() => {
- const processedInitialMessages = initialMessages
- .filter((msg: Record) => {
- if (!isValidMessage(msg)) {
- console.warn("Invalid message structure from backend:", msg);
- return false;
- }
- const content = String(msg.content || "").trim();
- const toolCalls = msg.tool_calls;
- return (
- content.length > 0 ||
- (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0)
- );
- })
- .map((msg: Record) => {
- const content = String(msg.content || "");
- const role = String(msg.role || "assistant").toLowerCase();
- const toolCalls = msg.tool_calls;
- if (
- role === "assistant" &&
- toolCalls &&
- isToolCallArray(toolCalls) &&
- toolCalls.length > 0
- ) {
- return null;
- }
- if (role === "tool") {
- const timestamp = msg.timestamp
- ? new Date(msg.timestamp as string)
- : undefined;
- const toolResponse = parseToolResponse(
- content,
- (msg.tool_call_id as string) || "",
- "unknown",
- timestamp,
- );
- if (!toolResponse) {
- return null;
- }
- return toolResponse;
- }
- return {
- type: "message",
- role: role as "user" | "assistant" | "system",
- content,
- timestamp: msg.timestamp
- ? new Date(msg.timestamp as string)
- : undefined,
- };
- })
- .filter((msg): msg is ChatMessageData => msg !== null);
-
- return [...processedInitialMessages, ...messages];
- }, [initialMessages, messages]);
-
- const sendMessage = useCallback(
- async function sendMessage(content: string, isUserMessage: boolean = true) {
- if (!sessionId) {
- console.error("Cannot send message: no session ID");
- return;
- }
- if (isUserMessage) {
- const userMessage = createUserMessage(content);
- setMessages((prev) => [...filterAuthMessages(prev), userMessage]);
- } else {
- setMessages((prev) => filterAuthMessages(prev));
- }
- setStreamingChunks([]);
- streamingChunksRef.current = [];
- setHasTextChunks(false);
- const dispatcher = createStreamEventDispatcher({
- setHasTextChunks,
- setStreamingChunks,
- streamingChunksRef,
- setMessages,
- sessionId,
- });
- try {
- await sendStreamMessage(sessionId, content, dispatcher, isUserMessage);
- } catch (err) {
- console.error("Failed to send message:", err);
- const errorMessage =
- err instanceof Error ? err.message : "Failed to send message";
- toast.error("Failed to send message", {
- description: errorMessage,
- });
- }
- },
- [sessionId, sendStreamMessage],
- );
-
- return {
- messages: allMessages,
- streamingChunks,
- isStreaming,
- error,
- sendMessage,
- };
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx
deleted file mode 100644
index 1f70f7740f..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
-import { Card } from "@/components/atoms/Card/Card";
-import { Text } from "@/components/atoms/Text/Text";
-import type { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
-import { cn } from "@/lib/utils";
-import { CheckIcon, KeyIcon, WarningIcon } from "@phosphor-icons/react";
-import { useEffect, useRef } from "react";
-import { useChatCredentialsSetup } from "./useChatCredentialsSetup";
-
-export interface CredentialInfo {
- provider: string;
- providerName: string;
- credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
- title: string;
- scopes?: string[];
-}
-
-interface Props {
- credentials: CredentialInfo[];
- agentName?: string;
- message: string;
- onAllCredentialsComplete: () => void;
- onCancel: () => void;
- className?: string;
-}
-
-function createSchemaFromCredentialInfo(
- credential: CredentialInfo,
-): BlockIOCredentialsSubSchema {
- return {
- type: "object",
- properties: {},
- credentials_provider: [credential.provider],
- credentials_types: [credential.credentialType],
- credentials_scopes: credential.scopes,
- discriminator: undefined,
- discriminator_mapping: undefined,
- discriminator_values: undefined,
- };
-}
-
-export function ChatCredentialsSetup({
- credentials,
- agentName: _agentName,
- message,
- onAllCredentialsComplete,
- onCancel: _onCancel,
- className,
-}: Props) {
- const { selectedCredentials, isAllComplete, handleCredentialSelect } =
- useChatCredentialsSetup(credentials);
-
- // Track if we've already called completion to prevent double calls
- const hasCalledCompleteRef = useRef(false);
-
- // Reset the completion flag when credentials change (new credential setup flow)
- useEffect(
- function resetCompletionFlag() {
- hasCalledCompleteRef.current = false;
- },
- [credentials],
- );
-
- // Auto-call completion when all credentials are configured
- useEffect(
- function autoCompleteWhenReady() {
- if (isAllComplete && !hasCalledCompleteRef.current) {
- hasCalledCompleteRef.current = true;
- onAllCredentialsComplete();
- }
- },
- [isAllComplete, onAllCredentialsComplete],
- );
-
- return (
-
-
-
-
-
-
-
- Credentials Required
-
-
- {message}
-
-
-
- {credentials.map((cred, index) => {
- const schema = createSchemaFromCredentialInfo(cred);
- const isSelected = !!selectedCredentials[cred.provider];
-
- return (
-