mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend): add interactive tutorial for the new builder interface (#11458)
### Changes 🏗️ This PR adds a comprehensive interactive tutorial for the new Builder UI to help users learn how to create agents. Key changes include: - Added a tutorial button to the canvas controls that launches a step-by-step guide - Created a Shepherd.js-based tutorial with multiple steps covering: - Adding blocks from the Block Menu - Understanding input and output handles - Configuring block values - Connecting blocks together - Saving and running agents - Added data-id attributes to key UI elements for tutorial targeting - Implemented tutorial state management with a new tutorialStore - Added helper functions for tutorial navigation and block manipulation - Created CSS styles for tutorial tooltips and highlights - Integrated with the Run Input dialog to support tutorial flow - Added prefetching of tutorial blocks for better performance https://github.com/user-attachments/assets/3db964b3-855c-4fcc-aa5f-6cd74ab33d7d ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Complete the tutorial from start to finish - [x] Test tutorial on different screen sizes - [x] Verify all tutorial steps work correctly - [x] Ensure tutorial can be canceled and restarted - [x] Check that tutorial doesn't interfere with normal builder functionality
This commit is contained in:
@@ -10,7 +10,10 @@ export const BuilderActions = memo(() => {
|
||||
flowID: parseAsString,
|
||||
});
|
||||
return (
|
||||
<div className="absolute bottom-4 left-[50%] z-[100] flex -translate-x-1/2 items-center gap-4 rounded-full bg-white p-2 px-2 shadow-lg">
|
||||
<div
|
||||
data-id="builder-actions"
|
||||
className="absolute bottom-4 left-[50%] z-[100] flex -translate-x-1/2 items-center gap-4 rounded-full bg-white p-2 px-2 shadow-lg"
|
||||
>
|
||||
<AgentOutputs flowID={flowID} />
|
||||
<RunGraph flowID={flowID} />
|
||||
<ScheduleGraph flowID={flowID} />
|
||||
|
||||
@@ -79,6 +79,7 @@ export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
data-id="agent-outputs-button"
|
||||
disabled={!flowID || !hasOutputs()}
|
||||
>
|
||||
<BookOpenIcon className="size-4" />
|
||||
|
||||
@@ -31,6 +31,7 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
<Button
|
||||
size="icon"
|
||||
variant={isGraphRunning ? "destructive" : "primary"}
|
||||
data-id={isGraphRunning ? "stop-graph-button" : "run-graph-button"}
|
||||
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
|
||||
disabled={!flowID || isExecutingGraph || isTerminatingGraph}
|
||||
loading={isExecutingGraph || isTerminatingGraph || isSaving}
|
||||
|
||||
@@ -7,10 +7,11 @@ import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { GraphExecutionMeta } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/use-agent-runs";
|
||||
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSaveGraph } from "@/app/(platform)/build/hooks/useSaveGraph";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { ApiError } from "@/lib/autogpt-server-api/helpers"; // Check if this exists
|
||||
import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore";
|
||||
|
||||
export const useRunGraph = () => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ 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";
|
||||
|
||||
export const RunInputDialog = ({
|
||||
isOpen,
|
||||
@@ -37,6 +39,21 @@ export const RunInputDialog = ({
|
||||
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 (
|
||||
<>
|
||||
<Dialog
|
||||
@@ -48,16 +65,16 @@ export const RunInputDialog = ({
|
||||
styling={{ maxWidth: "600px", minWidth: "600px" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="space-y-6 p-1">
|
||||
<div className="space-y-6 p-1" data-id="run-input-dialog-content">
|
||||
{/* Credentials Section */}
|
||||
{hasCredentials() && (
|
||||
<div>
|
||||
<div data-id="run-input-credentials-section">
|
||||
<div className="mb-4">
|
||||
<Text variant="h4" className="text-gray-900">
|
||||
Credentials
|
||||
</Text>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="px-2" data-id="run-input-credentials-form">
|
||||
<FormRenderer
|
||||
jsonSchema={credentialsSchema as RJSFSchema}
|
||||
handleChange={(v) => handleCredentialChange(v.formData)}
|
||||
@@ -75,27 +92,32 @@ export const RunInputDialog = ({
|
||||
|
||||
{/* Inputs Section */}
|
||||
{hasInputs() && (
|
||||
<div>
|
||||
<div data-id="run-input-inputs-section">
|
||||
<div className="mb-4">
|
||||
<Text variant="h4" className="text-gray-900">
|
||||
Inputs
|
||||
</Text>
|
||||
</div>
|
||||
<FormRenderer
|
||||
jsonSchema={inputSchema as RJSFSchema}
|
||||
handleChange={(v) => handleInputChange(v.formData)}
|
||||
uiSchema={uiSchema}
|
||||
initialValues={{}}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "large",
|
||||
}}
|
||||
/>
|
||||
<div data-id="run-input-inputs-form">
|
||||
<FormRenderer
|
||||
jsonSchema={inputSchema as RJSFSchema}
|
||||
handleChange={(v) => handleInputChange(v.formData)}
|
||||
uiSchema={uiSchema}
|
||||
initialValues={{}}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "large",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<div
|
||||
className="flex justify-end pt-2"
|
||||
data-id="run-input-actions-section"
|
||||
>
|
||||
{purpose === "run" && (
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -103,6 +125,7 @@ export const RunInputDialog = ({
|
||||
className="group h-fit min-w-0 gap-2"
|
||||
onClick={handleManualRun}
|
||||
loading={isExecutingGraph}
|
||||
data-id="run-input-manual-run-button"
|
||||
>
|
||||
{!isExecutingGraph && (
|
||||
<PlayIcon className="size-5 transition-transform group-hover:scale-110" />
|
||||
@@ -116,6 +139,7 @@ export const RunInputDialog = ({
|
||||
size="large"
|
||||
className="group h-fit min-w-0 gap-2"
|
||||
onClick={() => setOpenCronSchedulerDialog(true)}
|
||||
data-id="run-input-schedule-button"
|
||||
>
|
||||
<ClockIcon className="size-5 transition-transform group-hover:scale-110" />
|
||||
<span className="font-semibold">Schedule Run</span>
|
||||
|
||||
@@ -26,6 +26,7 @@ export const ScheduleGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
data-id="schedule-graph-button"
|
||||
onClick={handleScheduleGraph}
|
||||
disabled={!flowID}
|
||||
>
|
||||
|
||||
@@ -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,27 +27,65 @@ 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 = [
|
||||
{
|
||||
id: "zoom-in-button",
|
||||
icon: <PlusIcon className="size-4" />,
|
||||
label: "Zoom In",
|
||||
onClick: () => zoomIn(),
|
||||
className: "h-10 w-10 border-none",
|
||||
},
|
||||
{
|
||||
id: "zoom-out-button",
|
||||
icon: <MinusIcon className="size-4" />,
|
||||
label: "Zoom Out",
|
||||
onClick: () => zoomOut(),
|
||||
className: "h-10 w-10 border-none",
|
||||
},
|
||||
{
|
||||
id: "tutorial-button",
|
||||
icon: isTutorialLoading ? (
|
||||
<CircleNotchIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ChalkboardIcon className="size-4" />
|
||||
),
|
||||
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: <FrameCornersIcon className="size-4" />,
|
||||
label: "Fit View",
|
||||
onClick: () => fitView({ padding: 0.2, duration: 800, maxZoom: 1 }),
|
||||
className: "h-10 w-10 border-none",
|
||||
},
|
||||
{
|
||||
id: "lock-button",
|
||||
icon: !isLocked ? (
|
||||
<LockOpenIcon className="size-4" />
|
||||
) : (
|
||||
@@ -55,15 +98,20 @@ export const CustomControls = memo(
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-4 z-10 flex flex-col items-center gap-2 rounded-full bg-white px-1 py-2 shadow-lg">
|
||||
{controls.map((control, index) => (
|
||||
<Tooltip key={index} delayDuration={300}>
|
||||
<div
|
||||
data-id="custom-controls"
|
||||
className="absolute bottom-4 left-4 z-10 flex flex-col items-center gap-2 rounded-full bg-white px-1 py-2 shadow-lg"
|
||||
>
|
||||
{controls.map((control) => (
|
||||
<Tooltip key={control.id} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="icon"
|
||||
size={"small"}
|
||||
onClick={control.onClick}
|
||||
className={control.className}
|
||||
data-id={control.id}
|
||||
disabled={"disabled" in control ? control.disabled : false}
|
||||
>
|
||||
{control.icon}
|
||||
<span className="sr-only">{control.label}</span>
|
||||
|
||||
@@ -26,6 +26,7 @@ const InputNodeHandle = ({
|
||||
position={Position.Left}
|
||||
id={cleanedHandleId}
|
||||
className={"-ml-6 mr-2"}
|
||||
data-tutorial-id={`input-handler-${nodeId}-${cleanedHandleId}`}
|
||||
>
|
||||
<div className="pointer-events-none">
|
||||
<CircleIcon
|
||||
@@ -62,6 +63,7 @@ const OutputNodeHandle = ({
|
||||
position={Position.Right}
|
||||
id={field_name}
|
||||
className={"-mr-2 ml-2"}
|
||||
data-tutorial-id={`output-handler-${nodeId}-${field_name}`}
|
||||
>
|
||||
<div className="pointer-events-none">
|
||||
<CircleIcon
|
||||
|
||||
@@ -27,6 +27,7 @@ export const NodeContainer = ({
|
||||
status && nodeStyleBasedOnStatus[status],
|
||||
hasErrors ? nodeStyleBasedOnStatus[AgentExecutionStatus.FAILED] : "",
|
||||
)}
|
||||
data-id={`custom-node-${nodeId}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,10 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-b-xl border-t border-zinc-200 px-4 py-4">
|
||||
<div
|
||||
data-tutorial-id={`node-output`}
|
||||
className="flex flex-col gap-3 rounded-b-xl border-t border-zinc-200 px-4 py-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="body-medium" className="!font-semibold text-slate-700">
|
||||
Node Output
|
||||
|
||||
@@ -44,7 +44,10 @@ export const FormCreator: React.FC<FormCreatorProps> = React.memo(
|
||||
: hardcodedValues;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
className={className}
|
||||
data-id={`form-creator-container-${nodeId}-node`}
|
||||
>
|
||||
<FormRenderer
|
||||
jsonSchema={jsonSchema}
|
||||
handleChange={handleChange}
|
||||
|
||||
@@ -52,7 +52,11 @@ export const OutputHandler = ({
|
||||
const isBroken = brokenOutputs.has(fullKey);
|
||||
|
||||
return shouldShow ? (
|
||||
<div key={fullKey} className="flex flex-col items-end gap-2">
|
||||
<div
|
||||
key={fullKey}
|
||||
className="flex flex-col items-end gap-2"
|
||||
data-tutorial-id={`output-handler-${nodeId}-${fieldTitle}`}
|
||||
>
|
||||
<div className="relative flex items-center gap-2">
|
||||
{fieldSchema?.description && (
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -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;
|
||||
@@ -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<string, BlockInfo> = new Map();
|
||||
|
||||
export const prefetchTutorialBlocks = async (): Promise<void> => {
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { TUTORIAL_CONFIG, TUTORIAL_SELECTORS } from "../constants";
|
||||
import { useNodeStore } from "../../../../stores/nodeStore";
|
||||
|
||||
export const waitForNodeOnCanvas = (
|
||||
timeout = 10000,
|
||||
): Promise<Element | null> => {
|
||||
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<boolean> => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import { TUTORIAL_CONFIG, TUTORIAL_SELECTORS } from "../constants";
|
||||
|
||||
export const waitForElement = (
|
||||
selector: string,
|
||||
timeout = 10000,
|
||||
): Promise<Element> => {
|
||||
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<void> => {
|
||||
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<Element | null> => {
|
||||
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<Element | null> => {
|
||||
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);
|
||||
};
|
||||
};
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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 }));
|
||||
}
|
||||
};
|
||||
@@ -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 !== "-");
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// These are SVG Phosphor icons
|
||||
|
||||
export const ICONS = {
|
||||
ClickIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="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"></path></svg>`,
|
||||
Keyboard: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="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"></path></svg>`,
|
||||
Drag: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="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"></path></svg>`,
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
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";
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
tour.on("cancel", () => {
|
||||
handleTutorialCancel(tour);
|
||||
removeTutorialStyles();
|
||||
clearPrefetchedBlocks();
|
||||
});
|
||||
|
||||
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();
|
||||
};
|
||||
@@ -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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Excellent! This is your <strong>Calculator Block</strong>.</p>
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.5rem;">Let's explore how blocks work.</p>
|
||||
</div>
|
||||
`,
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">On the <strong>left side</strong> of the block are <strong>input handles</strong>.</p>
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.5rem;">These are where data flows <em>into</em> the block from other blocks.</p>
|
||||
</div>
|
||||
`,
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">On the <strong>right side</strong> is the <strong>output handle</strong>.</p>
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.5rem;">This is where the result flows <em>out</em> to connect to other blocks.</p>
|
||||
${banner(ICONS.Drag, "You can drag from output to input handler to connect blocks", "info")}
|
||||
</div>
|
||||
`,
|
||||
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(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Let's start by opening the Block Menu.</p>
|
||||
${banner(ICONS.ClickIcon, "Click this button to open the menu", "action")}
|
||||
</div>
|
||||
`,
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">This is the <strong>Block Menu</strong> — your toolbox for building agents.</p>
|
||||
<p class="text-sm font-medium leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.5rem;">Here you'll find:</p>
|
||||
<ul>
|
||||
<li><strong>Input Blocks</strong> — Entry points for data</li>
|
||||
<li><strong>Action Blocks</strong> — Processing and AI operations</li>
|
||||
<li><strong>Output Blocks</strong> — Results and responses</li>
|
||||
<li><strong>Integrations</strong> — Third-party service blocks</li>
|
||||
<li><strong>Library Agents</strong> — Your personal agents</li>
|
||||
<li><strong>Marketplace Agents</strong> — Community agents</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Let's add a Calculator block to start.</p>
|
||||
${banner(ICONS.Keyboard, "Type Calculator in the search bar", "action")}
|
||||
<p class="text-xs font-normal leading-[1.125rem] text-zinc-500 m-0" style="margin-top: 0.5rem;">The search will filter blocks as you type.</p>
|
||||
</div>
|
||||
`,
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">You should see the <strong>Calculator</strong> block in the results.</p>
|
||||
${banner(ICONS.ClickIcon, "Click on the Calculator block to add it", "action")}
|
||||
|
||||
<div class="bg-zinc-100 ring-1 ring-zinc-200 rounded-2xl p-2 px-4 mt-2 flex items-start gap-2 text-sm font-medium text-zinc-600">
|
||||
<span class="flex-shrink-0">${ICONS.Drag}</span>
|
||||
<span>You can also drag blocks onto the canvas</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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;
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,51 @@
|
||||
import { StepOptions } from "shepherd.js";
|
||||
|
||||
export const createCompletionSteps = (tour: any): StepOptions[] => [
|
||||
{
|
||||
id: "congratulations",
|
||||
title: "Congratulations! 🎉",
|
||||
text: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">You have successfully created and run your first agent flow!</p>
|
||||
|
||||
<div class="mt-3 p-3 bg-green-50 ring-1 ring-green-200 rounded-2xl">
|
||||
<p class="text-sm font-medium text-green-600 m-0">You learned how to:</p>
|
||||
<ul class="text-[0.8125rem] text-green-600 m-0 pl-4 mt-2 space-y-1">
|
||||
<li>• Add blocks from the Block Menu</li>
|
||||
<li>• Understand input and output handles</li>
|
||||
<li>• Configure block values</li>
|
||||
<li>• Connect blocks together</li>
|
||||
<li>• Save and run your agent</li>
|
||||
<li>• View execution status and output</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="text-sm font-medium leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.75rem;">Happy building! 🚀</p>
|
||||
</div>
|
||||
`,
|
||||
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(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 = () => `
|
||||
<div id="requirements-box" class="mt-3 p-3 bg-amber-50 ring-1 ring-amber-200 rounded-2xl">
|
||||
<p id="requirements-title" class="text-sm font-medium text-amber-600 m-0 mb-2">⚠️ Required to continue:</p>
|
||||
<ul id="requirements-list" class="text-[0.8125rem] text-amber-600 m-0 pl-4 space-y-1">
|
||||
<li id="req-a" class="flex items-center gap-2">
|
||||
<span class="req-icon">○</span> Enter a number in field <strong>A</strong> (e.g., 10)
|
||||
</li>
|
||||
<li id="req-b" class="flex items-center gap-2">
|
||||
<span class="req-icon">○</span> Enter a number in field <strong>B</strong> (e.g., 5)
|
||||
</li>
|
||||
<li id="req-op" class="flex items-center gap-2">
|
||||
<span class="req-icon">○</span> Select an <strong>Operation</strong> (Add, Multiply, etc.)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Now let's configure the block with actual values.</p>
|
||||
${getRequirementsHtml()}
|
||||
${banner(ICONS.ClickIcon, "Fill in all the required fields above", "action")}
|
||||
</div>
|
||||
`,
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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) => `
|
||||
<div id="${id}" class="mt-3 p-2 ${isConnected ? "bg-green-50 ring-1 ring-green-200" : "bg-amber-50 ring-1 ring-amber-200"} rounded-2xl text-center text-sm ${isConnected ? "text-green-600" : "text-amber-600"}">
|
||||
${isConnected ? "✅ Connected!" : "Waiting for connection..."}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Now, let's connect the <strong>Result output</strong> of the first Calculator to the <strong>input (A)</strong> of the second Calculator.</p>
|
||||
|
||||
<div class="mt-3 p-3 bg-blue-50 ring-1 ring-blue-200 rounded-2xl">
|
||||
<p class="text-sm font-medium text-blue-600 m-0 mb-2">Drag from the Result output:</p>
|
||||
<p class="text-[0.8125rem] text-blue-600 m-0">Click and drag from the <strong>Result</strong> output pin (right side) of the <strong>first Calculator block</strong>.</p>
|
||||
</div>
|
||||
${getConnectionStatusHtml("connection-status-output", false)}
|
||||
${banner(ICONS.Drag, "Drag from the Result output pin", "action")}
|
||||
</div>
|
||||
`,
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Now, connect to the <strong>input (A)</strong> of the second Calculator block.</p>
|
||||
|
||||
<div class="mt-3 p-3 bg-blue-50 ring-1 ring-blue-200 rounded-2xl">
|
||||
<p class="text-sm font-medium text-blue-600 m-0 mb-2">Drop on the A input:</p>
|
||||
<p class="text-[0.8125rem] text-blue-600 m-0">Drag to the <strong>A</strong> input handle (left side) of the <strong>second Calculator block</strong>.</p>
|
||||
</div>
|
||||
${getConnectionStatusHtml("connection-status-input", false)}
|
||||
</div>
|
||||
`,
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Excellent! Your Calculator blocks are now connected:</p>
|
||||
|
||||
<div class="mt-3 p-3 bg-green-50 ring-1 ring-green-200 rounded-2xl">
|
||||
<div class="flex items-center justify-center gap-2 text-sm font-medium text-green-600">
|
||||
<span>Calculator 1</span>
|
||||
<span>→</span>
|
||||
<span>Calculator 2</span>
|
||||
</div>
|
||||
<p class="text-[0.75rem] text-green-500 m-0 mt-2 text-center italic">The result of Calculator 1 flows into Calculator 2's input A</p>
|
||||
</div>
|
||||
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.75rem;">Now let's save and run your agent!</p>
|
||||
</div>
|
||||
`,
|
||||
beforeShowPromise: async () => {
|
||||
fitViewToScreen();
|
||||
return Promise.resolve();
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
text: "Save My Agent",
|
||||
action: () => tour.next(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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),
|
||||
];
|
||||
@@ -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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Your agent is saved and ready! Now let's <strong>run it</strong> to see it in action.</p>
|
||||
${banner(ICONS.ClickIcon, "Click the Run button", "action")}
|
||||
</div>
|
||||
`,
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Here's the <strong>output</strong> of your block!</p>
|
||||
|
||||
<div class="mt-3 p-3 bg-blue-50 ring-1 ring-blue-200 rounded-2xl">
|
||||
<p class="text-sm font-medium text-blue-600 m-0">Latest Output:</p>
|
||||
<p class="text-[0.8125rem] text-blue-600 m-0 mt-1">After each run, you can see the result of each block at the bottom of the block.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 p-2 bg-zinc-100 ring-1 ring-zinc-200 rounded-xl">
|
||||
<p class="text-[0.8125rem] text-zinc-600 m-0">The output shows:</p>
|
||||
<ul class="text-[0.8125rem] text-zinc-500 m-0 mt-1 pl-4">
|
||||
<li>• The calculated result</li>
|
||||
<li>• Execution timestamp</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Before running, we need to <strong>save</strong> your agent.</p>
|
||||
${banner(ICONS.ClickIcon, "Click the Save button", "action")}
|
||||
</div>
|
||||
`,
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Give your agent a <strong>name</strong> and optional description.</p>
|
||||
${banner(ICONS.ClickIcon, 'Enter a name and click "Save Agent"', "action")}
|
||||
<p class="text-xs font-normal leading-[1.125rem] text-zinc-500 m-0" style="margin-top: 0.5rem;">Example: "My Calculator Agent"</p>
|
||||
</div>
|
||||
`,
|
||||
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: [],
|
||||
},
|
||||
];
|
||||
@@ -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 = () => `
|
||||
<div id="second-calc-requirements-box" class="mt-3 p-3 bg-amber-50 ring-1 ring-amber-200 rounded-2xl">
|
||||
<p id="second-calc-requirements-title" class="text-sm font-medium text-amber-600 m-0 mb-2">⚠️ Required to continue:</p>
|
||||
<ul id="second-calc-requirements-list" class="text-[0.8125rem] text-amber-600 m-0 pl-4 space-y-1">
|
||||
<li id="req2-b" class="flex items-center gap-2">
|
||||
<span class="req-icon">○</span> Enter a number in field <strong>B</strong> (e.g., 2)
|
||||
</li>
|
||||
<li id="req2-op" class="flex items-center gap-2">
|
||||
<span class="req-icon">○</span> Select an <strong>Operation</strong> (e.g., Multiply)
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-[0.75rem] text-amber-500 m-0 mt-2 italic">Note: Field A will be connected from the first Calculator's output</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Great job configuring the first Calculator!</p>
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.5rem;">Now let's add a <strong>second Calculator block</strong> and connect them together.</p>
|
||||
|
||||
<div class="mt-3 p-3 bg-blue-50 ring-1 ring-blue-200 rounded-2xl">
|
||||
<p class="text-sm font-medium text-blue-600 m-0 mb-1">We'll create a chain:</p>
|
||||
<p class="text-[0.8125rem] text-blue-600 m-0">Calculator 1 → Calculator 2</p>
|
||||
<p class="text-[0.75rem] text-blue-500 m-0 mt-1 italic">The output of the first will feed into the second!</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">I've added a <strong>second Calculator block</strong> to your canvas.</p>
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.5rem;">Now let's configure it and connect them together.</p>
|
||||
|
||||
<div class="mt-3 p-3 bg-green-50 ring-1 ring-green-200 rounded-2xl">
|
||||
<p class="text-sm font-medium text-green-600 m-0">You now have 2 Calculator blocks!</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Now configure the <strong>second Calculator block</strong>.</p>
|
||||
${getSecondCalcRequirementsHtml()}
|
||||
${banner(ICONS.ClickIcon, "Fill in field B and select an Operation", "action")}
|
||||
</div>
|
||||
`,
|
||||
beforeShowPromise: async () => {
|
||||
fitViewToScreen();
|
||||
await new Promise<void>((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",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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: `
|
||||
<div class="text-sm leading-[1.375rem] text-zinc-800">
|
||||
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">This interactive tutorial will teach you how to build your first AI agent.</p>
|
||||
<p class="text-sm font-medium leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.75rem;">You'll learn how to:</p>
|
||||
<ul class="pl-2 text-sm pt-2">
|
||||
<li>- Add blocks to your workflow</li>
|
||||
<li>- Understand block inputs and outputs</li>
|
||||
<li>- Save and run your agent</li>
|
||||
<li>- and much more...</li>
|
||||
</ul>
|
||||
<p class="text-xs font-normal leading-[1.125rem] text-zinc-500 m-0" style="margin-top: 0.75rem;">Estimated time: 3-4 minutes</p>
|
||||
</div>
|
||||
`,
|
||||
buttons: [
|
||||
{
|
||||
text: "Skip Tutorial",
|
||||
action: () => handleTutorialSkip(tour),
|
||||
classes: "shepherd-button-secondary",
|
||||
},
|
||||
{
|
||||
text: "Let's Begin",
|
||||
action: () => tour.next(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 `
|
||||
<div class="${styles.bg} ring-1 ${styles.ring} rounded-2xl p-2 px-4 mt-2 flex items-start gap-2 text-sm font-medium ${styles.text} ${className || ""}">
|
||||
<span class="flex-shrink-0">${icon}</span>
|
||||
<span>${content}</span>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
// Requirement box components
|
||||
export const requirementBox = (
|
||||
title: string,
|
||||
items: string,
|
||||
variant: "warning" | "success" = "warning",
|
||||
) => {
|
||||
const isSuccess = variant === "success";
|
||||
return `
|
||||
<div id="requirements-box" class="mt-3 p-3 ${isSuccess ? "bg-green-50 ring-1 ring-green-200" : "bg-amber-50 ring-1 ring-amber-200"} rounded-2xl">
|
||||
<p class="text-sm font-medium ${isSuccess ? "text-green-600" : "text-amber-600"} m-0 mb-2">${title}</p>
|
||||
${items}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
export const requirementItem = (id: string, content: string) => `
|
||||
<li id="${id}" class="flex items-center gap-2 text-amber-600">
|
||||
<span class="req-icon">○</span> ${content}
|
||||
</li>
|
||||
`;
|
||||
|
||||
// Connection status box
|
||||
export const connectionStatusBox = (
|
||||
id: string,
|
||||
variant: "waiting" | "connected" = "waiting",
|
||||
) => {
|
||||
const isConnected = variant === "connected";
|
||||
return `
|
||||
<div id="${id}" class="mt-3 p-2 ${isConnected ? "bg-green-50 ring-1 ring-green-200" : "bg-amber-50 ring-1 ring-amber-200"} rounded-2xl text-center text-sm ${isConnected ? "text-green-600" : "text-amber-600"}">
|
||||
${isConnected ? "✅ Connection already exists!" : "Waiting for connection..."}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Button
|
||||
draggable={true}
|
||||
data-id={blockDataId}
|
||||
className={cn(
|
||||
"group flex h-16 w-full min-w-[7.5rem] items-center justify-start space-x-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
|
||||
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:cursor-not-allowed",
|
||||
|
||||
@@ -14,10 +14,17 @@ import { ControlPanelButton } from "../../ControlPanelButton";
|
||||
import { BlockMenuContent } from "../BlockMenuContent/BlockMenuContent";
|
||||
|
||||
export const BlockMenu = () => {
|
||||
const { blockMenuOpen, setBlockMenuOpen } = useControlPanelStore();
|
||||
const { blockMenuOpen, setBlockMenuOpen, forceOpenBlockMenu } =
|
||||
useControlPanelStore();
|
||||
return (
|
||||
// pinBlocksPopover ? true : open
|
||||
<Popover onOpenChange={setBlockMenuOpen} open={blockMenuOpen}>
|
||||
<Popover
|
||||
onOpenChange={(open) => {
|
||||
if (!forceOpenBlockMenu || open) {
|
||||
setBlockMenuOpen(open);
|
||||
}
|
||||
}}
|
||||
open={forceOpenBlockMenu ? true : blockMenuOpen}
|
||||
>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger className="hover:cursor-pointer">
|
||||
|
||||
@@ -5,7 +5,10 @@ import { BlockMenuSearchContent } from "../BlockMenuSearchContent/BlockMenuSearc
|
||||
|
||||
export const BlockMenuSearch = () => {
|
||||
return (
|
||||
<div className={blockMenuContainerStyle}>
|
||||
<div
|
||||
className={blockMenuContainerStyle}
|
||||
data-id="blocks-control-search-results"
|
||||
>
|
||||
<BlockMenuFilters />
|
||||
<Text variant="body-medium">Search results</Text>
|
||||
<BlockMenuSearchContent />
|
||||
|
||||
@@ -22,6 +22,7 @@ export const BlockMenuSearchBar: React.FC<BlockMenuSearchBarProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-id="blocks-control-search-bar"
|
||||
className={cn(
|
||||
"flex min-h-[3.5625rem] items-center gap-2.5 px-4",
|
||||
className,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -8,6 +8,7 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
selected?: boolean;
|
||||
number?: number;
|
||||
name?: string;
|
||||
menuItemType?: string;
|
||||
}
|
||||
|
||||
export const MenuItem: React.FC<Props> = ({
|
||||
@@ -15,10 +16,12 @@ export const MenuItem: React.FC<Props> = ({
|
||||
number,
|
||||
name,
|
||||
className,
|
||||
menuItemType,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
data-id={menuItemType ? `menu-item-${menuItemType}` : undefined}
|
||||
className={cn(
|
||||
"flex h-[2.375rem] w-[12.875rem] justify-between whitespace-normal rounded-[0.5rem] bg-transparent p-2 pl-3 shadow-none",
|
||||
"hover:cursor-default hover:bg-zinc-100 focus:ring-0",
|
||||
|
||||
@@ -19,10 +19,18 @@ import { useNewSaveControl } from "./useNewSaveControl";
|
||||
|
||||
export const NewSaveControl = () => {
|
||||
const { form, isSaving, graphVersion, handleSave } = useNewSaveControl();
|
||||
const { saveControlOpen, setSaveControlOpen } = useControlPanelStore();
|
||||
const { saveControlOpen, setSaveControlOpen, forceOpenSave } =
|
||||
useControlPanelStore();
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setSaveControlOpen}>
|
||||
<Popover
|
||||
onOpenChange={(open) => {
|
||||
if (!forceOpenSave || open) {
|
||||
setSaveControlOpen(open);
|
||||
}
|
||||
}}
|
||||
open={forceOpenSave ? true : saveControlOpen}
|
||||
>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -42,7 +42,12 @@ export const UndoRedoButtons = () => {
|
||||
<>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<ControlPanelButton as="button" disabled={!canUndo()} onClick={undo}>
|
||||
<ControlPanelButton
|
||||
as="button"
|
||||
data-id="undo-button"
|
||||
disabled={!canUndo()}
|
||||
onClick={undo}
|
||||
>
|
||||
<ArrowUUpLeftIcon className="size-5" />
|
||||
</ControlPanelButton>
|
||||
</TooltipTrigger>
|
||||
@@ -51,7 +56,12 @@ export const UndoRedoButtons = () => {
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<ControlPanelButton as="button" disabled={!canRedo()} onClick={redo}>
|
||||
<ControlPanelButton
|
||||
as="button"
|
||||
data-id="redo-button"
|
||||
disabled={!canRedo()}
|
||||
onClick={redo}
|
||||
>
|
||||
<ArrowUUpRightIcon className="size-5" />
|
||||
</ControlPanelButton>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -83,6 +83,7 @@ export const BuildActionBar: React.FC<Props> = ({
|
||||
title="Run the agent"
|
||||
aria-label="Run the agent"
|
||||
data-testid="primary-action-run-agent"
|
||||
data-tutorial-id="primary-action-run-agent"
|
||||
>
|
||||
<IconPlay /> Run
|
||||
</Button>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<ControlPanelStore>((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,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -47,6 +47,7 @@ const dragStartPositions: Record<string, XYPosition> = {};
|
||||
type NodeStore = {
|
||||
nodes: CustomNode[];
|
||||
nodeCounter: number;
|
||||
setNodeCounter: (nodeCounter: number) => void;
|
||||
nodeAdvancedStates: Record<string, boolean>;
|
||||
setNodes: (nodes: CustomNode[]) => void;
|
||||
onNodesChange: (changes: NodeChange<CustomNode>[]) => void;
|
||||
@@ -116,6 +117,7 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
nodes: [],
|
||||
setNodes: (nodes) => set({ nodes }),
|
||||
nodeCounter: 0,
|
||||
setNodeCounter: (nodeCounter) => set({ nodeCounter }),
|
||||
nodeAdvancedStates: {},
|
||||
incrementNodeCounter: () =>
|
||||
set((state) => ({
|
||||
|
||||
@@ -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<string, any>;
|
||||
setTutorialInputValues: (values: Record<string, any>) => void;
|
||||
};
|
||||
|
||||
export const useTutorialStore = create<TutorialStore>((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 }),
|
||||
}));
|
||||
@@ -3,9 +3,14 @@
|
||||
import React from "react";
|
||||
import { useTallyPopup } from "./useTallyPopup";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
export function TallyPopupSimple() {
|
||||
const { state, handlers } = useTallyPopup();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const isNewBuilder =
|
||||
pathname.includes("build") && searchParams.get("view") === "new";
|
||||
|
||||
if (state.isFormVisible) {
|
||||
return null;
|
||||
@@ -13,7 +18,7 @@ export function TallyPopupSimple() {
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-1 right-0 z-20 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
|
||||
{state.showTutorial && (
|
||||
{state.showTutorial && !isNewBuilder && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handlers.handleResetTutorial}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const FormRenderer = ({
|
||||
}, [preprocessedSchema, uiSchema]);
|
||||
|
||||
return (
|
||||
<div className={"mb-6 mt-4"}>
|
||||
<div className={"mb-6 mt-4"} data-tutorial-id="input-handles">
|
||||
<Form
|
||||
formContext={formContext}
|
||||
idPrefix="agpt"
|
||||
|
||||
@@ -28,12 +28,14 @@ export default function TitleField(props: TitleFieldProps) {
|
||||
|
||||
const showHandle = uiOptions.showHandles ?? showHandles;
|
||||
|
||||
const fieldPath = id.replace(/^agpt_%_/, "");
|
||||
const tutorialId = nodeId ? `label-${nodeId}-${fieldPath}` : undefined;
|
||||
const isInputBroken = useNodeStore((state) =>
|
||||
state.isInputBroken(nodeId, cleanUpHandleId(uiOptions.handleId)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center" data-tutorial-id={tutorialId}>
|
||||
{showHandle !== false && (
|
||||
<InputNodeHandle handleId={uiOptions.handleId} nodeId={nodeId} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user