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 afe70bd4342c..a6c3833b9b0b 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 @@ -9,7 +9,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 3fcde3bf761d..716ae1e1e254 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 @@ -76,7 +76,10 @@ 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 3a0c7aab4a3d..b0619a3fc603 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 @@ -29,6 +29,7 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => { { 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 c6f8e8ded4c4..0407342afc6e 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,15 @@ import { TooltipTrigger, } from "@/components/atoms/Tooltip/BaseTooltip"; import { + ChalkboardIcon, FrameCornersIcon, MinusIcon, PlusIcon, } from "@phosphor-icons/react/dist/ssr"; import { LockIcon, LockOpenIcon } from "lucide-react"; import { memo } from "react"; +import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore"; +import { startTutorial } from "../../tutorial"; export const CustomControls = memo( ({ @@ -22,27 +25,41 @@ export const CustomControls = memo( setIsLocked: (isLocked: boolean) => void; }) => { const { zoomIn, zoomOut, fitView } = useReactFlow(); - + const { isTutorialRunning, setIsTutorialRunning } = useTutorialStore(); const controls = [ { + id: "zoom-in-button", icon: , label: "Zoom In", onClick: () => zoomIn(), className: "h-10 w-10 border-none", }, { + id: "zoom-out-button", icon: , label: "Zoom Out", onClick: () => zoomOut(), className: "h-10 w-10 border-none", }, { + id: "tutorial-button", + icon: , + label: "Start Tutorial", + onClick: () => { + startTutorial(); + setIsTutorialRunning(true); + }, + className: `h-10 w-10 border-none ${isTutorialRunning ? "bg-zinc-100" : "bg-white"}`, + }, + { + 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 ? ( ) : ( @@ -55,15 +72,19 @@ export const CustomControls = memo( ]; return ( -
- {controls.map((control, index) => ( - +
+ {controls.map((control) => ( +
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 315a52f553f0..b04bec87f909 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 @@ -33,7 +33,10 @@ export const FormCreator = React.memo( const initialValues = getHardCodedValues(nodeId); return ( -
+
+
{property?.description && ( 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 000000000000..479f1ada9595 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/constants.ts @@ -0,0 +1,116 @@ +// 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 + INPUT_BLOCK_RESULT_OUTPUT_HANDLEER: '[data-id="output-handler-2-Result"]', + CALCULATOR_RESULT_OUTPUT_HANDLEER: '[data-id="input-handler-1-Result"]', + + // Input Handler + CALCULATOR_NUMBER_A_INPUT_HANDLER: '[data-id="label-1-root_a"]', + OUTPUT_VALUE_INPUT_HANDLEER: '[data-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"]', + + // 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-id="input-handles"]', + NODE_OUTPUT_HANDLE: '[data-handlepos="right"]', + NODE_INPUT_HANDLE: "[data-nodeid]", + NODE_LATEST_OUTPUT: '[data-id="latest-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 000000000000..e869b1daa87c --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/blocks.ts @@ -0,0 +1,122 @@ +/** + * Block-related helpers for the tutorial + */ + +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"; + +// Cache for prefetched blocks +let prefetchedBlocks: Map = new Map(); + +/** + * Prefetches Agent Input and Agent Output blocks at tutorial start + * Call this when the tutorial is initialized + */ +export const prefetchTutorialBlocks = async (): Promise => { + try { + const blockIds = [BLOCK_IDS.AGENT_INPUT, BLOCK_IDS.AGENT_OUTPUT]; + 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); + } +}; + +/** + * Gets a prefetched block by ID + */ +export const getPrefetchedBlock = (blockId: string): BlockInfo | undefined => { + return prefetchedBlocks.get(blockId); +}; + +/** + * Clears the prefetched blocks cache + */ +export const clearPrefetchedBlocks = (): void => { + prefetchedBlocks.clear(); +}; + +/** + * Adds a prefetched block to the canvas at a specific position + */ +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`); + } +}; + +/** + * Gets a node by its block_id + */ +export const getNodeByBlockId = (blockId: string) => { + const nodes = useNodeStore.getState().nodes; + return nodes.find((n) => n.data?.block_id === blockId); +}; + +/** + * Adds Agent Input and Agent Output blocks positioned relative to Calculator + * Agent Input: Left side of Calculator + * Agent Output: Right side of Calculator + */ +export const addAgentIOBlocks = (): void => { + // Find the Calculator node to position relative to it + const calculatorNode = getNodeByBlockId(BLOCK_IDS.CALCULATOR); + + if (calculatorNode) { + const calcX = calculatorNode.position.x; + const calcY = calculatorNode.position.y; + + // Agent Input: 600px to the left of Calculator + addPrefetchedBlock(BLOCK_IDS.AGENT_INPUT, { + x: calcX - 600, + y: calcY, + }); + + // Agent Output: 600px to the right of Calculator + addPrefetchedBlock(BLOCK_IDS.AGENT_OUTPUT, { + x: calcX + 600, + y: calcY, + }); + } else { + // Fallback: Add without specific positioning if Calculator not found + addPrefetchedBlock(BLOCK_IDS.AGENT_INPUT); + addPrefetchedBlock(BLOCK_IDS.AGENT_OUTPUT); + } +}; + +/** + * Gets the form container selector for a specific block + */ +export const getFormContainerSelector = (blockId: string): string | null => { + const node = getNodeByBlockId(blockId); + if (node) { + return `[data-id="form-creator-container-${node.id}"]`; + } + return null; +}; + +/** + * Gets the form container element for a specific block + */ +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 000000000000..559e811e281d --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/canvas.ts @@ -0,0 +1,111 @@ +/** + * Canvas-related helpers for the tutorial + */ + +import { TUTORIAL_CONFIG, TUTORIAL_SELECTORS } from "../constants"; +import { useNodeStore } from "../../../../stores/nodeStore"; + +/** + * Waits for a node to appear on the canvas using nodeStore + */ +export const waitForNodeOnCanvas = ( + timeout = 10000, +): Promise => { + return new Promise((resolve) => { + const startTime = Date.now(); + + const checkNode = () => { + // First check nodeStore + const storeNodes = useNodeStore.getState().nodes; + if (storeNodes.length > 0) { + // Node exists in store, now wait for DOM element + 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(); + }); +}; + +/** + * Waits for a specific number of nodes on canvas + */ +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(); + }); +}; + +/** + * Gets the count of nodes on canvas from nodeStore + */ +export const getNodesCount = (): number => { + return useNodeStore.getState().nodes.length; +}; + +/** + * Gets the first node from nodeStore + */ +export const getFirstNode = () => { + const nodes = useNodeStore.getState().nodes; + return nodes.length > 0 ? nodes[0] : null; +}; + +/** + * Gets a node by ID from nodeStore + */ +export const getNodeById = (nodeId: string) => { + const nodes = useNodeStore.getState().nodes; + return nodes.find((n) => n.id === nodeId); +}; + +/** + * Checks if a node has hardcoded values set + */ +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 !== "", + ); +}; + +/** + * Triggers the fit view button to center the canvas + */ +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 000000000000..dea5eaebd6d7 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/connections.ts @@ -0,0 +1,27 @@ +/** + * Connection-related helpers for the tutorial + */ + +import { useNodeStore } from "../../../../stores/nodeStore"; +import { useEdgeStore } from "../../../../stores/edgeStore"; + +/** + * Checks if a specific connection exists between two nodes + */ +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 000000000000..543a87647dbb --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/dom.ts @@ -0,0 +1,215 @@ +/** + * DOM manipulation helpers for the tutorial + */ + +import { TUTORIAL_CONFIG, TUTORIAL_SELECTORS } from "../constants"; + +/** + * Waits for an element to appear in the DOM + */ +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(); + }); +}; + +/** + * Waits for an input to contain a specific value (case-insensitive, partial match) + */ +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)) { + // Check if user has typed enough characters (at least 4 chars or the full string) + if (currentValue.length >= 4 || currentValue === target) { + resolve(); + return; + } + } + } + + if (Date.now() - startTime > timeout) { + resolve(); // Don't reject, just continue after timeout + } else { + setTimeout(checkInput, TUTORIAL_CONFIG.INPUT_CHECK_INTERVAL); + } + }; + checkInput(); + }); +}; + +/** + * Waits for a specific element to appear in the search results + */ +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); // Don't reject, just return null + } else { + setTimeout(checkResult, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL); + } + }; + checkResult(); + }); +}; + +/** + * Waits for any block card to appear in the block menu + */ +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(); + }); +}; + +/** + * Sets focus on an input element + */ +export const focusElement = (selector: string): void => { + const element = document.querySelector(selector) as HTMLElement; + if (element) { + element.focus(); + } +}; + +/** + * Scrolls an element into view smoothly + */ +export const scrollIntoView = (selector: string): void => { + const element = document.querySelector(selector); + if (element) { + element.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } +}; + +/** + * Types text into an input element with event dispatch + */ +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 })); + } +}; + +/** + * Creates a mutation observer to watch for element appearance + */ +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, + }); + + // Also check immediately + const element = document.querySelector(selector); + if (element) { + callback(element); + observer.disconnect(); + } + + return observer; +}; + +/** + * Watches for search input changes and calls callback when target is typed + */ +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(); + + // Match when user types at least 4 characters that match + 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 000000000000..1cc7a22352a2 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/highlights.ts @@ -0,0 +1,80 @@ +/** + * Highlight and animation helpers for the tutorial + */ + +import { CSS_CLASSES, TUTORIAL_SELECTORS } from "../constants"; + +/** + * Disables all blocks except the target block + */ +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); + }); +}; + +/** + * Enables all blocks (removes disable and highlight classes) + */ +export const enableAllBlocks = () => { + document + .querySelectorAll(TUTORIAL_SELECTORS.BLOCK_CARD_PREFIX) + .forEach((block) => { + block.classList.remove( + CSS_CLASSES.DISABLE, + CSS_CLASSES.HIGHLIGHT, + CSS_CLASSES.PULSE, + ); + }); +}; + +/** + * Adds highlight class to an element + */ +export const highlightElement = (selector: string) => { + const element = document.querySelector(selector); + if (element) { + element.classList.add(CSS_CLASSES.HIGHLIGHT); + } +}; + +/** + * Removes highlight from all elements + */ +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); + }); +}; + +/** + * Adds pulse animation to an element + */ +export const pulseElement = (selector: string) => { + const element = document.querySelector(selector); + if (element) { + element.classList.add(CSS_CLASSES.PULSE); + } +}; + +/** + * Highlights the first matching block in search results + */ +export const highlightFirstBlockInSearch = () => { + const firstBlock = document.querySelector( + TUTORIAL_SELECTORS.BLOCK_CARD_PREFIX, + ); + if (firstBlock) { + firstBlock.classList.add(CSS_CLASSES.PULSE); + // Scroll it into view + 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 000000000000..5d5d5806b5d1 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/index.ts @@ -0,0 +1,77 @@ +/** + * Tutorial helpers - re-exports all helper modules + */ + +// DOM helpers +export { + waitForElement, + waitForInputValue, + waitForSearchResult, + waitForAnyBlockCard, + focusElement, + scrollIntoView, + typeIntoInput, + observeElement, + watchSearchInput, +} from "./dom"; + +// Highlight helpers +export { + disableOtherBlocks, + enableAllBlocks, + highlightElement, + removeAllHighlights, + pulseElement, + highlightFirstBlockInSearch, +} from "./highlights"; + +// Block helpers +export { + prefetchTutorialBlocks, + getPrefetchedBlock, + clearPrefetchedBlocks, + addPrefetchedBlock, + getNodeByBlockId, + addAgentIOBlocks, + getFormContainerSelector, + getFormContainerElement, +} from "./blocks"; + +// Canvas helpers +export { + waitForNodeOnCanvas, + waitForNodesCount, + getNodesCount, + getFirstNode, + getNodeById, + nodeHasValues, + fitViewToScreen, +} from "./canvas"; + +// Connection helpers +export { isConnectionMade } from "./connections"; + +// Menu helpers +export { + forceBlockMenuOpen, + openBlockMenu, + closeBlockMenu, + clearBlockMenuSearch, +} from "./menu"; + +// Save helpers +export { + openSaveControl, + closeSaveControl, + forceSaveOpen, + clickSaveButton, + isAgentSaved, +} from "./save"; + +// State helpers +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 000000000000..fba9bce0f9ca --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/menu.ts @@ -0,0 +1,42 @@ +/** + * Block menu helpers for the tutorial + */ + +import { TUTORIAL_SELECTORS } from "../constants"; +import { useControlPanelStore } from "../../../../stores/controlPanelStore"; + +/** + * Forces the block menu to stay open during tutorial + */ +export const forceBlockMenuOpen = (force: boolean) => { + useControlPanelStore.getState().setForceOpenBlockMenu(force); +}; + +/** + * Opens the block menu + */ +export const openBlockMenu = () => { + useControlPanelStore.getState().setBlockMenuOpen(true); +}; + +/** + * Closes the block menu + */ +export const closeBlockMenu = () => { + useControlPanelStore.getState().setBlockMenuOpen(false); + useControlPanelStore.getState().setForceOpenBlockMenu(false); +}; + +/** + * Clears the search input in block menu + */ +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 000000000000..797374b5583b --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/save.ts @@ -0,0 +1,51 @@ +/** + * Save control helpers for the tutorial + */ + +import { TUTORIAL_SELECTORS } from "../constants"; +import { useControlPanelStore } from "../../../../stores/controlPanelStore"; + +/** + * Opens the save control popover + */ +export const openSaveControl = () => { + useControlPanelStore.getState().setSaveControlOpen(true); +}; + +/** + * Closes the save control popover + */ +export const closeSaveControl = () => { + useControlPanelStore.getState().setSaveControlOpen(false); + useControlPanelStore.getState().setForceOpenSave(false); +}; + +/** + * Forces the save control to stay open during tutorial + */ +export const forceSaveOpen = (force: boolean) => { + useControlPanelStore.getState().setForceOpenSave(force); +}; + +/** + * Simulates a click on the save button + */ +export const clickSaveButton = () => { + const saveButton = document.querySelector( + TUTORIAL_SELECTORS.SAVE_AGENT_BUTTON, + ) as HTMLButtonElement; + if (saveButton && !saveButton.disabled) { + saveButton.click(); + } +}; + +/** + * Check if the agent has been saved (by checking if version exists) + */ +export const isAgentSaved = (): boolean => { + const versionInput = document.querySelector( + '[data-testid="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 000000000000..4c2505c69bfe --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/state.ts @@ -0,0 +1,47 @@ +/** + * Tutorial state management helpers + */ + +import { Key, storage } from "@/services/storage/local-storage"; +import { closeBlockMenu } from "./menu"; +import { closeSaveControl, forceSaveOpen } from "./save"; +import { removeAllHighlights, enableAllBlocks } from "./highlights"; + +/** + * Handles tutorial cancellation + */ +export const handleTutorialCancel = (tour: any) => { + closeBlockMenu(); + closeSaveControl(); + forceSaveOpen(false); + removeAllHighlights(); + enableAllBlocks(); + tour.cancel(); + storage.set(Key.SHEPHERD_TOUR, "canceled"); +}; + +/** + * Handles tutorial skip + */ +export const handleTutorialSkip = (tour: any) => { + closeBlockMenu(); + closeSaveControl(); + forceSaveOpen(false); + removeAllHighlights(); + enableAllBlocks(); + tour.cancel(); + storage.set(Key.SHEPHERD_TOUR, "skipped"); +}; + +/** + * Handles tutorial completion + */ +export const handleTutorialComplete = () => { + 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 000000000000..19e133ef7d29 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/icons.ts @@ -0,0 +1,7 @@ +// These are SVG Phosphor icons + +export const ICONS = { + ClickIcon: ``, + Keyboard: ``, + Drag: ``, +}; 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 000000000000..147c45c20851 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/index.ts @@ -0,0 +1,64 @@ +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"; + +/** + * Starts the interactive tutorial + */ +export const startTutorial = async () => { + // Prefetch Agent Input and Agent Output blocks at the start + await prefetchTutorialBlocks(); + + 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, + }, + }); + + // Inject tutorial styles + injectTutorialStyles(); + + // Add all steps to the tour + const steps = createTutorialSteps(tour); + steps.forEach((step) => tour.addStep(step)); + + // Event handlers + tour.on("complete", () => { + handleTutorialComplete(); + removeTutorialStyles(); + clearPrefetchedBlocks(); // Clean up prefetched blocks + }); + + tour.on("cancel", () => { + handleTutorialCancel(tour); + removeTutorialStyles(); + clearPrefetchedBlocks(); // Clean up prefetched blocks + }); + + // Track tutorial steps with google analytics + 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/agent-io.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/agent-io.ts new file mode 100644 index 000000000000..e7ece97a4774 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/agent-io.ts @@ -0,0 +1,276 @@ +/** + * Agent I/O steps - Steps 10-13 + * Add agent input/output blocks and configure them + */ + +import { StepOptions } from "shepherd.js"; +import { TUTORIAL_SELECTORS, BLOCK_IDS } from "../constants"; +import { + waitForElement, + waitForNodesCount, + fitViewToScreen, + highlightElement, + removeAllHighlights, + addAgentIOBlocks, + getNodeByBlockId, +} from "../helpers"; +import { ICONS } from "../icons"; +import { banner } from "../styles"; + +/** + * Creates the agent I/O steps + */ +export const createAgentIOSteps = (tour: any): StepOptions[] => [ + { + id: "ask-add-agent-io-blocks", + title: "Add Agent Input & Output", + text: ` +
+

Great job configuring the Calculator!

+

Now we need to add Agent Input and Agent Output blocks to complete your agent.

+ +
+

These blocks are essential:

+
    +
  • Agent Input — Receives data when the agent runs
  • +
  • Agent Output — Returns the result to the user
  • +
+
+ +

Can I add these blocks for you?

+
+ `, + buttons: [ + { + text: "Back", + action: () => tour.back(), + classes: "shepherd-button-secondary", + }, + { + text: "Yes, Add Blocks", + action: () => tour.next(), + }, + ], + }, + + { + id: "blocks-added", + title: "Blocks Added! ✅", + text: ` +
+

I've added Agent Input and Agent Output blocks to your canvas.

+

Now let's configure them and connect everything together.

+
+

You now have 3 blocks:

+
    +
  • • Agent Input (for receiving data)
  • +
  • • Calculator (processes data)
  • +
  • • Agent Output (for sending results)
  • +
+
+
+ `, + beforeShowPromise: async () => { + addAgentIOBlocks(); + await waitForNodesCount(3, 5000); + await new Promise((resolve) => setTimeout(resolve, 500)); + fitViewToScreen(); + }, + buttons: [ + { + text: "Let's configure them", + action: () => tour.next(), + }, + ], + }, + + // STEP 12: Configure Agent Input Name + { + id: "configure-input-name", + title: "Configure Agent Input", + text: ` +
+

First, let's set up the Agent Input block.

+ +
+

⚠️ Required:

+
    +
  • + Enter a Name for the input (e.g., "number_a") +
  • +
+
+ ${banner(ICONS.ClickIcon, "Fill in the Name field in this block")} +
+ `, + attachTo: { + element: TUTORIAL_SELECTORS.AGENT_INPUT_NODE_FORM_CONTAINER, + on: "right", + }, + when: { + show: () => { + // Get the form container and manually position the popover + const formContainer = document.querySelector( + TUTORIAL_SELECTORS.AGENT_INPUT_NODE_FORM_CONTAINER, + ); + + // Get the Shepherd popover element and position it + const popover = document.querySelector(".shepherd-element"); + if (formContainer && popover) { + const rect = formContainer.getBoundingClientRect(); + (popover as HTMLElement).style.position = "fixed"; + (popover as HTMLElement).style.left = `${rect.left - 320}px`; // Position to the left + (popover as HTMLElement).style.top = `${rect.top}px`; + } + + const checkInterval = setInterval(() => { + const node = getNodeByBlockId(BLOCK_IDS.AGENT_INPUT); + if (!node) return; + + const hardcodedValues = node.data?.hardcodedValues || {}; + const hasName = + hardcodedValues.name && hardcodedValues.name.trim() !== ""; + + const reqName = document.querySelector("#req-input-name .req-icon"); + if (reqName) reqName.textContent = hasName ? "✓" : "○"; + + // Fix: Explicitly set the correct color class instead of just toggling + const reqNameEl = document.querySelector("#req-input-name"); + if (reqNameEl) { + if (hasName) { + reqNameEl.classList.remove("text-amber-700"); + reqNameEl.classList.add("text-green-700"); + } else { + reqNameEl.classList.remove("text-green-700"); + reqNameEl.classList.add("text-amber-700"); + } + } + + const nextBtn = document.querySelector( + ".shepherd-button-primary", + ) as HTMLButtonElement; + if (nextBtn) { + nextBtn.style.opacity = hasName ? "1" : "0.5"; + nextBtn.style.pointerEvents = hasName ? "auto" : "none"; + } + }, 300); + + (window as any).__tutorialInputNameInterval = checkInterval; + }, + hide: () => { + removeAllHighlights(); + if ((window as any).__tutorialInputNameInterval) { + clearInterval((window as any).__tutorialInputNameInterval); + delete (window as any).__tutorialInputNameInterval; + } + }, + }, + buttons: [ + { + text: "Back", + action: () => tour.back(), + classes: "shepherd-button-secondary", + }, + { + text: "Continue", + action: () => { + const node = getNodeByBlockId(BLOCK_IDS.AGENT_INPUT); + if (!node) return; + const hasName = node.data?.hardcodedValues?.name?.trim(); + if (hasName) tour.next(); + }, + classes: "shepherd-button-primary", + }, + ], + }, + + // STEP 13: Configure Agent Output Name + { + id: "configure-output-name", + title: "Configure Agent Output", + text: ` +
+

Now, let's set up the Agent Output block.

+ +
+

⚠️ Required:

+
    +
  • + Enter a Name for the output (e.g., "result") +
  • +
+
+ ${banner(ICONS.ClickIcon, "Fill in the Name field in this block")} +
+ `, + attachTo: { + element: TUTORIAL_SELECTORS.NAME_FIELD_OUTPUT_NODE, + on: "bottom", + }, + modalOverlayOpeningPadding: 10, + when: { + show: () => { + // Poll for name being set + const checkInterval = setInterval(() => { + const node = getNodeByBlockId(BLOCK_IDS.AGENT_OUTPUT); + if (!node) return; + + const hardcodedValues = node.data?.hardcodedValues || {}; + const hasName = + hardcodedValues.name && hardcodedValues.name.trim() !== ""; + + // Update requirement icon + const reqName = document.querySelector("#req-output-name .req-icon"); + if (reqName) reqName.textContent = hasName ? "✓" : "○"; + + // Fix: Explicitly set the correct color class instead of just toggling + const reqNameEl = document.querySelector("#req-output-name"); + if (reqNameEl) { + if (hasName) { + reqNameEl.classList.remove("text-amber-700"); + reqNameEl.classList.add("text-green-700"); + } else { + reqNameEl.classList.remove("text-green-700"); + reqNameEl.classList.add("text-amber-700"); + } + } + + const nextBtn = document.querySelector( + ".shepherd-button-primary", + ) as HTMLButtonElement; + if (nextBtn) { + nextBtn.style.opacity = hasName ? "1" : "0.5"; + nextBtn.style.pointerEvents = hasName ? "auto" : "none"; + } + nextBtn.disabled = !hasName; + }, 300); + + (window as any).__tutorialOutputNameInterval = checkInterval; + }, + hide: () => { + removeAllHighlights(); + if ((window as any).__tutorialOutputNameInterval) { + clearInterval((window as any).__tutorialOutputNameInterval); + delete (window as any).__tutorialOutputNameInterval; + } + }, + }, + buttons: [ + { + text: "Back", + action: () => tour.back(), + classes: "shepherd-button-secondary", + }, + { + text: "Continue", + action: () => { + const node = getNodeByBlockId(BLOCK_IDS.AGENT_OUTPUT); + if (!node) return; + const hasName = node.data?.hardcodedValues?.name?.trim(); + if (hasName) tour.next(); + }, + classes: "shepherd-button-primary", + }, + ], + }, +]; 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 000000000000..43087c23e923 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-basics.ts @@ -0,0 +1,126 @@ +/** + * Block basics steps - Steps 6-8 + * Understanding blocks, input handles, output handles + */ + +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"; + +/** + * Creates the block basics steps + */ +export const createBlockBasicsSteps = (tour: any): StepOptions[] => [ + // STEP 6: Focus on New Block + { + 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(), + }, + ], + }, + + // STEP 7: Input Handles + { + 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(), + }, + ], + }, + + // STEP 8: Output Handles + { + 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")} +
+ `, + 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 000000000000..262db58ee3e3 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-menu.ts @@ -0,0 +1,219 @@ +/** + * Block menu steps - Steps 2-5 + * Opening menu, overview, search, and select calculator + */ + +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"; + +/** + * Creates the block menu steps + */ +export const createBlockMenuSteps = (tour: any): StepOptions[] => [ + // STEP 2: Open Block Menu + { + 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")} +
+ `, + 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(); + }, + }, + }, + + // STEP 3: Block Menu Overview + { + 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(), + }, + ], + }, + + // STEP 4: Search for Calculator Block + { + 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")} +

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); + + // Blur the search input to prevent further typing + 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: [], + }, + + // STEP 5: Select Calculator Block + { + 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")} + ${banner(ICONS.Drag, "You can also drag blocks onto the canvas", "bg-zinc-100 ring-1 ring-zinc-600 text-zinc-700")} +
+ `, + 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: () => { + // Highlight any visible calculator block or the first block + const calcBlock = document.querySelector( + TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR, + ); + if (calcBlock) { + disableOtherBlocks(TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR); + } else { + // Highlight first available block + highlightFirstBlockInSearch(); + } + + // Calculator block_id from constants + const CALCULATOR_BLOCK_ID = BLOCK_IDS.CALCULATOR; + + // Store initial node count to detect additions + const initialNodeCount = useNodeStore.getState().nodes.length; + + // Subscribe to node store changes + const unsubscribe = useNodeStore.subscribe((state) => { + // Check if a new node was added + if (state.nodes.length > initialNodeCount) { + // Find if a Calculator node was added + const calculatorNode = state.nodes.find( + (node) => node.data?.block_id === CALCULATOR_BLOCK_ID, + ); + + if (calculatorNode) { + // Unsubscribe to prevent multiple triggers + unsubscribe(); + + // Clean up and close block menu + enableAllBlocks(); + forceBlockMenuOpen(false); + tour.next(); + } + } + }); + + // Store unsubscribe function on the step for cleanup in hide + (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 000000000000..e09145c3d032 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/completion.ts @@ -0,0 +1,142 @@ +/** + * Completion steps - Steps 22-25 + * Canvas controls, keyboard shortcuts, next steps, congratulations + */ + +import { StepOptions } from "shepherd.js"; +import { TUTORIAL_SELECTORS } from "../constants"; +import { waitForElement } from "../helpers"; +import { ICONS } from "../icons"; + +/** + * Creates the completion steps + */ +export const createCompletionSteps = (tour: any): StepOptions[] => [ + // STEP 22: Canvas Controls + { + id: "canvas-controls", + title: "Canvas Controls", + text: ` +
+

Use these controls to navigate:

+
    +
  • +/− — Zoom in/out
  • +
  • Fit View — Center all blocks
  • +
  • Lock — Prevent accidental moves
  • +
  • Tutorial — Restart this anytime
  • +
+
+ `, + attachTo: { + element: TUTORIAL_SELECTORS.CUSTOM_CONTROLS, + on: "right", + }, + beforeShowPromise: () => + waitForElement(TUTORIAL_SELECTORS.CUSTOM_CONTROLS, 3000).catch(() => {}), + buttons: [ + { + text: "Next", + action: () => tour.next(), + }, + ], + }, + + // STEP 23: Keyboard Shortcuts + { + id: "keyboard-shortcuts", + title: "Keyboard Shortcuts", + text: ` +
+

Speed up your workflow with shortcuts:

+
    +
  • Ctrl/Cmd + Z — Undo
  • +
  • Ctrl/Cmd + Y — Redo
  • +
  • Ctrl/Cmd + C — Copy block
  • +
  • Ctrl/Cmd + V — Paste block
  • +
  • Delete — Remove selected
  • +
+
+ `, + attachTo: { + element: TUTORIAL_SELECTORS.UNDO_BUTTON, + on: "right", + }, + beforeShowPromise: () => + waitForElement(TUTORIAL_SELECTORS.UNDO_BUTTON, 3000).catch(() => {}), + buttons: [ + { + text: "Next", + action: () => tour.next(), + }, + ], + }, + + // STEP 24: Next Steps + { + id: "next-steps", + title: "What's Next?", + text: ` +
+

You've built and run your first agent!

+

To build more complex agents:

+
    +
  • Add multiple blocks and connect them
  • +
  • Try AI blocks for intelligent processing
  • +
  • Explore Integrations for external services
  • +
  • Use Marketplace Agents as starting points
  • +
+
+ `, + buttons: [ + { + text: "Next", + action: () => tour.next(), + }, + ], + }, + + // STEP 25: Congratulations + { + id: "congratulations", + title: "Congratulations!", + text: ` +
+

You've completed the AutoGPT Builder tutorial!

+

You now know how to:

+
    +
  • ${ICONS.ClickIcon} Add blocks to the canvas
  • +
  • ${ICONS.ClickIcon} Configure block inputs and form fields
  • +
  • ${ICONS.ClickIcon} Connect blocks together
  • +
  • ${ICONS.ClickIcon} Save and run agents
  • +
  • ${ICONS.ClickIcon} View execution outputs
  • +
+

Happy building!

+
+ `, + when: { + show: () => { + const modal = document.querySelector( + ".shepherd-modal-overlay-container", + ); + if (modal) { + (modal as HTMLElement).style.opacity = "0.3"; + } + }, + }, + buttons: [ + { + text: "Restart", + 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 000000000000..8b4e48fd5028 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/configure-calculator.ts @@ -0,0 +1,156 @@ +/** + * Configure calculator step - Step 9 + * Enter values in calculator block + */ + +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"; + +/** + * Creates the configure calculator step + */ +export const createConfigureCalculatorSteps = (tour: any): StepOptions[] => [ + // STEP 9: Enter Values (Required) + { + id: "enter-values", + title: "Enter Values", + text: ` +
+

Now let's configure the block with actual values.

+ +
+

⚠️ 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.) +
  • +
+
+ ${banner(ICONS.ClickIcon, "Fill in all the required fields above")} +
+ `, + 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}"]`); + } + + // Start polling to update requirements UI and button visibility + 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 !== ""; + + // Update requirement icons + 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 ? "✓" : "○"; + + // Update styling for completed items + document + .querySelector("#req-a") + ?.classList.toggle("text-green-700", hasA); + document + .querySelector("#req-b") + ?.classList.toggle("text-green-700", hasB); + document + .querySelector("#req-op") + ?.classList.toggle("text-green-700", hasOp); + + // Show/hide the next button based on completion + const nextBtn = document.querySelector( + ".shepherd-button-primary", + ) as HTMLButtonElement; + if (nextBtn) { + const allComplete = hasA && hasB && hasOp; + nextBtn.style.opacity = allComplete ? "1" : "0.5"; + nextBtn.style.pointerEvents = allComplete ? "auto" : "none"; + } + }, 300); + + // Store interval ID for cleanup + (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 000000000000..0649941ca82c --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/connections.ts @@ -0,0 +1,375 @@ +import { StepOptions } from "shepherd.js"; +import { BLOCK_IDS, TUTORIAL_SELECTORS } from "../constants"; +import { + fitViewToScreen, + highlightElement, + removeAllHighlights, + pulseElement, + getNodeByBlockId, + isConnectionMade, +} from "../helpers"; +import { ICONS } from "../icons"; +import { banner } from "../styles"; +import { useEdgeStore } from "../../../../stores/edgeStore"; + +/** + * Creates the connection steps + */ +export const createConnectionSteps = (tour: any): StepOptions[] => { + let isConnecting = false; + + // Helper to detect when user starts dragging from output handle + const handleMouseDown = () => { + isConnecting = true; + setTimeout(() => { + if (isConnecting) { + tour.next(); + } + }, 100); + }; + + // Helper to reset connection state + const resetConnectionState = () => { + isConnecting = false; + }; + + return [ + // STEP 14a: Highlight Agent Input's OUTPUT handle - start dragging + { + id: "connect-input-output-handle", + title: "Connect Agent Input → Calculator (Step 1)", + text: ` +
+

Now let's connect the blocks together!

+ +
+

Drag from the output:

+

Click and drag from the result output handle (right side) of Agent Input block.

+
+ ${banner(ICONS.Drag, "Click and drag from the highlighted output handle")} +
+ `, + attachTo: { + element: TUTORIAL_SELECTORS.INPUT_BLOCK_RESULT_OUTPUT_HANDLEER, + on: "right", + }, + modalOverlayOpeningPadding: 10, + when: { + show: () => { + resetConnectionState(); + const inputNode = getNodeByBlockId(BLOCK_IDS.AGENT_INPUT); + if (inputNode) { + const outputHandle = document.querySelector( + TUTORIAL_SELECTORS.INPUT_BLOCK_RESULT_OUTPUT_HANDLEER, + ); + if (outputHandle) { + outputHandle.addEventListener("mousedown", handleMouseDown); + } + } + }, + hide: () => { + removeAllHighlights(); + const inputNode = getNodeByBlockId(BLOCK_IDS.AGENT_INPUT); + if (inputNode) { + const outputHandle = document.querySelector( + TUTORIAL_SELECTORS.INPUT_BLOCK_RESULT_OUTPUT_HANDLEER, + ); + if (outputHandle) { + outputHandle.removeEventListener("mousedown", handleMouseDown); + } + } + }, + }, + buttons: [ + { + text: "Back", + action: () => tour.back(), + classes: "shepherd-button-secondary", + }, + ], + }, + + // STEP 14b: Highlight Calculator's INPUT handle - complete connection + { + id: "connect-input-to-calculator-target", + title: "Connect Agent Input → Calculator (Step 2)", + text: ` +
+

Now connect to the Calculator's input!

+ +
+

Drop on the input:

+

Drag to the A or B input handle (left side) of the Calculator block.

+
+ +
+ Waiting for connection... +
+
+ `, + attachTo: { + element: TUTORIAL_SELECTORS.INPUT_BLOCK_RESULT_OUTPUT_HANDLEER, + on: "bottom", + }, + modalOverlayOpeningPadding: 10, + extraHighlights: [TUTORIAL_SELECTORS.CALCULATOR_NUMBER_A_INPUT_HANDLER], + when: { + show: () => { + // Subscribe to edge store changes to detect connection + const unsubscribe = useEdgeStore.subscribe(() => { + const connected = isConnectionMade( + BLOCK_IDS.AGENT_INPUT, + BLOCK_IDS.CALCULATOR, + ); + const statusEl = document.querySelector("#connection-status-1"); + + if (connected && statusEl) { + statusEl.innerHTML = "✅ Connected!"; + statusEl.classList.remove( + "bg-amber-100", + "ring-amber-500", + "text-amber-700", + ); + statusEl.classList.add("bg-green-100", "text-green-700"); + + // Auto-advance after brief delay + setTimeout(() => { + unsubscribe(); + tour.next(); + }, 500); + } + }); + + (tour.getCurrentStep() as any)._edgeUnsubscribe = unsubscribe; + + // Also handle mouseup to detect failed connection attempts + const handleMouseUp = (event: MouseEvent) => { + setTimeout(() => { + const connected = isConnectionMade( + BLOCK_IDS.AGENT_INPUT, + BLOCK_IDS.CALCULATOR, + ); + if (!connected) { + // Connection failed, go back to output handle step + isConnecting = false; + tour.show("connect-input-output-handle"); + } + }, 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-input-output-handle"), + classes: "shepherd-button-secondary", + }, + { + text: "Skip (already connected)", + action: () => tour.next(), + classes: "shepherd-button-secondary", + }, + ], + }, + + // STEP 15a: Highlight Calculator's OUTPUT handle - start dragging + { + id: "connect-calculator-output-handle", + title: "Connect Calculator → Agent Output (Step 1)", + text: ` +
+

Great! Now let's connect Calculator to Agent Output.

+ +
+

Drag from the output:

+

Click and drag from the result output handle (right side) of Calculator block.

+
+ ${banner(ICONS.Drag, "Click and drag from the highlighted output handle")} +
+ `, + attachTo: { + element: TUTORIAL_SELECTORS.CALCULATOR_RESULT_OUTPUT_HANDLEER, + on: "right", + }, + beforeShowPromise: async () => { + fitViewToScreen(); + return Promise.resolve(); + }, + when: { + show: () => { + resetConnectionState(); + const calcNode = getNodeByBlockId(BLOCK_IDS.CALCULATOR); + if (calcNode) { + const outputHandle = document.querySelector( + TUTORIAL_SELECTORS.CALCULATOR_RESULT_OUTPUT_HANDLEER, + ); + if (outputHandle) { + highlightElement( + TUTORIAL_SELECTORS.CALCULATOR_RESULT_OUTPUT_HANDLEER, + ); + outputHandle.addEventListener("mousedown", handleMouseDown); + } + } + }, + hide: () => { + removeAllHighlights(); + const calcNode = getNodeByBlockId(BLOCK_IDS.CALCULATOR); + if (calcNode) { + const outputHandle = document.querySelector( + TUTORIAL_SELECTORS.CALCULATOR_RESULT_OUTPUT_HANDLEER, + ); + if (outputHandle) { + outputHandle.removeEventListener("mousedown", handleMouseDown); + } + } + }, + }, + buttons: [ + { + text: "Back", + action: () => tour.back(), + classes: "shepherd-button-secondary", + }, + ], + }, + + // STEP 15b: Highlight Agent Output's INPUT handle - complete connection + { + id: "connect-calculator-to-output-target", + title: "Connect Calculator → Agent Output (Step 2)", + text: ` +
+

Now connect to the Agent Output's input!

+ +
+

Drop on the input:

+

Drag to the Value input handle (left side) of the Agent Output block.

+
+ +
+ Waiting for connection... +
+
+ `, + attachTo: { + element: TUTORIAL_SELECTORS.OUTPUT_VALUE_INPUT_HANDLEER, + on: "left", + }, + when: { + show: () => { + const outputNode = getNodeByBlockId(BLOCK_IDS.AGENT_OUTPUT); + + // Subscribe to edge store changes + const unsubscribe = useEdgeStore.subscribe(() => { + const connected = isConnectionMade( + BLOCK_IDS.CALCULATOR, + BLOCK_IDS.AGENT_OUTPUT, + ); + const statusEl = document.querySelector("#connection-status-2"); + + if (connected && statusEl) { + statusEl.innerHTML = "✅ Connected!"; + statusEl.classList.remove( + "bg-amber-100", + "ring-amber-500", + "text-amber-700", + ); + statusEl.classList.add("bg-green-100", "text-green-700"); + + setTimeout(() => { + unsubscribe(); + tour.next(); + }, 500); + } + }); + + (tour.getCurrentStep() as any)._edgeUnsubscribe = unsubscribe; + + // Handle failed connection attempts + const handleMouseUp = (event: MouseEvent) => { + setTimeout(() => { + const connected = isConnectionMade( + BLOCK_IDS.CALCULATOR, + BLOCK_IDS.AGENT_OUTPUT, + ); + if (!connected) { + isConnecting = false; + tour.show("connect-calculator-output-handle"); + } + }, 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-calculator-output-handle"), + classes: "shepherd-button-secondary", + }, + { + text: "Skip (already connected)", + action: () => tour.next(), + classes: "shepherd-button-secondary", + }, + ], + }, + + // STEP 16: Connections Complete (keep as-is) + { + id: "connections-complete", + title: "Connections Complete! 🎉", + text: ` +
+

Excellent! Your agent workflow is now connected:

+ +
+
+ Agent Input + + Calculator + + Agent Output +
+
+ +

Data will flow from input, through the calculator, and out as the result.

+

Now let's save 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 000000000000..ca633fcf1d9a --- /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 { createAgentIOSteps } from "./agent-io"; +import { createConnectionSteps } from "./connections"; +import { createSaveSteps } from "./save"; +import { createRunSteps } from "./run"; +import { createCompletionSteps } from "./completion"; + +export const createTutorialSteps = (tour: any): StepOptions[] => [ + ...createWelcomeSteps(tour), // Step 1 + ...createBlockMenuSteps(tour), // Steps 2-5 + ...createBlockBasicsSteps(tour), // Steps 6-8 + ...createConfigureCalculatorSteps(tour), // Step 9 + ...createAgentIOSteps(tour), // Steps 10-13 + ...createConnectionSteps(tour), // Steps 14-16 + ...createSaveSteps(tour), // Steps 17-18 + ...createRunSteps(tour), // Steps 19-21 + ...createCompletionSteps(tour), // Steps 22-25 +]; 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 000000000000..a7a81550bb68 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/run.ts @@ -0,0 +1,128 @@ +/** + * Run steps - Steps 19-21 + * Run the agent and check output + */ + +import { StepOptions } from "shepherd.js"; +import { TUTORIAL_SELECTORS } from "../constants"; +import { + waitForElement, + fitViewToScreen, + removeAllHighlights, + pulseElement, +} from "../helpers"; +import { ICONS } from "../icons"; +import { banner } from "../styles"; + +/** + * Creates the run steps + */ +export const createRunSteps = (tour: any): StepOptions[] => [ + // STEP 19: Run Button + { + id: "run-agent", + title: "Run Your Agent", + text: ` +
+

Your agent is saved! Now let's run it.

+ ${banner(ICONS.ClickIcon, "Click the Run button")} +
+ `, + attachTo: { + element: TUTORIAL_SELECTORS.RUN_BUTTON, + on: "top", + }, + advanceOn: { + selector: TUTORIAL_SELECTORS.RUN_BUTTON, + event: "click", + }, + beforeShowPromise: async () => { + await waitForElement(TUTORIAL_SELECTORS.RUN_BUTTON, 3000).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 500)); + }, + buttons: [], + when: { + show: () => { + pulseElement(TUTORIAL_SELECTORS.RUN_BUTTON); + }, + hide: () => { + removeAllHighlights(); + }, + }, + }, + + // STEP 20: Wait for Execution + { + id: "wait-execution", + title: "Processing...", + text: ` +
+

Your agent is running! Watch the block for status updates.

+

The badge will show: Queued → Running → Completed

+
+ `, + beforeShowPromise: async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + fitViewToScreen(); + }, + when: { + show: () => { + // Auto-advance when execution completes + const checkComplete = () => { + const completed = document.querySelector( + TUTORIAL_SELECTORS.BADGE_COMPLETED, + ); + const output = document.querySelector( + TUTORIAL_SELECTORS.NODE_LATEST_OUTPUT, + ); + if (completed || output) { + setTimeout(() => tour.next(), 500); + } else { + setTimeout(checkComplete, 500); + } + }; + setTimeout(checkComplete, 1000); + }, + }, + buttons: [ + { + text: "Skip wait", + action: () => tour.next(), + classes: "shepherd-button-secondary", + }, + ], + }, + + // STEP 21: Check Output + { + id: "check-output", + title: "View the Output", + text: ` +
+

The block has finished! Check the output at the bottom of the block.

+

This shows the result of your calculation.

+ ${banner(ICONS.ClickIcon, "Every block displays its output after execution")} +
+ `, + attachTo: { + element: TUTORIAL_SELECTORS.NODE_LATEST_OUTPUT, + on: "top", + }, + beforeShowPromise: () => + waitForElement(TUTORIAL_SELECTORS.NODE_LATEST_OUTPUT, 5000).catch( + () => {}, + ), + when: { + show: () => { + fitViewToScreen(); + }, + }, + buttons: [ + { + text: "Next", + 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 000000000000..1aec51d6d0a0 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/save.ts @@ -0,0 +1,83 @@ +/** + * Save steps - Steps 17-18 + * Save the agent + */ + +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"; + +/** + * Creates the save steps + */ +export const createSaveSteps = (tour: any): StepOptions[] => [ + // STEP 17: Save - Open Popover + { + id: "open-save", + title: "Save Your Agent", + text: ` +
+

Before running, we need to save your agent.

+ ${banner(ICONS.ClickIcon, "Click the Save button")} +
+ `, + 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(); + }, + }, + }, + + // STEP 18: Save - Fill Details + { + 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"')} +

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/welcome.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/welcome.ts new file mode 100644 index 000000000000..6158aad05fbf --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/welcome.ts @@ -0,0 +1,41 @@ +/** + * Welcome step - Step 1 + */ + +import { StepOptions } from "shepherd.js"; +import { handleTutorialSkip } from "../helpers"; + +/** + * Creates the welcome step + */ +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 000000000000..2ad6d2ef99f9 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/styles.ts @@ -0,0 +1,31 @@ +/** + * 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"); + } +}; + +// Some resulable components + +export const banner = (icon: string, content: string, className?: string) => ` +
+ ${icon} + ${content} +
+`; 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 000000000000..3e3521239d60 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/tutorial.css @@ -0,0 +1,218 @@ +/* ============================================ + Tutorial Styles for New Builder + Prefix: new-builder- to avoid collision with old builder + + Note: Typography classes (body, small, content, action, etc.) + are now using Tailwind utilities directly in steps.ts + ============================================ */ + +/* ============================================ + Disabled state - pointer-events disabled, dimmed + Class name: new-builder-tutorial-disable (matches CSS_CLASSES.DISABLE) + ============================================ */ +.new-builder-tutorial-disable { + pointer-events: none !important; + opacity: 0.4 !important; + filter: grayscale(50%); +} + +/* ============================================ + Highlight state - brings element to front with glow + Class name: new-builder-tutorial-highlight (matches CSS_CLASSES.HIGHLIGHT) + ============================================ */ +/* .new-builder-tutorial-highlight { + position: relative; + z-index: 10000 !important; + opacity: 1 !important; + filter: none !important; + box-shadow: + 0 0 0 4px white, + 0 0 0 6px #7c3aed, + 0 0 30px 8px rgba(124, 58, 237, 0.4) !important; + border-radius: 1rem; +} */ + +.new-builder-tutorial-highlight * { + opacity: 1 !important; + filter: none !important; +} + +/* ============================================ + Pulse animation for attention + Class name: new-builder-tutorial-pulse (matches CSS_CLASSES.PULSE) + ============================================ */ +.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.js - Main element container + SCOPED: Only applies to new builder tutorial (.new-builder-tour) + ============================================ */ +.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.js - Header section + ============================================ */ +.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.js - Text content + ============================================ */ +.shepherd-element.new-builder-tour .shepherd-text { + padding: 0 1.25rem 1rem !important; + color: #52525b !important; /* zinc-600 */ +} + +/* ============================================ + Shepherd.js - Footer section + ============================================ */ +.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.js - Button base styles + ============================================ */ +.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.js - Primary button + ============================================ */ +.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.js - Secondary button + ============================================ */ +.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.js - Cancel/close icon + ============================================ */ +.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.js - Arrow styling + ============================================ */ +.shepherd-element.new-builder-tour .shepherd-arrow { + transform: scale(1.2) !important; +} + +.shepherd-element.new-builder-tour .shepherd-arrow:before { + background: white !important; +} + +/* ============================================ + Shepherd.js - Placement spacing (12px = 0.75rem) + ============================================ */ +.shepherd-element.new-builder-tour[data-popper-placement^="top"] { + margin-bottom: 20px !important; +} + +.shepherd-element.new-builder-tour[data-popper-placement^="bottom"] { + margin-top: 20px !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 435aa62c6161..10f4fc8a4425 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 (