diff --git a/apps/builder/src/features/blocks/logic/loop/components/LoopIcon.tsx b/apps/builder/src/features/blocks/logic/loop/components/LoopIcon.tsx new file mode 100644 index 0000000000..0710d0f74e --- /dev/null +++ b/apps/builder/src/features/blocks/logic/loop/components/LoopIcon.tsx @@ -0,0 +1,17 @@ +import { featherIconsBaseProps } from "@/components/icons"; +import { Icon, type IconProps, useColorModeValue } from "@chakra-ui/react"; +import React from "react"; + +export const LoopIcon = (props: IconProps) => ( + + + + + + +); diff --git a/apps/builder/src/features/blocks/logic/loop/components/LoopNodeContent.tsx b/apps/builder/src/features/blocks/logic/loop/components/LoopNodeContent.tsx new file mode 100644 index 0000000000..75004468e1 --- /dev/null +++ b/apps/builder/src/features/blocks/logic/loop/components/LoopNodeContent.tsx @@ -0,0 +1,18 @@ +import { Text } from "@chakra-ui/react"; +import { defaultLoopOptions } from "@typebot.io/blocks-logic/loop/constants"; +import type { LoopBlock } from "@typebot.io/blocks-logic/loop/schema"; +import React from "react"; + +type Props = { + options?: LoopBlock["options"]; +}; + +export const LoopNodeContent = ({ options }: Props) => { + const iterations = options?.iterations ?? defaultLoopOptions.iterations; + + return ( + + {iterations ? `Repeat ${iterations} times` : "Configure..."} + + ); +}; diff --git a/apps/builder/src/features/blocks/logic/loop/components/LoopSettings.tsx b/apps/builder/src/features/blocks/logic/loop/components/LoopSettings.tsx new file mode 100644 index 0000000000..cdc33c2213 --- /dev/null +++ b/apps/builder/src/features/blocks/logic/loop/components/LoopSettings.tsx @@ -0,0 +1,33 @@ +import { TextInput } from "@/components/inputs"; +import { Stack } from "@chakra-ui/react"; +import { defaultLoopOptions } from "@typebot.io/blocks-logic/loop/constants"; +import type { LoopBlock } from "@typebot.io/blocks-logic/loop/schema"; +import React from "react"; + +type Props = { + options: LoopBlock["options"]; + onOptionsChange: (options: LoopBlock["options"]) => void; +}; + +export const LoopSettings = ({ options, onOptionsChange }: Props) => { + const handleIterationsChange = (iterationsStr: string) => { + const iterations = Number.parseInt(iterationsStr); + if (isNaN(iterations)) return; + onOptionsChange({ ...options, iterations }); + }; + + return ( + + + + ); +}; diff --git a/apps/builder/src/features/editor/components/BlockIcon.tsx b/apps/builder/src/features/editor/components/BlockIcon.tsx index bfc7c11340..e5a6362520 100644 --- a/apps/builder/src/features/editor/components/BlockIcon.tsx +++ b/apps/builder/src/features/editor/components/BlockIcon.tsx @@ -28,6 +28,7 @@ import { ZapierLogo } from "@/features/blocks/integrations/zapier/components/Zap import { AbTestIcon } from "@/features/blocks/logic/abTest/components/AbTestIcon"; import { ConditionIcon } from "@/features/blocks/logic/condition/components/ConditionIcon"; import { JumpIcon } from "@/features/blocks/logic/jump/components/JumpIcon"; +import { LoopIcon } from "@/features/blocks/logic/loop/components/LoopIcon"; import { RedirectIcon } from "@/features/blocks/logic/redirect/components/RedirectIcon"; import { ReturnBlockIcon } from "@/features/blocks/logic/return/components/ReturnBlockIcon"; import { ScriptIcon } from "@/features/blocks/logic/script/components/ScriptIcon"; @@ -107,6 +108,8 @@ export const BlockIcon = ({ type, ...props }: BlockIconProps): JSX.Element => { return ; case LogicBlockType.RETURN: return ; + case LogicBlockType.LOOP: + return ; case LogicBlockType.WEBHOOK: return ; case IntegrationBlockType.GOOGLE_SHEETS: diff --git a/apps/builder/src/features/editor/components/BlockLabel.tsx b/apps/builder/src/features/editor/components/BlockLabel.tsx index 3393962e05..9d48fa4833 100644 --- a/apps/builder/src/features/editor/components/BlockLabel.tsx +++ b/apps/builder/src/features/editor/components/BlockLabel.tsx @@ -81,6 +81,7 @@ export const getLogicBlockLabel = ( [LogicBlockType.AB_TEST]: t("editor.sidebarBlock.abTest.label"), [LogicBlockType.WEBHOOK]: "Webhook", [LogicBlockType.RETURN]: "Return", + [LogicBlockType.LOOP]: t("editor.sidebarBlock.loop.label"), }); export const getIntegrationBlockLabel = ( diff --git a/apps/builder/src/features/graph/components/nodes/block/BlockNodeContent.tsx b/apps/builder/src/features/graph/components/nodes/block/BlockNodeContent.tsx index add67a25ee..bd57243a03 100644 --- a/apps/builder/src/features/graph/components/nodes/block/BlockNodeContent.tsx +++ b/apps/builder/src/features/graph/components/nodes/block/BlockNodeContent.tsx @@ -29,6 +29,7 @@ import { ZapierContent } from "@/features/blocks/integrations/zapier/components/ import { AbTestNodeBody } from "@/features/blocks/logic/abTest/components/AbTestNodeBody"; import { ConditionBlockContent } from "@/features/blocks/logic/condition/components/ConditionBlockContent"; import { JumpNodeBody } from "@/features/blocks/logic/jump/components/JumpNodeBody"; +import { LoopNodeContent } from "@/features/blocks/logic/loop/components/LoopNodeContent"; import { RedirectNodeContent } from "@/features/blocks/logic/redirect/components/RedirectNodeContent"; import { ReturnBlockNodeContent } from "@/features/blocks/logic/return/components/ReturnBlockNodeContent"; import { ScriptNodeContent } from "@/features/blocks/logic/script/components/ScriptNodeContent"; @@ -135,6 +136,8 @@ export const BlockNodeContent = ({ return ; case LogicBlockType.WEBHOOK: return ; + case LogicBlockType.LOOP: + return ; case LogicBlockType.RETURN: return ; case IntegrationBlockType.GOOGLE_SHEETS: { diff --git a/apps/builder/src/features/graph/components/nodes/block/SettingsPopoverContent.tsx b/apps/builder/src/features/graph/components/nodes/block/SettingsPopoverContent.tsx index 928a19ff40..30f8b1aa04 100644 --- a/apps/builder/src/features/graph/components/nodes/block/SettingsPopoverContent.tsx +++ b/apps/builder/src/features/graph/components/nodes/block/SettingsPopoverContent.tsx @@ -23,6 +23,7 @@ import { SendEmailSettings } from "@/features/blocks/integrations/sendEmail/comp import { ZapierSettings } from "@/features/blocks/integrations/zapier/components/ZapierSettings"; import { AbTestSettings } from "@/features/blocks/logic/abTest/components/AbTestSettings"; import { JumpSettings } from "@/features/blocks/logic/jump/components/JumpSettings"; +import { LoopSettings } from "@/features/blocks/logic/loop/components/LoopSettings"; import { RedirectSettings } from "@/features/blocks/logic/redirect/components/RedirectSettings"; import { ScriptSettings } from "@/features/blocks/logic/script/components/ScriptSettings"; import { SetVariableSettings } from "@/features/blocks/logic/setVariable/components/SetVariableSettings"; @@ -310,6 +311,11 @@ export const NodeSettings = ({ <> ); } + case LogicBlockType.LOOP: { + return ( + + ); + } case LogicBlockType.AB_TEST: { return ( ; diff --git a/packages/blocks/logic/src/schema.ts b/packages/blocks/logic/src/schema.ts index 21488af828..e908fa4029 100644 --- a/packages/blocks/logic/src/schema.ts +++ b/packages/blocks/logic/src/schema.ts @@ -2,6 +2,7 @@ import { z } from "@typebot.io/zod"; import { abTestBlockSchemas } from "./abTest/schema"; import { conditionBlockSchemas } from "./condition/schema"; import { jumpBlockSchema } from "./jump/schema"; +import { loopBlockSchema } from "./loop/schema"; import { redirectBlockSchema } from "./redirect/schema"; import { returnBlockSchema } from "./return/schema"; import { scriptBlockSchema } from "./script/schema"; @@ -18,6 +19,7 @@ const logicBlockSchemas = [ waitBlockSchema, jumpBlockSchema, returnBlockSchema, + loopBlockSchema, ] as const; export const logicBlockV5Schema = z.discriminatedUnion("type", [ diff --git a/packages/bot-engine/src/blocks/logic/loop/executeLoopBlock.ts b/packages/bot-engine/src/blocks/logic/loop/executeLoopBlock.ts new file mode 100644 index 0000000000..a60020a040 --- /dev/null +++ b/packages/bot-engine/src/blocks/logic/loop/executeLoopBlock.ts @@ -0,0 +1,95 @@ +import { createId } from "@paralleldrive/cuid2"; +import { + MAX_ITERATIONS, + defaultLoopOptions, +} from "@typebot.io/blocks-logic/loop/constants"; +import type { LoopBlock } from "@typebot.io/blocks-logic/loop/schema"; +import type { SessionState } from "@typebot.io/chat-session/schemas"; +import type { SessionStore } from "@typebot.io/runtime-session-store"; +import { addVirtualEdge } from "../../../addPortalEdge"; +import type { ExecuteLogicResponse } from "../../../types"; + +export class LoopGuardrailError extends Error { + constructor(message: string) { + super(message); + this.name = "LoopGuardrailError"; + } +} + +export const executeLoopBlock = ( + block: LoopBlock, + { state, sessionStore }: { state: SessionState; sessionStore: SessionStore }, +): ExecuteLogicResponse => { + const loopOptions = block.options ?? defaultLoopOptions; + const { loopConfiguration, currentBlockId } = state; + + // Get the loop index and total iterations + const existingConfiguration = loopConfiguration?.[block.id]; + const loopIndex = existingConfiguration?.index ?? 0; + const totalIterations = + loopOptions.iterations ?? defaultLoopOptions.iterations; + + // Check if we've exceeded the maximum iterations guard rail + if (totalIterations > MAX_ITERATIONS) { + return { + outgoingEdgeId: null, + logs: [ + { + status: "error", + description: `Loop iterations (${totalIterations}) exceeds maximum allowed (${MAX_ITERATIONS})`, + }, + ], + }; + } + + // Store the loop index in a variable if this is the first iteration + if (!existingConfiguration) { + sessionStore.setVariable("loopIndex", 0); + } else { + sessionStore.setVariable("loopIndex", loopIndex); + } + + // Check if we've completed all iterations + if (loopIndex >= totalIterations) { + // Reset loop configuration for this block + const { [block.id]: _, ...restLoopConfig } = state.loopConfiguration ?? {}; + const newState: SessionState = { + ...state, + loopConfiguration: restLoopConfig, + }; + + // Exit the loop + return { + outgoingEdgeId: block.outgoingEdgeId, + newSessionState: newState, + }; + } // Create a virtual edge to create a loop + + // Find the group ID of the current block + const currentGroup = state.typebotsQueue[0].typebot.groups.find((group) => + group.blocks.some((b) => b.id === currentBlockId), + ); + + const groupId = currentGroup?.id ?? block.id; + + const { edgeId, newSessionState } = addVirtualEdge(state, { + to: { groupId, blockId: currentBlockId ?? block.id }, + }); + + // Increment the loop index for the next iteration + const updatedSessionState: SessionState = { + ...newSessionState, + loopConfiguration: { + ...newSessionState.loopConfiguration, + [block.id]: { + index: loopIndex + 1, + }, + }, + }; + + // Continue the loop + return { + outgoingEdgeId: edgeId, + newSessionState: updatedSessionState, + }; +}; diff --git a/packages/bot-engine/src/executeLogic.ts b/packages/bot-engine/src/executeLogic.ts index 09091ae7d2..d8fb13d155 100644 --- a/packages/bot-engine/src/executeLogic.ts +++ b/packages/bot-engine/src/executeLogic.ts @@ -6,6 +6,7 @@ import type { SetVariableHistoryItem } from "@typebot.io/variables/schemas"; import { executeAbTest } from "./blocks/logic/abTest/executeAbTest"; import { executeConditionBlock } from "./blocks/logic/condition/executeConditionBlock"; import { executeJumpBlock } from "./blocks/logic/jump/executeJumpBlock"; +import { executeLoopBlock } from "./blocks/logic/loop/executeLoopBlock"; import { executeRedirect } from "./blocks/logic/redirect/executeRedirect"; import { executeReturnBlock } from "./blocks/logic/return/executeReturnBlock"; import { executeScript } from "./blocks/logic/script/executeScript"; @@ -51,5 +52,7 @@ export const executeLogic = async ({ return executeWebhookBlock(block); case LogicBlockType.RETURN: return executeReturnBlock(state); + case LogicBlockType.LOOP: + return executeLoopBlock(block, { state, sessionStore }); } }; diff --git a/packages/bot-engine/src/resetSessionState.ts b/packages/bot-engine/src/resetSessionState.ts index 153574d596..9a805eec21 100644 --- a/packages/bot-engine/src/resetSessionState.ts +++ b/packages/bot-engine/src/resetSessionState.ts @@ -6,6 +6,7 @@ export const resetSessionState = (state: SessionState): SessionState => ({ currentVisitedEdgeIndex: undefined, previewMetadata: undefined, progressMetadata: undefined, + loopConfiguration: undefined, typebotsQueue: state.typebotsQueue.map((queueItem) => ({ ...queueItem, answers: [], diff --git a/packages/chat-session/src/schemas.ts b/packages/chat-session/src/schemas.ts index e8c9d868ec..81dd07fa54 100644 --- a/packages/chat-session/src/schemas.ts +++ b/packages/chat-session/src/schemas.ts @@ -142,6 +142,13 @@ const sessionStateSchemaV3 = sessionStateSchemaV2 setVariableIdsForHistory: z.array(z.string()).optional(), currentSetVariableHistoryIndex: z.number().optional(), workspaceId: z.string(), + loopConfiguration: z + .record( + z.object({ + index: z.number(), + }), + ) + .optional(), previewMetadata: z .object({ answers: z.array(answerSchema).optional(), diff --git a/packages/runtime-session-store/src/index.ts b/packages/runtime-session-store/src/index.ts index 27b1e7714e..a911fa28b2 100644 --- a/packages/runtime-session-store/src/index.ts +++ b/packages/runtime-session-store/src/index.ts @@ -8,12 +8,14 @@ export class SessionStore { private emailSendingCount: number; private prevHash: string | undefined; private createdAt: Date; + private variableStore: Record; constructor() { this.isolate = undefined; this.emailSendingCount = 0; this.prevHash = undefined; this.createdAt = new Date(); + this.variableStore = {}; } getEmailSendingCount(): number { @@ -47,6 +49,14 @@ export class SessionStore { getCreatedAt(): Date { return this.createdAt; } + + setVariable(name: string, value: any): void { + this.variableStore[name] = value; + } + + getVariable(name: string): any { + return this.variableStore[name]; + } } export const sessionStores = new Map();