+ {data.node?.outputs![index].allows_loop && (
+
+
+
+ )}
handleUpdateOutputHide()}
@@ -324,7 +379,7 @@ function NodeOutputField({
: "Please build the component first"
}
>
-
+
+ {looping && (
+
+ Looping
+
+ )}
diff --git a/src/frontend/src/constants/alerts_constants.tsx b/src/frontend/src/constants/alerts_constants.tsx
index 679b19da1..62c2a0d19 100644
--- a/src/frontend/src/constants/alerts_constants.tsx
+++ b/src/frontend/src/constants/alerts_constants.tsx
@@ -1,5 +1,7 @@
// ERROR
export const MISSED_ERROR_ALERT = "Oops! Looks like you missed something";
+export const INCOMPLETE_LOOP_ERROR_ALERT =
+ "The flow has an incomplete loop. Check your connections and try again.";
export const INVALID_FILE_ALERT =
"Please select a valid file. Only these file types are allowed:";
export const CONSOLE_ERROR_MSG = "Error occurred while uploading file";
diff --git a/src/frontend/src/stores/flowStore.ts b/src/frontend/src/stores/flowStore.ts
index 016cfac7f..db980623d 100644
--- a/src/frontend/src/stores/flowStore.ts
+++ b/src/frontend/src/stores/flowStore.ts
@@ -41,6 +41,7 @@ import {
scapedJSONStringfy,
unselectAllNodesEdges,
updateGroupRecursion,
+ validateEdge,
validateNodes,
} from "../utils/reactflowUtils";
import { getInputsAndOutputs } from "../utils/storeUtils";
@@ -606,6 +607,25 @@ const useFlowStore = create((set, get) => ({
const setSuccessData = useAlertStore.getState().setSuccessData;
const setErrorData = useAlertStore.getState().setErrorData;
const setNoticeData = useAlertStore.getState().setNoticeData;
+
+ const edges = get().edges;
+ let error = false;
+ for (const edge of edges) {
+ const errors = validateEdge(edge, get().nodes, edges);
+ if (errors.length > 0) {
+ error = true;
+ setErrorData({
+ title: MISSED_ERROR_ALERT,
+ list: errors,
+ });
+ }
+ }
+ if (error) {
+ get().setIsBuilding(false);
+ get().setLockChat(false);
+ throw new Error("Invalid components");
+ }
+
function validateSubgraph(nodes: string[]) {
const errorsObjs = validateNodes(
get().nodes.filter((node) => nodes.includes(node.id)),
diff --git a/src/frontend/src/types/api/index.ts b/src/frontend/src/types/api/index.ts
index 6d9e0bba2..650f4f80f 100644
--- a/src/frontend/src/types/api/index.ts
+++ b/src/frontend/src/types/api/index.ts
@@ -4,7 +4,6 @@ import {
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
-import { Edge, Node, Viewport } from "@xyflow/react";
import { ChatInputType, ChatOutputType } from "../chat";
import { FlowType } from "../flow";
//kind and class are just representative names to represent the actual structure of the object received by the API
@@ -103,6 +102,7 @@ export type OutputFieldType = {
display_name: string;
hidden?: boolean;
proxy?: OutputFieldProxyType;
+ allows_loop?: boolean;
};
export type errorsTypeAPI = {
function: { errors: Array };
diff --git a/src/frontend/src/types/flow/index.ts b/src/frontend/src/types/flow/index.ts
index 197c521b1..881d842eb 100644
--- a/src/frontend/src/types/flow/index.ts
+++ b/src/frontend/src/types/flow/index.ts
@@ -101,8 +101,10 @@ export type sourceHandleType = {
//left side
export type targetHandleType = {
inputTypes?: string[];
+ output_types?: string[];
type: string;
fieldName: string;
+ name?: string;
id: string;
proxy?: { field: string; id: string };
};
diff --git a/src/frontend/src/utils/reactflowUtils.ts b/src/frontend/src/utils/reactflowUtils.ts
index 8aa4dff72..6d1442a4b 100644
--- a/src/frontend/src/utils/reactflowUtils.ts
+++ b/src/frontend/src/utils/reactflowUtils.ts
@@ -2,9 +2,11 @@ import {
getLeftHandleId,
getRightHandleId,
} from "@/CustomNodes/utils/get-handle-id";
+import { INCOMPLETE_LOOP_ERROR_ALERT } from "@/constants/alerts_constants";
import {
Connection,
Edge,
+ getOutgoers,
Node,
OnSelectionChangeParams,
ReactFlowJsonObject,
@@ -18,8 +20,8 @@ import {
IS_MAC,
LANGFLOW_SUPPORTED_TYPES,
OUTPUT_TYPES,
- SUCCESS_BUILD,
specialCharsRegex,
+ SUCCESS_BUILD,
} from "../constants/constants";
import { DESCRIPTIONS } from "../flow_constants";
import {
@@ -68,14 +70,39 @@ export function cleanEdges(nodes: AllNodeType[], edges: EdgeType[]) {
if (targetHandle) {
const targetHandleObject: targetHandleType = scapeJSONParse(targetHandle);
const field = targetHandleObject.fieldName;
- const id: targetHandleType = {
- type: targetNode.data.node!.template[field]?.type,
- fieldName: field,
- id: targetNode.data.id,
- inputTypes: targetNode.data.node!.template[field]?.input_types,
- };
- if (targetNode.data.node!.template[field]?.proxy) {
- id.proxy = targetNode.data.node!.template[field]?.proxy;
+ let id: targetHandleType | sourceHandleType;
+
+ const templateFieldType = targetNode.data.node!.template[field]?.type;
+ const inputTypes = targetNode.data.node!.template[field]?.input_types;
+ const hasProxy = targetNode.data.node!.template[field]?.proxy;
+
+ if (
+ !field &&
+ targetHandleObject.name &&
+ targetNode.type === "genericNode"
+ ) {
+ const dataType = targetNode.data.type;
+ const outputTypes =
+ targetNode.data.node!.outputs?.find(
+ (output) => output.name === targetHandleObject.name,
+ )?.types ?? [];
+
+ id = {
+ dataType: dataType ?? "",
+ name: targetHandleObject.name,
+ id: targetNode.data.id,
+ output_types: outputTypes,
+ };
+ } else {
+ id = {
+ type: templateFieldType,
+ fieldName: field,
+ id: targetNode.data.id,
+ inputTypes: inputTypes,
+ };
+ if (hasProxy) {
+ id.proxy = targetNode.data.node!.template[field]?.proxy;
+ }
}
if (scapedJSONStringfy(id) !== targetHandle) {
newEdges = newEdges.filter((e) => e.id !== edge.id);
@@ -132,7 +159,9 @@ export function detectBrokenEdgesEdges(nodes: AllNodeType[], edges: Edge[]) {
displayName: targetNode.data.node!.display_name,
field:
targetNode.data.node!.template[targetHandleObject.fieldName]
- ?.display_name ?? targetHandleObject.fieldName,
+ ?.display_name ??
+ targetHandleObject.fieldName ??
+ targetHandleObject.name,
},
};
}
@@ -161,14 +190,39 @@ export function detectBrokenEdgesEdges(nodes: AllNodeType[], edges: Edge[]) {
if (targetHandle) {
const targetHandleObject: targetHandleType = scapeJSONParse(targetHandle);
const field = targetHandleObject.fieldName;
- const id: targetHandleType = {
- type: targetNode.data.node!.template[field]?.type,
- fieldName: field,
- id: targetNode.data.id,
- inputTypes: targetNode.data.node!.template[field]?.input_types,
- };
- if (targetNode.data.node!.template[field]?.proxy) {
- id.proxy = targetNode.data.node!.template[field]?.proxy;
+ let id: sourceHandleType | targetHandleType;
+
+ const templateFieldType = targetNode.data.node!.template[field]?.type;
+ const inputTypes = targetNode.data.node!.template[field]?.input_types;
+ const hasProxy = targetNode.data.node!.template[field]?.proxy;
+
+ if (
+ !field &&
+ targetHandleObject.name &&
+ targetNode.type === "genericNode"
+ ) {
+ const dataType = targetNode.data.type;
+ const outputTypes =
+ targetNode.data.node!.outputs?.find(
+ (output) => output.name === targetHandleObject.name,
+ )?.types ?? [];
+
+ id = {
+ dataType: dataType ?? "",
+ name: targetHandleObject.name,
+ id: targetNode.data.id,
+ output_types: outputTypes,
+ };
+ } else {
+ id = {
+ type: templateFieldType,
+ fieldName: field,
+ id: targetNode.data.id,
+ inputTypes: inputTypes,
+ };
+ if (hasProxy) {
+ id.proxy = targetNode.data.node!.template[field]?.proxy;
+ }
}
if (scapedJSONStringfy(id) !== targetHandle) {
newEdges = newEdges.filter((e) => e.id !== edge.id);
@@ -219,7 +273,7 @@ export function isValidConnection(
{ source, target, sourceHandle, targetHandle }: Connection,
nodes: AllNodeType[],
edges: EdgeType[],
-) {
+): boolean {
if (source === target) {
return false;
}
@@ -229,6 +283,13 @@ export function isValidConnection(
targetHandleObject.inputTypes?.some(
(n) => n === sourceHandleObject.dataType,
) ||
+ (targetHandleObject.output_types &&
+ (targetHandleObject.output_types?.some(
+ (n) => n === sourceHandleObject.dataType,
+ ) ||
+ sourceHandleObject.output_types.some((t) =>
+ targetHandleObject.output_types?.some((n) => n === t),
+ ))) ||
sourceHandleObject.output_types.some(
(t) =>
targetHandleObject.inputTypes?.some((n) => n === t) ||
@@ -241,9 +302,15 @@ export function isValidConnection(
return true;
}
} else if (
- (!targetNode.template[targetHandleObject.fieldName].list &&
+ targetHandleObject.output_types &&
+ !edges.find((e) => e.targetHandle === targetHandle)
+ ) {
+ return true;
+ } else if (
+ !targetHandleObject.output_types &&
+ ((!targetNode.template[targetHandleObject.fieldName].list &&
!edges.find((e) => e.targetHandle === targetHandle)) ||
- targetNode.template[targetHandleObject.fieldName].list
+ targetNode.template[targetHandleObject.fieldName].list)
) {
return true;
}
@@ -485,9 +552,51 @@ Array<{ id: string; errors: Array }> {
id: n.id,
errors: validateNode(n, edges),
}));
+
return nodeMap.filter((n) => n.errors?.length);
}
+export function validateEdge(
+ e: EdgeType,
+ nodes: AllNodeType[],
+ edges: EdgeType[],
+): Array {
+ const targetHandleObject: targetHandleType = scapeJSONParse(e.targetHandle!);
+
+ const loop = hasLoop(e, nodes, edges);
+ if (targetHandleObject.output_types && !loop) {
+ return [INCOMPLETE_LOOP_ERROR_ALERT];
+ }
+ return [];
+}
+
+function hasLoop(
+ e: EdgeType,
+ nodes: AllNodeType[],
+ edges: EdgeType[],
+): boolean {
+ const source = e.source;
+ const target = e.target;
+
+ // Check if this connection would create a cycle
+ const targetNode = nodes.find((n) => n.id === target);
+
+ const hasCycle = (node, visited = new Set()): boolean => {
+ if (visited.has(node.id)) return false;
+
+ visited.add(node.id);
+
+ for (const outgoer of getOutgoers(node, nodes, edges)) {
+ if (outgoer.id === source) return true;
+ if (hasCycle(outgoer, visited)) return true;
+ }
+ return false;
+ };
+
+ if (targetNode?.id === source) return false;
+ return hasCycle(targetNode);
+}
+
export function updateEdges(edges: EdgeType[]) {
if (edges)
edges.forEach((edge) => {
diff --git a/src/frontend/src/utils/styleUtils.ts b/src/frontend/src/utils/styleUtils.ts
index b53fe4e04..b82ea6903 100644
--- a/src/frontend/src/utils/styleUtils.ts
+++ b/src/frontend/src/utils/styleUtils.ts
@@ -109,6 +109,7 @@ import {
HelpCircle,
Home,
Image,
+ Infinity,
Info,
InstagramIcon,
Key,
@@ -866,6 +867,7 @@ export const nodeIconsLucide: iconsType = {
Share2,
Share,
GitBranchPlus,
+ Infinity,
Loader2,
BookmarkPlus,
Heart,
diff --git a/src/frontend/tests/extended/features/loop-component.spec.ts b/src/frontend/tests/extended/features/loop-component.spec.ts
new file mode 100644
index 000000000..b582a48b3
--- /dev/null
+++ b/src/frontend/tests/extended/features/loop-component.spec.ts
@@ -0,0 +1,243 @@
+import { expect, test } from "@playwright/test";
+import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
+import { zoomOut } from "../../utils/zoom-out";
+
+test(
+ "should process loop with update data correctly",
+ { tag: ["@release", "@workspace", "@components"] },
+ async ({ page }) => {
+ await awaitBootstrapTest(page);
+ await page.getByTestId("blank-flow").click();
+
+ await page.waitForSelector(
+ '[data-testid="sidebar-custom-component-button"]',
+ {
+ timeout: 3000,
+ },
+ );
+
+ // Add URL component
+ await page.getByTestId("sidebar-search-input").click();
+ await page.getByTestId("sidebar-search-input").fill("url");
+ await page.waitForSelector('[data-testid="dataURL"]', {
+ timeout: 1000,
+ });
+
+ await zoomOut(page, 3);
+
+ await page
+ .getByTestId("dataURL")
+ .dragTo(page.locator('//*[@id="react-flow-id"]'), {
+ targetPosition: { x: 100, y: 100 },
+ });
+
+ // Add Loop component
+ await page.getByTestId("sidebar-search-input").click();
+ await page.getByTestId("sidebar-search-input").fill("loop");
+ await page.waitForSelector('[data-testid="logicLoop"]', {
+ timeout: 1000,
+ });
+
+ await page
+ .getByTestId("logicLoop")
+ .dragTo(page.locator('//*[@id="react-flow-id"]'), {
+ targetPosition: { x: 300, y: 100 },
+ });
+
+ // Add Update Data component
+ await page.getByTestId("sidebar-search-input").click();
+ await page.getByTestId("sidebar-search-input").fill("update data");
+ await page.waitForSelector('[data-testid="processingUpdate Data"]', {
+ timeout: 1000,
+ });
+
+ await page
+ .getByTestId("processingUpdate Data")
+ .dragTo(page.locator('//*[@id="react-flow-id"]'), {
+ targetPosition: { x: 500, y: 100 },
+ });
+
+ // Add Parse Data component
+ await page.getByTestId("sidebar-search-input").click();
+ await page.getByTestId("sidebar-search-input").fill("parse data");
+ await page.waitForSelector('[data-testid="processingParse Data"]', {
+ timeout: 1000,
+ });
+
+ await page
+ .getByTestId("processingParse Data")
+ .dragTo(page.locator('//*[@id="react-flow-id"]'), {
+ targetPosition: { x: 700, y: 100 },
+ });
+
+ //This one is for testing the wrong loop message
+ await page
+ .getByTestId("processingParse Data")
+ .dragTo(page.locator('//*[@id="react-flow-id"]'), {
+ targetPosition: { x: 700, y: 400 },
+ });
+
+ const secondParseDataOutput = await page
+ .getByTestId("handle-parsedata-shownode-data list-right")
+ .nth(2);
+
+ const loopItemInput = await page
+ .getByTestId("handle-loopcomponent-shownode-item-left")
+ .first();
+
+ // Connecting the second parse data to the loop item to test the wrong loop message
+
+ await secondParseDataOutput.hover();
+ await page.mouse.down();
+ await loopItemInput.hover();
+ await page.mouse.up();
+
+ // Add Chat Output component
+ await page.getByTestId("sidebar-search-input").click();
+ await page.getByTestId("sidebar-search-input").fill("chat output");
+ await page.waitForSelector('[data-testid="outputsChat Output"]', {
+ timeout: 1000,
+ });
+
+ await page
+ .getByTestId("outputsChat Output")
+ .dragTo(page.locator('//*[@id="react-flow-id"]'), {
+ targetPosition: { x: 900, y: 100 },
+ });
+
+ await page.getByTestId("fit_view").click();
+
+ await zoomOut(page, 2);
+
+ // Loop Item -> Update Data
+
+ const loopItemHandle = await page
+ .getByTestId("handle-loopcomponent-shownode-item-right")
+ .first();
+ const updateDataInput = await page
+ .getByTestId("handle-updatedata-shownode-data-left")
+ .first();
+
+ await loopItemHandle.hover();
+ await page.mouse.down();
+ await updateDataInput.hover();
+ await page.mouse.up();
+
+ // URL -> Loop Data
+ const urlOutput = await page
+ .getByTestId("handle-url-shownode-data-right")
+ .first();
+ const loopInput = await page
+ .getByTestId("handle-loopcomponent-shownode-data-left")
+ .first();
+
+ await urlOutput.hover();
+ await page.mouse.down();
+ await loopInput.hover();
+ await page.mouse.up();
+
+ // Loop Done -> Parse Data
+ const loopDoneHandle = await page
+ .getByTestId("handle-loopcomponent-shownode-done-right")
+ .first();
+ const parseDataInput = await page
+ .getByTestId("handle-parsedata-shownode-data-left")
+ .first();
+
+ await loopDoneHandle.hover();
+ await page.mouse.down();
+ await parseDataInput.hover();
+ await page.mouse.up();
+
+ await page.getByTestId("div-generic-node").nth(5).click();
+
+ await page.getByTestId("more-options-modal").click();
+
+ await page.getByTestId("expand-button-modal").click();
+
+ // Parse Data -> Chat Output
+ const parseDataOutput = await page
+ .getByTestId("handle-parsedata-shownode-message-right")
+ .first();
+
+ const chatOutputInput = await page
+ .getByTestId("handle-chatoutput-shownode-text-left")
+ .first();
+
+ await parseDataOutput.hover();
+ await page.mouse.down();
+ await chatOutputInput.hover();
+ await page.mouse.up();
+
+ await page.getByTestId("input-list-plus-btn_urls-0").click();
+
+ // Configure components
+ await page
+ .getByTestId("inputlist_str_urls_0")
+ .fill("https://en.wikipedia.org/wiki/Artificial_intelligence");
+ await page
+ .getByTestId("inputlist_str_urls_1")
+ .fill("https://en.wikipedia.org/wiki/Artificial_intelligence");
+
+ await page.getByTestId("div-generic-node").nth(2).click();
+ await page.getByTestId("int_int_number_of_fields").fill("1");
+ await page.getByTestId("div-generic-node").nth(2).click();
+
+ await page.getByTestId("keypair0").fill("text");
+ await page.getByTestId("keypair100").fill("modified_value");
+
+ // Build and run, expect the wrong loop message
+ await page.getByTestId("button_run_chat output").click();
+ await page.waitForSelector("text=The flow has an incomplete loop.", {
+ timeout: 30000,
+ });
+ await page.getByText("The flow has an incomplete loop.").last().click({
+ timeout: 15000,
+ });
+
+ // Delete the second parse data used to test
+
+ await page.getByTestId("div-generic-node").nth(4).click();
+
+ await page.getByTestId("more-options-modal").click();
+
+ await page.getByText("Delete").first().click();
+
+ // Update Data -> Loop Item (left side)
+ const updateDataOutput = await page
+ .getByTestId("handle-updatedata-shownode-data-right")
+ .first();
+
+ await updateDataOutput.hover();
+ await page.mouse.down();
+ await loopItemInput.hover();
+ await page.mouse.up();
+
+ // Build and run
+ await page.getByTestId("button_run_chat output").click();
+ await page.waitForSelector("text=built successfully", { timeout: 30000 });
+ await page.getByText("built successfully").last().click({
+ timeout: 15000,
+ });
+
+ // Verify output
+ await page.waitForSelector(
+ '[data-testid="output-inspection-message-chatoutput"]',
+ {
+ timeout: 1000,
+ },
+ );
+ await page
+ .getByTestId("output-inspection-message-chatoutput")
+ .first()
+ .click();
+ await page.getByRole("gridcell").nth(4).click();
+
+ const output = await page.getByPlaceholder("Empty").textContent();
+ expect(output).toContain("modified_value");
+
+ // Count occurrences of modified_value in output
+ const matches = output?.match(/modified_value/g) || [];
+ expect(matches).toHaveLength(2);
+ },
+);