Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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) => (
<Icon
viewBox="0 0 24 24"
color={useColorModeValue("purple.500", "purple.300")}
{...featherIconsBaseProps}
{...props}
>
<path d="M17 2.1l4 4-4 4" />
<path d="M3 12.2v-2a4 4 0 0 1 4-4h12.8" />
<path d="M7 21.9l-4-4 4-4" />
<path d="M21 11.8v2a4 4 0 0 1-4 4H4.2" />
</Icon>
);
Original file line number Diff line number Diff line change
@@ -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 (
<Text color={iterations ? "currentcolor" : "gray.500"} noOfLines={1}>
{iterations ? `Repeat ${iterations} times` : "Configure..."}
</Text>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<Stack spacing={4}>
<TextInput
label="Iterations:"
type="number"
defaultValue={(
options?.iterations ?? defaultLoopOptions.iterations
).toString()}
onChange={handleIterationsChange}
withVariableButton={false}
placeholder="Number of times to repeat (max 100)"
/>
</Stack>
);
};
3 changes: 3 additions & 0 deletions apps/builder/src/features/editor/components/BlockIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -107,6 +108,8 @@ export const BlockIcon = ({ type, ...props }: BlockIconProps): JSX.Element => {
return <AbTestIcon color={purple} {...props} />;
case LogicBlockType.RETURN:
return <ReturnBlockIcon color={purple} {...props} />;
case LogicBlockType.LOOP:
return <LoopIcon color={purple} {...props} />;
case LogicBlockType.WEBHOOK:
return <WebhookIcon {...props} />;
case IntegrationBlockType.GOOGLE_SHEETS:
Expand Down
1 change: 1 addition & 0 deletions apps/builder/src/features/editor/components/BlockLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -135,6 +136,8 @@ export const BlockNodeContent = ({
return <ConditionBlockContent block={block} indices={indices} />;
case LogicBlockType.WEBHOOK:
return <WebhookNodeContent options={block.options} />;
case LogicBlockType.LOOP:
return <LoopNodeContent options={block.options} />;
case LogicBlockType.RETURN:
return <ReturnBlockNodeContent />;
case IntegrationBlockType.GOOGLE_SHEETS: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -310,6 +311,11 @@ export const NodeSettings = ({
<></>
);
}
case LogicBlockType.LOOP: {
return (
<LoopSettings options={node.options} onOptionsChange={updateOptions} />
);
}
case LogicBlockType.AB_TEST: {
return (
<AbTestSettings
Expand Down
1 change: 1 addition & 0 deletions packages/blocks/logic/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export enum LogicBlockType {
WEBHOOK = "webhook",
JUMP = "Jump",
RETURN = "Return",
LOOP = "Loop",
}
7 changes: 7 additions & 0 deletions packages/blocks/logic/src/loop/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { LoopBlock } from "./schema";

export const MAX_ITERATIONS = 100;

export const defaultLoopOptions = {
iterations: 3,
} as const satisfies LoopBlock["options"];
21 changes: 21 additions & 0 deletions packages/blocks/logic/src/loop/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { blockBaseSchema } from "@typebot.io/blocks-base/schemas";
import { z } from "@typebot.io/zod";
import { LogicBlockType } from "../constants";

export const loopOptionsSchema = z.object({
iterations: z.number().int().min(1).max(100).default(3),
});

export const loopBlockSchema = blockBaseSchema
.merge(
z.object({
type: z.enum([LogicBlockType.LOOP]),
options: loopOptionsSchema.optional(),
}),
)
.openapi({
title: "Loop",
ref: "loopLogic",
});

export type LoopBlock = z.infer<typeof loopBlockSchema>;
2 changes: 2 additions & 0 deletions packages/blocks/logic/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -18,6 +19,7 @@ const logicBlockSchemas = [
waitBlockSchema,
jumpBlockSchema,
returnBlockSchema,
loopBlockSchema,
] as const;

export const logicBlockV5Schema = z.discriminatedUnion("type", [
Expand Down
95 changes: 95 additions & 0 deletions packages/bot-engine/src/blocks/logic/loop/executeLoopBlock.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
3 changes: 3 additions & 0 deletions packages/bot-engine/src/executeLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
}
};
1 change: 1 addition & 0 deletions packages/bot-engine/src/resetSessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
7 changes: 7 additions & 0 deletions packages/chat-session/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
10 changes: 10 additions & 0 deletions packages/runtime-session-store/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ export class SessionStore {
private emailSendingCount: number;
private prevHash: string | undefined;
private createdAt: Date;
private variableStore: Record<string, any>;

constructor() {
this.isolate = undefined;
this.emailSendingCount = 0;
this.prevHash = undefined;
this.createdAt = new Date();
this.variableStore = {};
}

getEmailSendingCount(): number {
Expand Down Expand Up @@ -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<string, SessionStore>();
Expand Down