Browse Source

Feature/newnew workflow loop node (#14863)

Co-authored-by: arkunzz <4873204@qq.com>
Wood 1 month ago
parent
commit
2c17bb2c36
100 changed files with 4850 additions and 120 deletions
  1. 79 0
      api/controllers/console/app/workflow.py
  2. 55 0
      api/core/app/apps/advanced_chat/app_generator.py
  3. 7 0
      api/core/app/apps/advanced_chat/app_runner.py
  4. 59 1
      api/core/app/apps/advanced_chat/generate_task_pipeline.py
  5. 54 0
      api/core/app/apps/workflow/app_generator.py
  6. 7 0
      api/core/app/apps/workflow/app_runner.py
  7. 62 1
      api/core/app/apps/workflow/generate_task_pipeline.py
  8. 195 1
      api/core/app/apps/workflow_app_runner.py
  9. 20 0
      api/core/app/entities/app_invoke_entities.py
  10. 141 0
      api/core/app/entities/queue_entities.py
  11. 98 0
      api/core/app/entities/task_entities.py
  12. 96 1
      api/core/app/task_pipeline/workflow_cycle_manage.py
  13. 39 0
      api/core/workflow/callbacks/workflow_logging_callback.py
  14. 3 0
      api/core/workflow/entities/node_entities.py
  15. 4 1
      api/core/workflow/entities/workflow_entities.py
  16. 62 1
      api/core/workflow/graph_engine/entities/event.py
  17. 7 0
      api/core/workflow/graph_engine/graph_engine.py
  18. 1 0
      api/core/workflow/nodes/answer/answer_stream_generate_router.py
  19. 1 1
      api/core/workflow/nodes/answer/answer_stream_processor.py
  20. 9 2
      api/core/workflow/nodes/base/__init__.py
  21. 15 0
      api/core/workflow/nodes/base/entities.py
  22. 1 1
      api/core/workflow/nodes/end/end_stream_processor.py
  23. 1 0
      api/core/workflow/nodes/enums.py
  24. 5 0
      api/core/workflow/nodes/loop/__init__.py
  25. 44 3
      api/core/workflow/nodes/loop/entities.py
  26. 345 20
      api/core/workflow/nodes/loop/loop_node.py
  27. 20 0
      api/core/workflow/nodes/loop/loop_start_node.py
  28. 9 0
      api/core/workflow/nodes/node_mapping.py
  29. 19 0
      api/services/app_generate_service.py
  30. 3 0
      docker/.env.example
  31. 1 0
      docker/docker-compose-template.yaml
  32. 2 0
      docker/docker-compose.yaml
  33. 3 0
      web/.env.example
  34. 2 0
      web/app/components/base/chat/chat/answer/__mocks__/workflowProcess.ts
  35. 36 1
      web/app/components/base/chat/chat/hooks.ts
  36. 66 0
      web/app/components/base/icons/src/vender/workflow/Loop.json
  37. 16 0
      web/app/components/base/icons/src/vender/workflow/Loop.tsx
  38. 36 0
      web/app/components/base/icons/src/vender/workflow/LoopStart.json
  39. 16 0
      web/app/components/base/icons/src/vender/workflow/LoopStart.tsx
  40. 2 0
      web/app/components/base/icons/src/vender/workflow/index.ts
  41. 35 0
      web/app/components/share/text-generation/result/index.tsx
  42. 4 0
      web/app/components/workflow/block-icon.tsx
  43. 5 0
      web/app/components/workflow/block-selector/constants.tsx
  44. 4 1
      web/app/components/workflow/candidate-node.tsx
  45. 44 1
      web/app/components/workflow/constants.ts
  46. 4 3
      web/app/components/workflow/custom-edge.tsx
  47. 13 0
      web/app/components/workflow/hooks/use-helpline.ts
  48. 12 4
      web/app/components/workflow/hooks/use-nodes-data.ts
  49. 207 36
      web/app/components/workflow/hooks/use-nodes-interactions.ts
  50. 3 0
      web/app/components/workflow/hooks/use-workflow-run-event/index.ts
  51. 46 0
      web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts
  52. 35 0
      web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts
  53. 85 0
      web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts
  54. 9 0
      web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts
  55. 52 2
      web/app/components/workflow/hooks/use-workflow-run.ts
  56. 3 0
      web/app/components/workflow/hooks/use-workflow-variables.ts
  57. 38 4
      web/app/components/workflow/hooks/use-workflow.ts
  58. 4 0
      web/app/components/workflow/index.tsx
  59. 1 1
      web/app/components/workflow/nodes/_base/components/next-step/add.tsx
  60. 1 1
      web/app/components/workflow/nodes/_base/components/next-step/operator.tsx
  61. 2 2
      web/app/components/workflow/nodes/_base/components/node-handle.tsx
  62. 1 1
      web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx
  63. 1 1
      web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx
  64. 97 0
      web/app/components/workflow/nodes/_base/components/variable/utils.ts
  65. 16 2
      web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx
  66. 1 0
      web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts
  67. 5 0
      web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts
  68. 2 0
      web/app/components/workflow/nodes/_base/hooks/use-node-info.ts
  69. 117 8
      web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts
  70. 39 8
      web/app/components/workflow/nodes/_base/node.tsx
  71. 2 2
      web/app/components/workflow/nodes/_base/panel.tsx
  72. 4 2
      web/app/components/workflow/nodes/assigner/use-config.ts
  73. 4 0
      web/app/components/workflow/nodes/constants.ts
  74. 4 2
      web/app/components/workflow/nodes/document-extractor/use-config.ts
  75. 3 2
      web/app/components/workflow/nodes/if-else/types.ts
  76. 1 0
      web/app/components/workflow/nodes/if-else/use-config.ts
  77. 4 1
      web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts
  78. 4 2
      web/app/components/workflow/nodes/list-operator/use-config.ts
  79. 1 0
      web/app/components/workflow/nodes/loop-start/constants.ts
  80. 21 0
      web/app/components/workflow/nodes/loop-start/default.ts
  81. 42 0
      web/app/components/workflow/nodes/loop-start/index.tsx
  82. 3 0
      web/app/components/workflow/nodes/loop-start/types.ts
  83. 80 0
      web/app/components/workflow/nodes/loop/add-block.tsx
  84. 74 0
      web/app/components/workflow/nodes/loop/components/condition-add.tsx
  85. 115 0
      web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx
  86. 53 0
      web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx
  87. 330 0
      web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx
  88. 94 0
      web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx
  89. 58 0
      web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx
  90. 126 0
      web/app/components/workflow/nodes/loop/components/condition-list/index.tsx
  91. 168 0
      web/app/components/workflow/nodes/loop/components/condition-number-input.tsx
  92. 98 0
      web/app/components/workflow/nodes/loop/components/condition-value.tsx
  93. 149 0
      web/app/components/workflow/nodes/loop/components/condition-wrap.tsx
  94. 92 0
      web/app/components/workflow/nodes/loop/default.ts
  95. 61 0
      web/app/components/workflow/nodes/loop/insert-block.tsx
  96. 61 0
      web/app/components/workflow/nodes/loop/node.tsx
  97. 120 0
      web/app/components/workflow/nodes/loop/panel.tsx
  98. 76 0
      web/app/components/workflow/nodes/loop/types.ts
  99. 329 0
      web/app/components/workflow/nodes/loop/use-config.ts
  100. 146 0
      web/app/components/workflow/nodes/loop/use-interactions.ts

+ 79 - 0
api/controllers/console/app/workflow.py

@@ -246,6 +246,80 @@ class WorkflowDraftRunIterationNodeApi(Resource):
             raise InternalServerError()
 
 
+class AdvancedChatDraftRunLoopNodeApi(Resource):
+    @setup_required
+    @login_required
+    @account_initialization_required
+    @get_app_model(mode=[AppMode.ADVANCED_CHAT])
+    def post(self, app_model: App, node_id: str):
+        """
+        Run draft workflow loop node
+        """
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
+            raise Forbidden()
+
+        if not isinstance(current_user, Account):
+            raise Forbidden()
+
+        parser = reqparse.RequestParser()
+        parser.add_argument("inputs", type=dict, location="json")
+        args = parser.parse_args()
+
+        try:
+            response = AppGenerateService.generate_single_loop(
+                app_model=app_model, user=current_user, node_id=node_id, args=args, streaming=True
+            )
+
+            return helper.compact_generate_response(response)
+        except services.errors.conversation.ConversationNotExistsError:
+            raise NotFound("Conversation Not Exists.")
+        except services.errors.conversation.ConversationCompletedError:
+            raise ConversationCompletedError()
+        except ValueError as e:
+            raise e
+        except Exception:
+            logging.exception("internal server error.")
+            raise InternalServerError()
+
+
+class WorkflowDraftRunLoopNodeApi(Resource):
+    @setup_required
+    @login_required
+    @account_initialization_required
+    @get_app_model(mode=[AppMode.WORKFLOW])
+    def post(self, app_model: App, node_id: str):
+        """
+        Run draft workflow loop node
+        """
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
+            raise Forbidden()
+
+        if not isinstance(current_user, Account):
+            raise Forbidden()
+
+        parser = reqparse.RequestParser()
+        parser.add_argument("inputs", type=dict, location="json")
+        args = parser.parse_args()
+
+        try:
+            response = AppGenerateService.generate_single_loop(
+                app_model=app_model, user=current_user, node_id=node_id, args=args, streaming=True
+            )
+
+            return helper.compact_generate_response(response)
+        except services.errors.conversation.ConversationNotExistsError:
+            raise NotFound("Conversation Not Exists.")
+        except services.errors.conversation.ConversationCompletedError:
+            raise ConversationCompletedError()
+        except ValueError as e:
+            raise e
+        except Exception:
+            logging.exception("internal server error.")
+            raise InternalServerError()
+
+
 class DraftWorkflowRunApi(Resource):
     @setup_required
     @login_required
@@ -512,6 +586,11 @@ api.add_resource(
 api.add_resource(
     WorkflowDraftRunIterationNodeApi, "/apps/<uuid:app_id>/workflows/draft/iteration/nodes/<string:node_id>/run"
 )
+api.add_resource(
+    AdvancedChatDraftRunLoopNodeApi,
+    "/apps/<uuid:app_id>/advanced-chat/workflows/draft/loop/nodes/<string:node_id>/run",
+)
+api.add_resource(WorkflowDraftRunLoopNodeApi, "/apps/<uuid:app_id>/workflows/draft/loop/nodes/<string:node_id>/run")
 api.add_resource(PublishedWorkflowApi, "/apps/<uuid:app_id>/workflows/publish")
 api.add_resource(PublishedAllWorkflowApi, "/apps/<uuid:app_id>/workflows")
 api.add_resource(DefaultBlockConfigsApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs")

+ 55 - 0
api/core/app/apps/advanced_chat/app_generator.py

@@ -223,6 +223,61 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
             stream=streaming,
         )
 
+    def single_loop_generate(
+        self,
+        app_model: App,
+        workflow: Workflow,
+        node_id: str,
+        user: Account | EndUser,
+        args: Mapping,
+        streaming: bool = True,
+    ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], Any, None]:
+        """
+        Generate App response.
+
+        :param app_model: App
+        :param workflow: Workflow
+        :param user: account or end user
+        :param args: request args
+        :param invoke_from: invoke from source
+        :param stream: is stream
+        """
+        if not node_id:
+            raise ValueError("node_id is required")
+
+        if args.get("inputs") is None:
+            raise ValueError("inputs is required")
+
+        # convert to app config
+        app_config = AdvancedChatAppConfigManager.get_app_config(app_model=app_model, workflow=workflow)
+
+        # init application generate entity
+        application_generate_entity = AdvancedChatAppGenerateEntity(
+            task_id=str(uuid.uuid4()),
+            app_config=app_config,
+            conversation_id=None,
+            inputs={},
+            query="",
+            files=[],
+            user_id=user.id,
+            stream=streaming,
+            invoke_from=InvokeFrom.DEBUGGER,
+            extras={"auto_generate_conversation_name": False},
+            single_loop_run=AdvancedChatAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args["inputs"]),
+        )
+        contexts.tenant_id.set(application_generate_entity.app_config.tenant_id)
+        contexts.plugin_tool_providers.set({})
+        contexts.plugin_tool_providers_lock.set(threading.Lock())
+
+        return self._generate(
+            workflow=workflow,
+            user=user,
+            invoke_from=InvokeFrom.DEBUGGER,
+            application_generate_entity=application_generate_entity,
+            conversation=None,
+            stream=streaming,
+        )
+
     def _generate(
         self,
         *,

+ 7 - 0
api/core/app/apps/advanced_chat/app_runner.py

@@ -79,6 +79,13 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
                 node_id=self.application_generate_entity.single_iteration_run.node_id,
                 user_inputs=dict(self.application_generate_entity.single_iteration_run.inputs),
             )
+        elif self.application_generate_entity.single_loop_run:
+            # if only single loop run is requested
+            graph, variable_pool = self._get_graph_and_variable_pool_of_single_loop(
+                workflow=workflow,
+                node_id=self.application_generate_entity.single_loop_run.node_id,
+                user_inputs=dict(self.application_generate_entity.single_loop_run.inputs),
+            )
         else:
             inputs = self.application_generate_entity.inputs
             query = self.application_generate_entity.query

+ 59 - 1
api/core/app/apps/advanced_chat/generate_task_pipeline.py

@@ -23,10 +23,14 @@ from core.app.entities.queue_entities import (
     QueueIterationCompletedEvent,
     QueueIterationNextEvent,
     QueueIterationStartEvent,
+    QueueLoopCompletedEvent,
+    QueueLoopNextEvent,
+    QueueLoopStartEvent,
     QueueMessageReplaceEvent,
     QueueNodeExceptionEvent,
     QueueNodeFailedEvent,
     QueueNodeInIterationFailedEvent,
+    QueueNodeInLoopFailedEvent,
     QueueNodeRetryEvent,
     QueueNodeStartedEvent,
     QueueNodeSucceededEvent,
@@ -372,7 +376,13 @@ class AdvancedChatAppGenerateTaskPipeline:
 
                 if node_finish_resp:
                     yield node_finish_resp
-            elif isinstance(event, QueueNodeFailedEvent | QueueNodeInIterationFailedEvent | QueueNodeExceptionEvent):
+            elif isinstance(
+                event,
+                QueueNodeFailedEvent
+                | QueueNodeInIterationFailedEvent
+                | QueueNodeInLoopFailedEvent
+                | QueueNodeExceptionEvent,
+            ):
                 with Session(db.engine, expire_on_commit=False) as session:
                     workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_failed(
                         session=session, event=event
@@ -472,6 +482,54 @@ class AdvancedChatAppGenerateTaskPipeline:
                     )
 
                 yield iter_finish_resp
+            elif isinstance(event, QueueLoopStartEvent):
+                if not self._workflow_run_id:
+                    raise ValueError("workflow run not initialized.")
+
+                with Session(db.engine, expire_on_commit=False) as session:
+                    workflow_run = self._workflow_cycle_manager._get_workflow_run(
+                        session=session, workflow_run_id=self._workflow_run_id
+                    )
+                    loop_start_resp = self._workflow_cycle_manager._workflow_loop_start_to_stream_response(
+                        session=session,
+                        task_id=self._application_generate_entity.task_id,
+                        workflow_run=workflow_run,
+                        event=event,
+                    )
+
+                yield loop_start_resp
+            elif isinstance(event, QueueLoopNextEvent):
+                if not self._workflow_run_id:
+                    raise ValueError("workflow run not initialized.")
+
+                with Session(db.engine, expire_on_commit=False) as session:
+                    workflow_run = self._workflow_cycle_manager._get_workflow_run(
+                        session=session, workflow_run_id=self._workflow_run_id
+                    )
+                    loop_next_resp = self._workflow_cycle_manager._workflow_loop_next_to_stream_response(
+                        session=session,
+                        task_id=self._application_generate_entity.task_id,
+                        workflow_run=workflow_run,
+                        event=event,
+                    )
+
+                yield loop_next_resp
+            elif isinstance(event, QueueLoopCompletedEvent):
+                if not self._workflow_run_id:
+                    raise ValueError("workflow run not initialized.")
+
+                with Session(db.engine, expire_on_commit=False) as session:
+                    workflow_run = self._workflow_cycle_manager._get_workflow_run(
+                        session=session, workflow_run_id=self._workflow_run_id
+                    )
+                    loop_finish_resp = self._workflow_cycle_manager._workflow_loop_completed_to_stream_response(
+                        session=session,
+                        task_id=self._application_generate_entity.task_id,
+                        workflow_run=workflow_run,
+                        event=event,
+                    )
+
+                yield loop_finish_resp
             elif isinstance(event, QueueWorkflowSucceededEvent):
                 if not self._workflow_run_id:
                     raise ValueError("workflow run not initialized.")

+ 54 - 0
api/core/app/apps/workflow/app_generator.py

@@ -250,6 +250,60 @@ class WorkflowAppGenerator(BaseAppGenerator):
             streaming=streaming,
         )
 
+    def single_loop_generate(
+        self,
+        app_model: App,
+        workflow: Workflow,
+        node_id: str,
+        user: Account | EndUser,
+        args: Mapping[str, Any],
+        streaming: bool = True,
+    ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], None, None]:
+        """
+        Generate App response.
+
+        :param app_model: App
+        :param workflow: Workflow
+        :param user: account or end user
+        :param args: request args
+        :param invoke_from: invoke from source
+        :param stream: is stream
+        """
+        if not node_id:
+            raise ValueError("node_id is required")
+
+        if args.get("inputs") is None:
+            raise ValueError("inputs is required")
+
+        # convert to app config
+        app_config = WorkflowAppConfigManager.get_app_config(app_model=app_model, workflow=workflow)
+
+        # init application generate entity
+        application_generate_entity = WorkflowAppGenerateEntity(
+            task_id=str(uuid.uuid4()),
+            app_config=app_config,
+            inputs={},
+            files=[],
+            user_id=user.id,
+            stream=streaming,
+            invoke_from=InvokeFrom.DEBUGGER,
+            extras={"auto_generate_conversation_name": False},
+            single_loop_run=WorkflowAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args["inputs"]),
+            workflow_run_id=str(uuid.uuid4()),
+        )
+        contexts.tenant_id.set(application_generate_entity.app_config.tenant_id)
+        contexts.plugin_tool_providers.set({})
+        contexts.plugin_tool_providers_lock.set(threading.Lock())
+
+        return self._generate(
+            app_model=app_model,
+            workflow=workflow,
+            user=user,
+            invoke_from=InvokeFrom.DEBUGGER,
+            application_generate_entity=application_generate_entity,
+            streaming=streaming,
+        )
+
     def _generate_worker(
         self,
         flask_app: Flask,

+ 7 - 0
api/core/app/apps/workflow/app_runner.py

@@ -81,6 +81,13 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
                 node_id=self.application_generate_entity.single_iteration_run.node_id,
                 user_inputs=self.application_generate_entity.single_iteration_run.inputs,
             )
+        elif self.application_generate_entity.single_loop_run:
+            # if only single loop run is requested
+            graph, variable_pool = self._get_graph_and_variable_pool_of_single_loop(
+                workflow=workflow,
+                node_id=self.application_generate_entity.single_loop_run.node_id,
+                user_inputs=self.application_generate_entity.single_loop_run.inputs,
+            )
         else:
             inputs = self.application_generate_entity.inputs
             files = self.application_generate_entity.files

+ 62 - 1
api/core/app/apps/workflow/generate_task_pipeline.py

@@ -18,9 +18,13 @@ from core.app.entities.queue_entities import (
     QueueIterationCompletedEvent,
     QueueIterationNextEvent,
     QueueIterationStartEvent,
+    QueueLoopCompletedEvent,
+    QueueLoopNextEvent,
+    QueueLoopStartEvent,
     QueueNodeExceptionEvent,
     QueueNodeFailedEvent,
     QueueNodeInIterationFailedEvent,
+    QueueNodeInLoopFailedEvent,
     QueueNodeRetryEvent,
     QueueNodeStartedEvent,
     QueueNodeSucceededEvent,
@@ -323,7 +327,13 @@ class WorkflowAppGenerateTaskPipeline:
 
                 if node_success_response:
                     yield node_success_response
-            elif isinstance(event, QueueNodeFailedEvent | QueueNodeInIterationFailedEvent | QueueNodeExceptionEvent):
+            elif isinstance(
+                event,
+                QueueNodeFailedEvent
+                | QueueNodeInIterationFailedEvent
+                | QueueNodeInLoopFailedEvent
+                | QueueNodeExceptionEvent,
+            ):
                 with Session(db.engine, expire_on_commit=False) as session:
                     workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_failed(
                         session=session,
@@ -429,6 +439,57 @@ class WorkflowAppGenerateTaskPipeline:
 
                 yield iter_finish_resp
 
+            elif isinstance(event, QueueLoopStartEvent):
+                if not self._workflow_run_id:
+                    raise ValueError("workflow run not initialized.")
+
+                with Session(db.engine, expire_on_commit=False) as session:
+                    workflow_run = self._workflow_cycle_manager._get_workflow_run(
+                        session=session, workflow_run_id=self._workflow_run_id
+                    )
+                    loop_start_resp = self._workflow_cycle_manager._workflow_loop_start_to_stream_response(
+                        session=session,
+                        task_id=self._application_generate_entity.task_id,
+                        workflow_run=workflow_run,
+                        event=event,
+                    )
+
+                yield loop_start_resp
+
+            elif isinstance(event, QueueLoopNextEvent):
+                if not self._workflow_run_id:
+                    raise ValueError("workflow run not initialized.")
+
+                with Session(db.engine, expire_on_commit=False) as session:
+                    workflow_run = self._workflow_cycle_manager._get_workflow_run(
+                        session=session, workflow_run_id=self._workflow_run_id
+                    )
+                    loop_next_resp = self._workflow_cycle_manager._workflow_loop_next_to_stream_response(
+                        session=session,
+                        task_id=self._application_generate_entity.task_id,
+                        workflow_run=workflow_run,
+                        event=event,
+                    )
+
+                yield loop_next_resp
+
+            elif isinstance(event, QueueLoopCompletedEvent):
+                if not self._workflow_run_id:
+                    raise ValueError("workflow run not initialized.")
+
+                with Session(db.engine, expire_on_commit=False) as session:
+                    workflow_run = self._workflow_cycle_manager._get_workflow_run(
+                        session=session, workflow_run_id=self._workflow_run_id
+                    )
+                    loop_finish_resp = self._workflow_cycle_manager._workflow_loop_completed_to_stream_response(
+                        session=session,
+                        task_id=self._application_generate_entity.task_id,
+                        workflow_run=workflow_run,
+                        event=event,
+                    )
+
+                yield loop_finish_resp
+
             elif isinstance(event, QueueWorkflowSucceededEvent):
                 if not self._workflow_run_id:
                     raise ValueError("workflow run not initialized.")

+ 195 - 1
api/core/app/apps/workflow_app_runner.py

@@ -9,9 +9,13 @@ from core.app.entities.queue_entities import (
     QueueIterationCompletedEvent,
     QueueIterationNextEvent,
     QueueIterationStartEvent,
+    QueueLoopCompletedEvent,
+    QueueLoopNextEvent,
+    QueueLoopStartEvent,
     QueueNodeExceptionEvent,
     QueueNodeFailedEvent,
     QueueNodeInIterationFailedEvent,
+    QueueNodeInLoopFailedEvent,
     QueueNodeRetryEvent,
     QueueNodeStartedEvent,
     QueueNodeSucceededEvent,
@@ -38,7 +42,12 @@ from core.workflow.graph_engine.entities.event import (
     IterationRunNextEvent,
     IterationRunStartedEvent,
     IterationRunSucceededEvent,
+    LoopRunFailedEvent,
+    LoopRunNextEvent,
+    LoopRunStartedEvent,
+    LoopRunSucceededEvent,
     NodeInIterationFailedEvent,
+    NodeInLoopFailedEvent,
     NodeRunExceptionEvent,
     NodeRunFailedEvent,
     NodeRunRetrieverResourceEvent,
@@ -173,6 +182,96 @@ class WorkflowBasedAppRunner(AppRunner):
 
         return graph, variable_pool
 
+    def _get_graph_and_variable_pool_of_single_loop(
+        self,
+        workflow: Workflow,
+        node_id: str,
+        user_inputs: dict,
+    ) -> tuple[Graph, VariablePool]:
+        """
+        Get variable pool of single loop
+        """
+        # fetch workflow graph
+        graph_config = workflow.graph_dict
+        if not graph_config:
+            raise ValueError("workflow graph not found")
+
+        graph_config = cast(dict[str, Any], graph_config)
+
+        if "nodes" not in graph_config or "edges" not in graph_config:
+            raise ValueError("nodes or edges not found in workflow graph")
+
+        if not isinstance(graph_config.get("nodes"), list):
+            raise ValueError("nodes in workflow graph must be a list")
+
+        if not isinstance(graph_config.get("edges"), list):
+            raise ValueError("edges in workflow graph must be a list")
+
+        # filter nodes only in loop
+        node_configs = [
+            node
+            for node in graph_config.get("nodes", [])
+            if node.get("id") == node_id or node.get("data", {}).get("loop_id", "") == node_id
+        ]
+
+        graph_config["nodes"] = node_configs
+
+        node_ids = [node.get("id") for node in node_configs]
+
+        # filter edges only in loop
+        edge_configs = [
+            edge
+            for edge in graph_config.get("edges", [])
+            if (edge.get("source") is None or edge.get("source") in node_ids)
+            and (edge.get("target") is None or edge.get("target") in node_ids)
+        ]
+
+        graph_config["edges"] = edge_configs
+
+        # init graph
+        graph = Graph.init(graph_config=graph_config, root_node_id=node_id)
+
+        if not graph:
+            raise ValueError("graph not found in workflow")
+
+        # fetch node config from node id
+        loop_node_config = None
+        for node in node_configs:
+            if node.get("id") == node_id:
+                loop_node_config = node
+                break
+
+        if not loop_node_config:
+            raise ValueError("loop node id not found in workflow graph")
+
+        # Get node class
+        node_type = NodeType(loop_node_config.get("data", {}).get("type"))
+        node_version = loop_node_config.get("data", {}).get("version", "1")
+        node_cls = NODE_TYPE_CLASSES_MAPPING[node_type][node_version]
+
+        # init variable pool
+        variable_pool = VariablePool(
+            system_variables={},
+            user_inputs={},
+            environment_variables=workflow.environment_variables,
+        )
+
+        try:
+            variable_mapping = node_cls.extract_variable_selector_to_variable_mapping(
+                graph_config=workflow.graph_dict, config=loop_node_config
+            )
+        except NotImplementedError:
+            variable_mapping = {}
+
+        WorkflowEntry.mapping_user_inputs_to_variable_pool(
+            variable_mapping=variable_mapping,
+            user_inputs=user_inputs,
+            variable_pool=variable_pool,
+            tenant_id=workflow.tenant_id,
+        )
+
+        return graph, variable_pool
+
     def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent) -> None:
         """
         Handle event
@@ -216,6 +315,7 @@ class WorkflowBasedAppRunner(AppRunner):
                     node_run_index=event.route_node_state.index,
                     predecessor_node_id=event.predecessor_node_id,
                     in_iteration_id=event.in_iteration_id,
+                    in_loop_id=event.in_loop_id,
                     parallel_mode_run_id=event.parallel_mode_run_id,
                     inputs=inputs,
                     process_data=process_data,
@@ -240,6 +340,7 @@ class WorkflowBasedAppRunner(AppRunner):
                     node_run_index=event.route_node_state.index,
                     predecessor_node_id=event.predecessor_node_id,
                     in_iteration_id=event.in_iteration_id,
+                    in_loop_id=event.in_loop_id,
                     parallel_mode_run_id=event.parallel_mode_run_id,
                     agent_strategy=event.agent_strategy,
                 )
@@ -272,6 +373,7 @@ class WorkflowBasedAppRunner(AppRunner):
                     outputs=outputs,
                     execution_metadata=execution_metadata,
                     in_iteration_id=event.in_iteration_id,
+                    in_loop_id=event.in_loop_id,
                 )
             )
         elif isinstance(event, NodeRunFailedEvent):
@@ -302,6 +404,7 @@ class WorkflowBasedAppRunner(AppRunner):
                     if event.route_node_state.node_run_result
                     else {},
                     in_iteration_id=event.in_iteration_id,
+                    in_loop_id=event.in_loop_id,
                 )
             )
         elif isinstance(event, NodeRunExceptionEvent):
@@ -332,6 +435,7 @@ class WorkflowBasedAppRunner(AppRunner):
                     if event.route_node_state.node_run_result
                     else {},
                     in_iteration_id=event.in_iteration_id,
+                    in_loop_id=event.in_loop_id,
                 )
             )
         elif isinstance(event, NodeInIterationFailedEvent):
@@ -362,18 +466,49 @@ class WorkflowBasedAppRunner(AppRunner):
                     error=event.error,
                 )
             )
+        elif isinstance(event, NodeInLoopFailedEvent):
+            self._publish_event(
+                QueueNodeInLoopFailedEvent(
+                    node_execution_id=event.id,
+                    node_id=event.node_id,
+                    node_type=event.node_type,
+                    node_data=event.node_data,
+                    parallel_id=event.parallel_id,
+                    parallel_start_node_id=event.parallel_start_node_id,
+                    parent_parallel_id=event.parent_parallel_id,
+                    parent_parallel_start_node_id=event.parent_parallel_start_node_id,
+                    start_at=event.route_node_state.start_at,
+                    inputs=event.route_node_state.node_run_result.inputs
+                    if event.route_node_state.node_run_result
+                    else {},
+                    process_data=event.route_node_state.node_run_result.process_data
+                    if event.route_node_state.node_run_result
+                    else {},
+                    outputs=event.route_node_state.node_run_result.outputs or {}
+                    if event.route_node_state.node_run_result
+                    else {},
+                    execution_metadata=event.route_node_state.node_run_result.metadata
+                    if event.route_node_state.node_run_result
+                    else {},
+                    in_loop_id=event.in_loop_id,
+                    error=event.error,
+                )
+            )
         elif isinstance(event, NodeRunStreamChunkEvent):
             self._publish_event(
                 QueueTextChunkEvent(
                     text=event.chunk_content,
                     from_variable_selector=event.from_variable_selector,
                     in_iteration_id=event.in_iteration_id,
+                    in_loop_id=event.in_loop_id,
                 )
             )
         elif isinstance(event, NodeRunRetrieverResourceEvent):
             self._publish_event(
                 QueueRetrieverResourcesEvent(
-                    retriever_resources=event.retriever_resources, in_iteration_id=event.in_iteration_id
+                    retriever_resources=event.retriever_resources,
+                    in_iteration_id=event.in_iteration_id,
+                    in_loop_id=event.in_loop_id,
                 )
             )
         elif isinstance(event, AgentLogEvent):
@@ -397,6 +532,7 @@ class WorkflowBasedAppRunner(AppRunner):
                     parent_parallel_id=event.parent_parallel_id,
                     parent_parallel_start_node_id=event.parent_parallel_start_node_id,
                     in_iteration_id=event.in_iteration_id,
+                    in_loop_id=event.in_loop_id,
                 )
             )
         elif isinstance(event, ParallelBranchRunSucceededEvent):
@@ -407,6 +543,7 @@ class WorkflowBasedAppRunner(AppRunner):
                     parent_parallel_id=event.parent_parallel_id,
                     parent_parallel_start_node_id=event.parent_parallel_start_node_id,
                     in_iteration_id=event.in_iteration_id,
+                    in_loop_id=event.in_loop_id,
                 )
             )
         elif isinstance(event, ParallelBranchRunFailedEvent):
@@ -417,6 +554,7 @@ class WorkflowBasedAppRunner(AppRunner):
                     parent_parallel_id=event.parent_parallel_id,
                     parent_parallel_start_node_id=event.parent_parallel_start_node_id,
                     in_iteration_id=event.in_iteration_id,
+                    in_loop_id=event.in_loop_id,
                     error=event.error,
                 )
             )
@@ -476,6 +614,62 @@ class WorkflowBasedAppRunner(AppRunner):
                     error=event.error if isinstance(event, IterationRunFailedEvent) else None,
                 )
             )
+        elif isinstance(event, LoopRunStartedEvent):
+            self._publish_event(
+                QueueLoopStartEvent(
+                    node_execution_id=event.loop_id,
+                    node_id=event.loop_node_id,
+                    node_type=event.loop_node_type,
+                    node_data=event.loop_node_data,
+                    parallel_id=event.parallel_id,
+                    parallel_start_node_id=event.parallel_start_node_id,
+                    parent_parallel_id=event.parent_parallel_id,
+                    parent_parallel_start_node_id=event.parent_parallel_start_node_id,
+                    start_at=event.start_at,
+                    node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps,
+                    inputs=event.inputs,
+                    predecessor_node_id=event.predecessor_node_id,
+                    metadata=event.metadata,
+                )
+            )
+        elif isinstance(event, LoopRunNextEvent):
+            self._publish_event(
+                QueueLoopNextEvent(
+                    node_execution_id=event.loop_id,
+                    node_id=event.loop_node_id,
+                    node_type=event.loop_node_type,
+                    node_data=event.loop_node_data,
+                    parallel_id=event.parallel_id,
+                    parallel_start_node_id=event.parallel_start_node_id,
+                    parent_parallel_id=event.parent_parallel_id,
+                    parent_parallel_start_node_id=event.parent_parallel_start_node_id,
+                    index=event.index,
+                    node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps,
+                    output=event.pre_loop_output,
+                    parallel_mode_run_id=event.parallel_mode_run_id,
+                    duration=event.duration,
+                )
+            )
+        elif isinstance(event, (LoopRunSucceededEvent | LoopRunFailedEvent)):
+            self._publish_event(
+                QueueLoopCompletedEvent(
+                    node_execution_id=event.loop_id,
+                    node_id=event.loop_node_id,
+                    node_type=event.loop_node_type,
+                    node_data=event.loop_node_data,
+                    parallel_id=event.parallel_id,
+                    parallel_start_node_id=event.parallel_start_node_id,
+                    parent_parallel_id=event.parent_parallel_id,
+                    parent_parallel_start_node_id=event.parent_parallel_start_node_id,
+                    start_at=event.start_at,
+                    node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps,
+                    inputs=event.inputs,
+                    outputs=event.outputs,
+                    metadata=event.metadata,
+                    steps=event.steps,
+                    error=event.error if isinstance(event, LoopRunFailedEvent) else None,
+                )
+            )
 
     def get_workflow(self, app_model: App, workflow_id: str) -> Optional[Workflow]:
         """

+ 20 - 0
api/core/app/entities/app_invoke_entities.py

@@ -187,6 +187,16 @@ class AdvancedChatAppGenerateEntity(ConversationAppGenerateEntity):
 
     single_iteration_run: Optional[SingleIterationRunEntity] = None
 
+    class SingleLoopRunEntity(BaseModel):
+        """
+        Single Loop Run Entity.
+        """
+
+        node_id: str
+        inputs: Mapping
+
+    single_loop_run: Optional[SingleLoopRunEntity] = None
+
 
 class WorkflowAppGenerateEntity(AppGenerateEntity):
     """
@@ -206,3 +216,13 @@ class WorkflowAppGenerateEntity(AppGenerateEntity):
         inputs: dict
 
     single_iteration_run: Optional[SingleIterationRunEntity] = None
+
+    class SingleLoopRunEntity(BaseModel):
+        """
+        Single Loop Run Entity.
+        """
+
+        node_id: str
+        inputs: dict
+
+    single_loop_run: Optional[SingleLoopRunEntity] = None

+ 141 - 0
api/core/app/entities/queue_entities.py

@@ -30,6 +30,9 @@ class QueueEvent(StrEnum):
     ITERATION_START = "iteration_start"
     ITERATION_NEXT = "iteration_next"
     ITERATION_COMPLETED = "iteration_completed"
+    LOOP_START = "loop_start"
+    LOOP_NEXT = "loop_next"
+    LOOP_COMPLETED = "loop_completed"
     NODE_STARTED = "node_started"
     NODE_SUCCEEDED = "node_succeeded"
     NODE_FAILED = "node_failed"
@@ -149,6 +152,89 @@ class QueueIterationCompletedEvent(AppQueueEvent):
     error: Optional[str] = None
 
 
+class QueueLoopStartEvent(AppQueueEvent):
+    """
+    QueueLoopStartEvent entity
+    """
+
+    event: QueueEvent = QueueEvent.LOOP_START
+    node_execution_id: str
+    node_id: str
+    node_type: NodeType
+    node_data: BaseNodeData
+    parallel_id: Optional[str] = None
+    """parallel id if node is in parallel"""
+    parallel_start_node_id: Optional[str] = None
+    """parallel start node id if node is in parallel"""
+    parent_parallel_id: Optional[str] = None
+    """parent parallel id if node is in parallel"""
+    parent_parallel_start_node_id: Optional[str] = None
+    """parent parallel start node id if node is in parallel"""
+    start_at: datetime
+
+    node_run_index: int
+    inputs: Optional[Mapping[str, Any]] = None
+    predecessor_node_id: Optional[str] = None
+    metadata: Optional[Mapping[str, Any]] = None
+
+
+class QueueLoopNextEvent(AppQueueEvent):
+    """
+    QueueLoopNextEvent entity
+    """
+
+    event: QueueEvent = QueueEvent.LOOP_NEXT
+
+    index: int
+    node_execution_id: str
+    node_id: str
+    node_type: NodeType
+    node_data: BaseNodeData
+    parallel_id: Optional[str] = None
+    """parallel id if node is in parallel"""
+    parallel_start_node_id: Optional[str] = None
+    """parallel start node id if node is in parallel"""
+    parent_parallel_id: Optional[str] = None
+    """parent parallel id if node is in parallel"""
+    parent_parallel_start_node_id: Optional[str] = None
+    """parent parallel start node id if node is in parallel"""
+    parallel_mode_run_id: Optional[str] = None
+    """iteratoin run in parallel mode run id"""
+    node_run_index: int
+    output: Optional[Any] = None  # output for the current loop
+    duration: Optional[float] = None
+
+
+class QueueLoopCompletedEvent(AppQueueEvent):
+    """
+    QueueLoopCompletedEvent entity
+    """
+
+    event: QueueEvent = QueueEvent.LOOP_COMPLETED
+
+    node_execution_id: str
+    node_id: str
+    node_type: NodeType
+    node_data: BaseNodeData
+    parallel_id: Optional[str] = None
+    """parallel id if node is in parallel"""
+    parallel_start_node_id: Optional[str] = None
+    """parallel start node id if node is in parallel"""
+    parent_parallel_id: Optional[str] = None
+    """parent parallel id if node is in parallel"""
+    parent_parallel_start_node_id: Optional[str] = None
+    """parent parallel start node id if node is in parallel"""
+    start_at: datetime
+
+    node_run_index: int
+    inputs: Optional[Mapping[str, Any]] = None
+    outputs: Optional[Mapping[str, Any]] = None
+    metadata: Optional[Mapping[str, Any]] = None
+    steps: int = 0
+
+    error: Optional[str] = None
+
+
 class QueueTextChunkEvent(AppQueueEvent):
     """
     QueueTextChunkEvent entity
@@ -160,6 +246,8 @@ class QueueTextChunkEvent(AppQueueEvent):
     """from variable selector"""
     in_iteration_id: Optional[str] = None
     """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
 
 
 class QueueAgentMessageEvent(AppQueueEvent):
@@ -189,6 +277,8 @@ class QueueRetrieverResourcesEvent(AppQueueEvent):
     retriever_resources: list[dict]
     in_iteration_id: Optional[str] = None
     """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
 
 
 class QueueAnnotationReplyEvent(AppQueueEvent):
@@ -278,6 +368,8 @@ class QueueNodeStartedEvent(AppQueueEvent):
     """parent parallel start node id if node is in parallel"""
     in_iteration_id: Optional[str] = None
     """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
     start_at: datetime
     parallel_mode_run_id: Optional[str] = None
     """iteratoin run in parallel mode run id"""
@@ -305,6 +397,8 @@ class QueueNodeSucceededEvent(AppQueueEvent):
     """parent parallel start node id if node is in parallel"""
     in_iteration_id: Optional[str] = None
     """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
     start_at: datetime
 
     inputs: Optional[Mapping[str, Any]] = None
@@ -315,6 +409,8 @@ class QueueNodeSucceededEvent(AppQueueEvent):
     error: Optional[str] = None
     """single iteration duration map"""
     iteration_duration_map: Optional[dict[str, float]] = None
+    """single loop duration map"""
+    loop_duration_map: Optional[dict[str, float]] = None
 
 
 class QueueAgentLogEvent(AppQueueEvent):
@@ -368,6 +464,41 @@ class QueueNodeInIterationFailedEvent(AppQueueEvent):
     """parent parallel start node id if node is in parallel"""
     in_iteration_id: Optional[str] = None
     """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
+    start_at: datetime
+
+    inputs: Optional[Mapping[str, Any]] = None
+    process_data: Optional[Mapping[str, Any]] = None
+    outputs: Optional[Mapping[str, Any]] = None
+    execution_metadata: Optional[Mapping[NodeRunMetadataKey, Any]] = None
+
+    error: str
+
+
+class QueueNodeInLoopFailedEvent(AppQueueEvent):
+    """
+    QueueNodeInLoopFailedEvent entity
+    """
+
+    event: QueueEvent = QueueEvent.NODE_FAILED
+
+    node_execution_id: str
+    node_id: str
+    node_type: NodeType
+    node_data: BaseNodeData
+    parallel_id: Optional[str] = None
+    """parallel id if node is in parallel"""
+    parallel_start_node_id: Optional[str] = None
+    """parallel start node id if node is in parallel"""
+    parent_parallel_id: Optional[str] = None
+    """parent parallel id if node is in parallel"""
+    parent_parallel_start_node_id: Optional[str] = None
+    """parent parallel start node id if node is in parallel"""
+    in_iteration_id: Optional[str] = None
+    """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
     start_at: datetime
 
     inputs: Optional[Mapping[str, Any]] = None
@@ -399,6 +530,8 @@ class QueueNodeExceptionEvent(AppQueueEvent):
     """parent parallel start node id if node is in parallel"""
     in_iteration_id: Optional[str] = None
     """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
     start_at: datetime
 
     inputs: Optional[Mapping[str, Any]] = None
@@ -430,6 +563,8 @@ class QueueNodeFailedEvent(AppQueueEvent):
     """parent parallel start node id if node is in parallel"""
     in_iteration_id: Optional[str] = None
     """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
     start_at: datetime
 
     inputs: Optional[Mapping[str, Any]] = None
@@ -549,6 +684,8 @@ class QueueParallelBranchRunStartedEvent(AppQueueEvent):
     """parent parallel start node id if node is in parallel"""
     in_iteration_id: Optional[str] = None
     """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
 
 
 class QueueParallelBranchRunSucceededEvent(AppQueueEvent):
@@ -566,6 +703,8 @@ class QueueParallelBranchRunSucceededEvent(AppQueueEvent):
     """parent parallel start node id if node is in parallel"""
     in_iteration_id: Optional[str] = None
     """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
 
 
 class QueueParallelBranchRunFailedEvent(AppQueueEvent):
@@ -583,4 +722,6 @@ class QueueParallelBranchRunFailedEvent(AppQueueEvent):
     """parent parallel start node id if node is in parallel"""
     in_iteration_id: Optional[str] = None
     """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
     error: str

+ 98 - 0
api/core/app/entities/task_entities.py

@@ -59,6 +59,9 @@ class StreamEvent(Enum):
     ITERATION_STARTED = "iteration_started"
     ITERATION_NEXT = "iteration_next"
     ITERATION_COMPLETED = "iteration_completed"
+    LOOP_STARTED = "loop_started"
+    LOOP_NEXT = "loop_next"
+    LOOP_COMPLETED = "loop_completed"
     TEXT_CHUNK = "text_chunk"
     TEXT_REPLACE = "text_replace"
     AGENT_LOG = "agent_log"
@@ -248,6 +251,7 @@ class NodeStartStreamResponse(StreamResponse):
         parent_parallel_id: Optional[str] = None
         parent_parallel_start_node_id: Optional[str] = None
         iteration_id: Optional[str] = None
+        loop_id: Optional[str] = None
         parallel_run_id: Optional[str] = None
         agent_strategy: Optional[AgentNodeStrategyInit] = None
 
@@ -275,6 +279,7 @@ class NodeStartStreamResponse(StreamResponse):
                 "parent_parallel_id": self.data.parent_parallel_id,
                 "parent_parallel_start_node_id": self.data.parent_parallel_start_node_id,
                 "iteration_id": self.data.iteration_id,
+                "loop_id": self.data.loop_id,
             },
         }
 
@@ -310,6 +315,7 @@ class NodeFinishStreamResponse(StreamResponse):
         parent_parallel_id: Optional[str] = None
         parent_parallel_start_node_id: Optional[str] = None
         iteration_id: Optional[str] = None
+        loop_id: Optional[str] = None
 
     event: StreamEvent = StreamEvent.NODE_FINISHED
     workflow_run_id: str
@@ -342,6 +348,7 @@ class NodeFinishStreamResponse(StreamResponse):
                 "parent_parallel_id": self.data.parent_parallel_id,
                 "parent_parallel_start_node_id": self.data.parent_parallel_start_node_id,
                 "iteration_id": self.data.iteration_id,
+                "loop_id": self.data.loop_id,
             },
         }
 
@@ -377,6 +384,7 @@ class NodeRetryStreamResponse(StreamResponse):
         parent_parallel_id: Optional[str] = None
         parent_parallel_start_node_id: Optional[str] = None
         iteration_id: Optional[str] = None
+        loop_id: Optional[str] = None
         retry_index: int = 0
 
     event: StreamEvent = StreamEvent.NODE_RETRY
@@ -410,6 +418,7 @@ class NodeRetryStreamResponse(StreamResponse):
                 "parent_parallel_id": self.data.parent_parallel_id,
                 "parent_parallel_start_node_id": self.data.parent_parallel_start_node_id,
                 "iteration_id": self.data.iteration_id,
+                "loop_id": self.data.loop_id,
                 "retry_index": self.data.retry_index,
             },
         }
@@ -430,6 +439,7 @@ class ParallelBranchStartStreamResponse(StreamResponse):
         parent_parallel_id: Optional[str] = None
         parent_parallel_start_node_id: Optional[str] = None
         iteration_id: Optional[str] = None
+        loop_id: Optional[str] = None
         created_at: int
 
     event: StreamEvent = StreamEvent.PARALLEL_BRANCH_STARTED
@@ -452,6 +462,7 @@ class ParallelBranchFinishedStreamResponse(StreamResponse):
         parent_parallel_id: Optional[str] = None
         parent_parallel_start_node_id: Optional[str] = None
         iteration_id: Optional[str] = None
+        loop_id: Optional[str] = None
         status: str
         error: Optional[str] = None
         created_at: int
@@ -548,6 +559,93 @@ class IterationNodeCompletedStreamResponse(StreamResponse):
     data: Data
 
 
+class LoopNodeStartStreamResponse(StreamResponse):
+    """
+    NodeStartStreamResponse entity
+    """
+
+    class Data(BaseModel):
+        """
+        Data entity
+        """
+
+        id: str
+        node_id: str
+        node_type: str
+        title: str
+        created_at: int
+        extras: dict = {}
+        metadata: Mapping = {}
+        inputs: Mapping = {}
+        parallel_id: Optional[str] = None
+        parallel_start_node_id: Optional[str] = None
+
+    event: StreamEvent = StreamEvent.LOOP_STARTED
+    workflow_run_id: str
+    data: Data
+
+
+class LoopNodeNextStreamResponse(StreamResponse):
+    """
+    NodeStartStreamResponse entity
+    """
+
+    class Data(BaseModel):
+        """
+        Data entity
+        """
+
+        id: str
+        node_id: str
+        node_type: str
+        title: str
+        index: int
+        created_at: int
+        pre_loop_output: Optional[Any] = None
+        extras: dict = {}
+        parallel_id: Optional[str] = None
+        parallel_start_node_id: Optional[str] = None
+        parallel_mode_run_id: Optional[str] = None
+        duration: Optional[float] = None
+
+    event: StreamEvent = StreamEvent.LOOP_NEXT
+    workflow_run_id: str
+    data: Data
+
+
+class LoopNodeCompletedStreamResponse(StreamResponse):
+    """
+    NodeCompletedStreamResponse entity
+    """
+
+    class Data(BaseModel):
+        """
+        Data entity
+        """
+
+        id: str
+        node_id: str
+        node_type: str
+        title: str
+        outputs: Optional[Mapping] = None
+        created_at: int
+        extras: Optional[dict] = None
+        inputs: Optional[Mapping] = None
+        status: WorkflowNodeExecutionStatus
+        error: Optional[str] = None
+        elapsed_time: float
+        total_tokens: int
+        execution_metadata: Optional[Mapping] = None
+        finished_at: int
+        steps: int
+        parallel_id: Optional[str] = None
+        parallel_start_node_id: Optional[str] = None
+
+    event: StreamEvent = StreamEvent.LOOP_COMPLETED
+    workflow_run_id: str
+    data: Data
+
+
 class TextChunkStreamResponse(StreamResponse):
     """
     TextChunkStreamResponse entity

+ 96 - 1
api/core/app/task_pipeline/workflow_cycle_manage.py

@@ -14,9 +14,13 @@ from core.app.entities.queue_entities import (
     QueueIterationCompletedEvent,
     QueueIterationNextEvent,
     QueueIterationStartEvent,
+    QueueLoopCompletedEvent,
+    QueueLoopNextEvent,
+    QueueLoopStartEvent,
     QueueNodeExceptionEvent,
     QueueNodeFailedEvent,
     QueueNodeInIterationFailedEvent,
+    QueueNodeInLoopFailedEvent,
     QueueNodeRetryEvent,
     QueueNodeStartedEvent,
     QueueNodeSucceededEvent,
@@ -29,6 +33,9 @@ from core.app.entities.task_entities import (
     IterationNodeCompletedStreamResponse,
     IterationNodeNextStreamResponse,
     IterationNodeStartStreamResponse,
+    LoopNodeCompletedStreamResponse,
+    LoopNodeNextStreamResponse,
+    LoopNodeStartStreamResponse,
     NodeFinishStreamResponse,
     NodeRetryStreamResponse,
     NodeStartStreamResponse,
@@ -304,6 +311,7 @@ class WorkflowCycleManage:
             {
                 NodeRunMetadataKey.PARALLEL_MODE_RUN_ID: event.parallel_mode_run_id,
                 NodeRunMetadataKey.ITERATION_ID: event.in_iteration_id,
+                NodeRunMetadataKey.LOOP_ID: event.in_loop_id,
             }
         )
         workflow_node_execution.created_at = datetime.now(UTC).replace(tzinfo=None)
@@ -344,7 +352,10 @@ class WorkflowCycleManage:
         self,
         *,
         session: Session,
-        event: QueueNodeFailedEvent | QueueNodeInIterationFailedEvent | QueueNodeExceptionEvent,
+        event: QueueNodeFailedEvent
+        | QueueNodeInIterationFailedEvent
+        | QueueNodeInLoopFailedEvent
+        | QueueNodeExceptionEvent,
     ) -> WorkflowNodeExecution:
         """
         Workflow node execution failed
@@ -396,6 +407,7 @@ class WorkflowCycleManage:
         origin_metadata = {
             NodeRunMetadataKey.ITERATION_ID: event.in_iteration_id,
             NodeRunMetadataKey.PARALLEL_MODE_RUN_ID: event.parallel_mode_run_id,
+            NodeRunMetadataKey.LOOP_ID: event.in_loop_id,
         }
         merged_metadata = (
             {**jsonable_encoder(event.execution_metadata), **origin_metadata}
@@ -540,6 +552,7 @@ class WorkflowCycleManage:
                 parent_parallel_id=event.parent_parallel_id,
                 parent_parallel_start_node_id=event.parent_parallel_start_node_id,
                 iteration_id=event.in_iteration_id,
+                loop_id=event.in_loop_id,
                 parallel_run_id=event.parallel_mode_run_id,
                 agent_strategy=event.agent_strategy,
             ),
@@ -563,6 +576,7 @@ class WorkflowCycleManage:
         event: QueueNodeSucceededEvent
         | QueueNodeFailedEvent
         | QueueNodeInIterationFailedEvent
+        | QueueNodeInLoopFailedEvent
         | QueueNodeExceptionEvent,
         task_id: str,
         workflow_node_execution: WorkflowNodeExecution,
@@ -601,6 +615,7 @@ class WorkflowCycleManage:
                 parent_parallel_id=event.parent_parallel_id,
                 parent_parallel_start_node_id=event.parent_parallel_start_node_id,
                 iteration_id=event.in_iteration_id,
+                loop_id=event.in_loop_id,
             ),
         )
 
@@ -646,6 +661,7 @@ class WorkflowCycleManage:
                 parent_parallel_id=event.parent_parallel_id,
                 parent_parallel_start_node_id=event.parent_parallel_start_node_id,
                 iteration_id=event.in_iteration_id,
+                loop_id=event.in_loop_id,
                 retry_index=event.retry_index,
             ),
         )
@@ -664,6 +680,7 @@ class WorkflowCycleManage:
                 parent_parallel_id=event.parent_parallel_id,
                 parent_parallel_start_node_id=event.parent_parallel_start_node_id,
                 iteration_id=event.in_iteration_id,
+                loop_id=event.in_loop_id,
                 created_at=int(time.time()),
             ),
         )
@@ -687,6 +704,7 @@ class WorkflowCycleManage:
                 parent_parallel_id=event.parent_parallel_id,
                 parent_parallel_start_node_id=event.parent_parallel_start_node_id,
                 iteration_id=event.in_iteration_id,
+                loop_id=event.in_loop_id,
                 status="succeeded" if isinstance(event, QueueParallelBranchRunSucceededEvent) else "failed",
                 error=event.error if isinstance(event, QueueParallelBranchRunFailedEvent) else None,
                 created_at=int(time.time()),
@@ -770,6 +788,83 @@ class WorkflowCycleManage:
             ),
         )
 
+    def _workflow_loop_start_to_stream_response(
+        self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueLoopStartEvent
+    ) -> LoopNodeStartStreamResponse:
+        # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
+        _ = session
+        return LoopNodeStartStreamResponse(
+            task_id=task_id,
+            workflow_run_id=workflow_run.id,
+            data=LoopNodeStartStreamResponse.Data(
+                id=event.node_id,
+                node_id=event.node_id,
+                node_type=event.node_type.value,
+                title=event.node_data.title,
+                created_at=int(time.time()),
+                extras={},
+                inputs=event.inputs or {},
+                metadata=event.metadata or {},
+                parallel_id=event.parallel_id,
+                parallel_start_node_id=event.parallel_start_node_id,
+            ),
+        )
+
+    def _workflow_loop_next_to_stream_response(
+        self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueLoopNextEvent
+    ) -> LoopNodeNextStreamResponse:
+        # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
+        _ = session
+        return LoopNodeNextStreamResponse(
+            task_id=task_id,
+            workflow_run_id=workflow_run.id,
+            data=LoopNodeNextStreamResponse.Data(
+                id=event.node_id,
+                node_id=event.node_id,
+                node_type=event.node_type.value,
+                title=event.node_data.title,
+                index=event.index,
+                pre_loop_output=event.output,
+                created_at=int(time.time()),
+                extras={},
+                parallel_id=event.parallel_id,
+                parallel_start_node_id=event.parallel_start_node_id,
+                parallel_mode_run_id=event.parallel_mode_run_id,
+                duration=event.duration,
+            ),
+        )
+
+    def _workflow_loop_completed_to_stream_response(
+        self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueLoopCompletedEvent
+    ) -> LoopNodeCompletedStreamResponse:
+        # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
+        _ = session
+        return LoopNodeCompletedStreamResponse(
+            task_id=task_id,
+            workflow_run_id=workflow_run.id,
+            data=LoopNodeCompletedStreamResponse.Data(
+                id=event.node_id,
+                node_id=event.node_id,
+                node_type=event.node_type.value,
+                title=event.node_data.title,
+                outputs=event.outputs,
+                created_at=int(time.time()),
+                extras={},
+                inputs=event.inputs or {},
+                status=WorkflowNodeExecutionStatus.SUCCEEDED
+                if event.error is None
+                else WorkflowNodeExecutionStatus.FAILED,
+                error=None,
+                elapsed_time=(datetime.now(UTC).replace(tzinfo=None) - event.start_at).total_seconds(),
+                total_tokens=event.metadata.get("total_tokens", 0) if event.metadata else 0,
+                execution_metadata=event.metadata,
+                finished_at=int(time.time()),
+                steps=event.steps,
+                parallel_id=event.parallel_id,
+                parallel_start_node_id=event.parallel_start_node_id,
+            ),
+        )
+
     def _fetch_files_from_node_outputs(self, outputs_dict: Mapping[str, Any]) -> Sequence[Mapping[str, Any]]:
         """
         Fetch files from node outputs

+ 39 - 0
api/core/workflow/callbacks/workflow_logging_callback.py

@@ -11,6 +11,10 @@ from core.workflow.graph_engine.entities.event import (
     IterationRunNextEvent,
     IterationRunStartedEvent,
     IterationRunSucceededEvent,
+    LoopRunFailedEvent,
+    LoopRunNextEvent,
+    LoopRunStartedEvent,
+    LoopRunSucceededEvent,
     NodeRunFailedEvent,
     NodeRunStartedEvent,
     NodeRunStreamChunkEvent,
@@ -62,6 +66,12 @@ class WorkflowLoggingCallback(WorkflowCallback):
             self.on_workflow_iteration_next(event=event)
         elif isinstance(event, IterationRunSucceededEvent | IterationRunFailedEvent):
             self.on_workflow_iteration_completed(event=event)
+        elif isinstance(event, LoopRunStartedEvent):
+            self.on_workflow_loop_started(event=event)
+        elif isinstance(event, LoopRunNextEvent):
+            self.on_workflow_loop_next(event=event)
+        elif isinstance(event, LoopRunSucceededEvent | LoopRunFailedEvent):
+            self.on_workflow_loop_completed(event=event)
         else:
             self.print_text(f"\n[{event.__class__.__name__}]", color="blue")
 
@@ -160,6 +170,8 @@ class WorkflowLoggingCallback(WorkflowCallback):
         self.print_text(f"Branch ID: {event.parallel_start_node_id}", color="blue")
         if event.in_iteration_id:
             self.print_text(f"Iteration ID: {event.in_iteration_id}", color="blue")
+        if event.in_loop_id:
+            self.print_text(f"Loop ID: {event.in_loop_id}", color="blue")
 
     def on_workflow_parallel_completed(
         self, event: ParallelBranchRunSucceededEvent | ParallelBranchRunFailedEvent
@@ -182,6 +194,8 @@ class WorkflowLoggingCallback(WorkflowCallback):
         self.print_text(f"Branch ID: {event.parallel_start_node_id}", color=color)
         if event.in_iteration_id:
             self.print_text(f"Iteration ID: {event.in_iteration_id}", color=color)
+        if event.in_loop_id:
+            self.print_text(f"Loop ID: {event.in_loop_id}", color=color)
 
         if isinstance(event, ParallelBranchRunFailedEvent):
             self.print_text(f"Error: {event.error}", color=color)
@@ -213,6 +227,31 @@ class WorkflowLoggingCallback(WorkflowCallback):
         )
         self.print_text(f"Node ID: {event.iteration_id}", color="blue")
 
+    def on_workflow_loop_started(self, event: LoopRunStartedEvent) -> None:
+        """
+        Publish loop started
+        """
+        self.print_text("\n[LoopRunStartedEvent]", color="blue")
+        self.print_text(f"Loop Node ID: {event.loop_id}", color="blue")
+
+    def on_workflow_loop_next(self, event: LoopRunNextEvent) -> None:
+        """
+        Publish loop next
+        """
+        self.print_text("\n[LoopRunNextEvent]", color="blue")
+        self.print_text(f"Loop Node ID: {event.loop_id}", color="blue")
+        self.print_text(f"Loop Index: {event.index}", color="blue")
+
+    def on_workflow_loop_completed(self, event: LoopRunSucceededEvent | LoopRunFailedEvent) -> None:
+        """
+        Publish loop completed
+        """
+        self.print_text(
+            "\n[LoopRunSucceededEvent]" if isinstance(event, LoopRunSucceededEvent) else "\n[LoopRunFailedEvent]",
+            color="blue",
+        )
+        self.print_text(f"Node ID: {event.loop_id}", color="blue")
+
     def print_text(self, text: str, color: Optional[str] = None, end: str = "\n") -> None:
         """Print text with highlighting and no end characters."""
         text_to_print = self._get_colored_text(text, color) if color else text

+ 3 - 0
api/core/workflow/entities/node_entities.py

@@ -20,12 +20,15 @@ class NodeRunMetadataKey(StrEnum):
     AGENT_LOG = "agent_log"
     ITERATION_ID = "iteration_id"
     ITERATION_INDEX = "iteration_index"
+    LOOP_ID = "loop_id"
+    LOOP_INDEX = "loop_index"
     PARALLEL_ID = "parallel_id"
     PARALLEL_START_NODE_ID = "parallel_start_node_id"
     PARENT_PARALLEL_ID = "parent_parallel_id"
     PARENT_PARALLEL_START_NODE_ID = "parent_parallel_start_node_id"
     PARALLEL_MODE_RUN_ID = "parallel_mode_run_id"
     ITERATION_DURATION_MAP = "iteration_duration_map"  # single iteration duration if iteration node runs
+    LOOP_DURATION_MAP = "loop_duration_map"  # single loop duration if loop node runs
     ERROR_STRATEGY = "error_strategy"  # node in continue on error mode return the field
 
 

+ 4 - 1
api/core/workflow/entities/workflow_entities.py

@@ -3,7 +3,7 @@ from typing import Optional
 from pydantic import BaseModel
 
 from core.app.entities.app_invoke_entities import InvokeFrom
-from core.workflow.nodes.base import BaseIterationState, BaseNode
+from core.workflow.nodes.base import BaseIterationState, BaseLoopState, BaseNode
 from models.enums import UserFrom
 from models.workflow import Workflow, WorkflowType
 
@@ -41,11 +41,13 @@ class WorkflowRunState:
     class NodeRun(BaseModel):
         node_id: str
         iteration_node_id: str
+        loop_node_id: str
 
     workflow_node_runs: list[NodeRun]
     workflow_node_steps: int
 
     current_iteration_state: Optional[BaseIterationState]
+    current_loop_state: Optional[BaseLoopState]
 
     def __init__(
         self,
@@ -74,3 +76,4 @@ class WorkflowRunState:
         self.workflow_node_steps = 1
         self.workflow_node_runs = []
         self.current_iteration_state = None
+        self.current_loop_state = None

+ 62 - 1
api/core/workflow/graph_engine/entities/event.py

@@ -63,6 +63,8 @@ class BaseNodeEvent(GraphEngineEvent):
     """parent parallel start node id if node is in parallel"""
     in_iteration_id: Optional[str] = None
     """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
 
 
 class NodeRunStartedEvent(BaseNodeEvent):
@@ -100,6 +102,10 @@ class NodeInIterationFailedEvent(BaseNodeEvent):
     error: str = Field(..., description="error")
 
 
+class NodeInLoopFailedEvent(BaseNodeEvent):
+    error: str = Field(..., description="error")
+
+
 class NodeRunRetryEvent(NodeRunStartedEvent):
     error: str = Field(..., description="error")
     retry_index: int = Field(..., description="which retry attempt is about to be performed")
@@ -122,6 +128,8 @@ class BaseParallelBranchEvent(GraphEngineEvent):
     """parent parallel start node id if node is in parallel"""
     in_iteration_id: Optional[str] = None
     """iteration id if node is in iteration"""
+    in_loop_id: Optional[str] = None
+    """loop id if node is in loop"""
 
 
 class ParallelBranchRunStartedEvent(BaseParallelBranchEvent):
@@ -189,6 +197,59 @@ class IterationRunFailedEvent(BaseIterationEvent):
     error: str = Field(..., description="failed reason")
 
 
+###########################################
+# Loop Events
+###########################################
+
+
+class BaseLoopEvent(GraphEngineEvent):
+    loop_id: str = Field(..., description="loop node execution id")
+    loop_node_id: str = Field(..., description="loop node id")
+    loop_node_type: NodeType = Field(..., description="node type, loop or loop")
+    loop_node_data: BaseNodeData = Field(..., description="node data")
+    parallel_id: Optional[str] = None
+    """parallel id if node is in parallel"""
+    parallel_start_node_id: Optional[str] = None
+    """parallel start node id if node is in parallel"""
+    parent_parallel_id: Optional[str] = None
+    """parent parallel id if node is in parallel"""
+    parent_parallel_start_node_id: Optional[str] = None
+    """parent parallel start node id if node is in parallel"""
+    parallel_mode_run_id: Optional[str] = None
+    """loop run in parallel mode run id"""
+
+
+class LoopRunStartedEvent(BaseLoopEvent):
+    start_at: datetime = Field(..., description="start at")
+    inputs: Optional[Mapping[str, Any]] = None
+    metadata: Optional[Mapping[str, Any]] = None
+    predecessor_node_id: Optional[str] = None
+
+
+class LoopRunNextEvent(BaseLoopEvent):
+    index: int = Field(..., description="index")
+    pre_loop_output: Optional[Any] = None
+    duration: Optional[float] = None
+
+
+class LoopRunSucceededEvent(BaseLoopEvent):
+    start_at: datetime = Field(..., description="start at")
+    inputs: Optional[Mapping[str, Any]] = None
+    outputs: Optional[Mapping[str, Any]] = None
+    metadata: Optional[Mapping[str, Any]] = None
+    steps: int = 0
+    loop_duration_map: Optional[dict[str, float]] = None
+
+
+class LoopRunFailedEvent(BaseLoopEvent):
+    start_at: datetime = Field(..., description="start at")
+    inputs: Optional[Mapping[str, Any]] = None
+    outputs: Optional[Mapping[str, Any]] = None
+    metadata: Optional[Mapping[str, Any]] = None
+    steps: int = 0
+    error: str = Field(..., description="failed reason")
+
+
 ###########################################
 # Agent Events
 ###########################################
@@ -209,4 +270,4 @@ class AgentLogEvent(BaseAgentEvent):
     metadata: Optional[Mapping[str, Any]] = Field(default=None, description="metadata")
 
 
-InNodeEvent = BaseNodeEvent | BaseParallelBranchEvent | BaseIterationEvent | BaseAgentEvent
+InNodeEvent = BaseNodeEvent | BaseParallelBranchEvent | BaseIterationEvent | BaseAgentEvent | BaseLoopEvent

+ 7 - 0
api/core/workflow/graph_engine/graph_engine.py

@@ -19,6 +19,7 @@ from core.workflow.entities.variable_pool import VariablePool, VariableValue
 from core.workflow.graph_engine.condition_handlers.condition_manager import ConditionManager
 from core.workflow.graph_engine.entities.event import (
     BaseIterationEvent,
+    BaseLoopEvent,
     GraphEngineEvent,
     GraphRunFailedEvent,
     GraphRunPartialSucceededEvent,
@@ -648,6 +649,12 @@ class GraphEngine:
                             item.parallel_start_node_id = parallel_start_node_id
                             item.parent_parallel_id = parent_parallel_id
                             item.parent_parallel_start_node_id = parent_parallel_start_node_id
+                        elif isinstance(item, BaseLoopEvent):
+                            # add parallel info to loop event
+                            item.parallel_id = parallel_id
+                            item.parallel_start_node_id = parallel_start_node_id
+                            item.parent_parallel_id = parent_parallel_id
+                            item.parent_parallel_start_node_id = parent_parallel_start_node_id
 
                         yield item
                     else:

+ 1 - 0
api/core/workflow/nodes/answer/answer_stream_generate_router.py

@@ -158,6 +158,7 @@ class AnswerStreamGeneratorRouter:
                     NodeType.IF_ELSE,
                     NodeType.QUESTION_CLASSIFIER,
                     NodeType.ITERATION,
+                    NodeType.LOOP,
                     NodeType.VARIABLE_ASSIGNER,
                 }
                 or source_node_data.get("error_strategy") == ErrorStrategy.FAIL_BRANCH

+ 1 - 1
api/core/workflow/nodes/answer/answer_stream_processor.py

@@ -35,7 +35,7 @@ class AnswerStreamProcessor(StreamProcessor):
 
                 yield event
             elif isinstance(event, NodeRunStreamChunkEvent):
-                if event.in_iteration_id:
+                if event.in_iteration_id or event.in_loop_id:
                     yield event
                     continue
 

+ 9 - 2
api/core/workflow/nodes/base/__init__.py

@@ -1,4 +1,11 @@
-from .entities import BaseIterationNodeData, BaseIterationState, BaseNodeData
+from .entities import BaseIterationNodeData, BaseIterationState, BaseLoopNodeData, BaseLoopState, BaseNodeData
 from .node import BaseNode
 
-__all__ = ["BaseIterationNodeData", "BaseIterationState", "BaseNode", "BaseNodeData"]
+__all__ = [
+    "BaseIterationNodeData",
+    "BaseIterationState",
+    "BaseLoopNodeData",
+    "BaseLoopState",
+    "BaseNode",
+    "BaseNodeData",
+]

+ 15 - 0
api/core/workflow/nodes/base/entities.py

@@ -147,3 +147,18 @@ class BaseIterationState(BaseModel):
         pass
 
     metadata: MetaData
+
+
+class BaseLoopNodeData(BaseNodeData):
+    start_node_id: Optional[str] = None
+
+
+class BaseLoopState(BaseModel):
+    loop_node_id: str
+    index: int
+    inputs: dict
+
+    class MetaData(BaseModel):
+        pass
+
+    metadata: MetaData

+ 1 - 1
api/core/workflow/nodes/end/end_stream_processor.py

@@ -33,7 +33,7 @@ class EndStreamProcessor(StreamProcessor):
 
                 yield event
             elif isinstance(event, NodeRunStreamChunkEvent):
-                if event.in_iteration_id:
+                if event.in_iteration_id or event.in_loop_id:
                     if self.has_output and event.node_id not in self.output_node_ids:
                         event.chunk_content = "\n" + event.chunk_content
 

+ 1 - 0
api/core/workflow/nodes/enums.py

@@ -16,6 +16,7 @@ class NodeType(StrEnum):
     VARIABLE_AGGREGATOR = "variable-aggregator"
     LEGACY_VARIABLE_AGGREGATOR = "variable-assigner"  # TODO: Merge this into VARIABLE_AGGREGATOR in the database.
     LOOP = "loop"
+    LOOP_START = "loop-start"
     ITERATION = "iteration"
     ITERATION_START = "iteration-start"  # Fake start node for iteration.
     PARAMETER_EXTRACTOR = "parameter-extractor"

+ 5 - 0
api/core/workflow/nodes/loop/__init__.py

@@ -0,0 +1,5 @@
+from .entities import LoopNodeData
+from .loop_node import LoopNode
+from .loop_start_node import LoopStartNode
+
+__all__ = ["LoopNode", "LoopNodeData", "LoopStartNode"]

+ 44 - 3
api/core/workflow/nodes/loop/entities.py

@@ -1,13 +1,54 @@
-from core.workflow.nodes.base import BaseIterationNodeData, BaseIterationState
+from typing import Any, Literal, Optional
 
+from pydantic import Field
 
-class LoopNodeData(BaseIterationNodeData):
+from core.workflow.nodes.base import BaseLoopNodeData, BaseLoopState, BaseNodeData
+from core.workflow.utils.condition.entities import Condition
+
+
+class LoopNodeData(BaseLoopNodeData):
     """
     Loop Node Data.
     """
 
+    loop_count: int  # Maximum number of loops
+    break_conditions: list[Condition]  # Conditions to break the loop
+    logical_operator: Literal["and", "or"]
 
-class LoopState(BaseIterationState):
+
+class LoopStartNodeData(BaseNodeData):
+    """
+    Loop Start Node Data.
+    """
+
+    pass
+
+
+class LoopState(BaseLoopState):
     """
     Loop State.
     """
+
+    outputs: list[Any] = Field(default_factory=list)
+    current_output: Optional[Any] = None
+
+    class MetaData(BaseLoopState.MetaData):
+        """
+        Data.
+        """
+
+        loop_length: int
+
+    def get_last_output(self) -> Optional[Any]:
+        """
+        Get last output.
+        """
+        if self.outputs:
+            return self.outputs[-1]
+        return None
+
+    def get_current_output(self) -> Optional[Any]:
+        """
+        Get current output.
+        """
+        return self.current_output

+ 345 - 20
api/core/workflow/nodes/loop/loop_node.py

@@ -1,9 +1,35 @@
-from typing import Any
+import logging
+from collections.abc import Generator, Mapping, Sequence
+from datetime import datetime, timezone
+from typing import Any, cast
 
+from configs import dify_config
+from core.variables import IntegerSegment
+from core.workflow.entities.node_entities import NodeRunMetadataKey, NodeRunResult
+from core.workflow.graph_engine.entities.event import (
+    BaseGraphEvent,
+    BaseNodeEvent,
+    BaseParallelBranchEvent,
+    GraphRunFailedEvent,
+    InNodeEvent,
+    LoopRunFailedEvent,
+    LoopRunNextEvent,
+    LoopRunStartedEvent,
+    LoopRunSucceededEvent,
+    NodeRunFailedEvent,
+    NodeRunStartedEvent,
+    NodeRunStreamChunkEvent,
+    NodeRunSucceededEvent,
+)
+from core.workflow.graph_engine.entities.graph import Graph
 from core.workflow.nodes.base import BaseNode
 from core.workflow.nodes.enums import NodeType
-from core.workflow.nodes.loop.entities import LoopNodeData, LoopState
-from core.workflow.utils.condition.entities import Condition
+from core.workflow.nodes.event import NodeEvent, RunCompletedEvent
+from core.workflow.nodes.loop.entities import LoopNodeData
+from core.workflow.utils.condition.processor import ConditionProcessor
+from models.workflow import WorkflowNodeExecutionStatus
+
+logger = logging.getLogger(__name__)
 
 
 class LoopNode(BaseNode[LoopNodeData]):
@@ -14,24 +40,323 @@ class LoopNode(BaseNode[LoopNodeData]):
     _node_data_cls = LoopNodeData
     _node_type = NodeType.LOOP
 
-    def _run(self) -> LoopState:  # type: ignore
-        return super()._run()  # type: ignore
+    def _run(self) -> Generator[NodeEvent | InNodeEvent, None, None]:
+        """Run the node."""
+        # Get inputs
+        loop_count = self.node_data.loop_count
+        break_conditions = self.node_data.break_conditions
+        logical_operator = self.node_data.logical_operator
+
+        inputs = {"loop_count": loop_count}
+
+        if not self.node_data.start_node_id:
+            raise ValueError(f"field start_node_id in loop {self.node_id} not found")
+
+        # Initialize graph
+        loop_graph = Graph.init(graph_config=self.graph_config, root_node_id=self.node_data.start_node_id)
+        if not loop_graph:
+            raise ValueError("loop graph not found")
+
+        # Initialize variable pool
+        variable_pool = self.graph_runtime_state.variable_pool
+        variable_pool.add([self.node_id, "index"], 0)
+
+        from core.workflow.graph_engine.graph_engine import GraphEngine
+
+        graph_engine = GraphEngine(
+            tenant_id=self.tenant_id,
+            app_id=self.app_id,
+            workflow_type=self.workflow_type,
+            workflow_id=self.workflow_id,
+            user_id=self.user_id,
+            user_from=self.user_from,
+            invoke_from=self.invoke_from,
+            call_depth=self.workflow_call_depth,
+            graph=loop_graph,
+            graph_config=self.graph_config,
+            variable_pool=variable_pool,
+            max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS,
+            max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME,
+            thread_pool_id=self.thread_pool_id,
+        )
+
+        start_at = datetime.now(timezone.utc).replace(tzinfo=None)
+        condition_processor = ConditionProcessor()
+
+        # Start Loop event
+        yield LoopRunStartedEvent(
+            loop_id=self.id,
+            loop_node_id=self.node_id,
+            loop_node_type=self.node_type,
+            loop_node_data=self.node_data,
+            start_at=start_at,
+            inputs=inputs,
+            metadata={"loop_length": loop_count},
+            predecessor_node_id=self.previous_node_id,
+        )
+
+        yield LoopRunNextEvent(
+            loop_id=self.id,
+            loop_node_id=self.node_id,
+            loop_node_type=self.node_type,
+            loop_node_data=self.node_data,
+            index=0,
+            pre_loop_output=None,
+        )
+
+        try:
+            check_break_result = False
+            for i in range(loop_count):
+                # Run workflow
+                rst = graph_engine.run()
+                current_index_variable = variable_pool.get([self.node_id, "index"])
+                if not isinstance(current_index_variable, IntegerSegment):
+                    raise ValueError(f"loop {self.node_id} current index not found")
+                current_index = current_index_variable.value
+
+                check_break_result = False
+
+                for event in rst:
+                    if isinstance(event, (BaseNodeEvent | BaseParallelBranchEvent)) and not event.in_loop_id:
+                        event.in_loop_id = self.node_id
+
+                    if (
+                        isinstance(event, BaseNodeEvent)
+                        and event.node_type == NodeType.LOOP_START
+                        and not isinstance(event, NodeRunStreamChunkEvent)
+                    ):
+                        continue
+
+                    if isinstance(event, NodeRunSucceededEvent):
+                        yield self._handle_event_metadata(event=event, iter_run_index=current_index)
+
+                        # Check if all variables in break conditions exist
+                        exists_variable = False
+                        for condition in break_conditions:
+                            if not self.graph_runtime_state.variable_pool.get(condition.variable_selector):
+                                exists_variable = False
+                                break
+                            else:
+                                exists_variable = True
+                        if exists_variable:
+                            input_conditions, group_result, check_break_result = condition_processor.process_conditions(
+                                variable_pool=self.graph_runtime_state.variable_pool,
+                                conditions=break_conditions,
+                                operator=logical_operator,
+                            )
+                            if check_break_result:
+                                break
+
+                    elif isinstance(event, BaseGraphEvent):
+                        if isinstance(event, GraphRunFailedEvent):
+                            # Loop run failed
+                            yield LoopRunFailedEvent(
+                                loop_id=self.id,
+                                loop_node_id=self.node_id,
+                                loop_node_type=self.node_type,
+                                loop_node_data=self.node_data,
+                                start_at=start_at,
+                                inputs=inputs,
+                                steps=i,
+                                metadata={
+                                    NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens,
+                                    "completed_reason": "error",
+                                },
+                                error=event.error,
+                            )
+                            yield RunCompletedEvent(
+                                run_result=NodeRunResult(
+                                    status=WorkflowNodeExecutionStatus.FAILED,
+                                    error=event.error,
+                                    metadata={
+                                        NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens
+                                    },
+                                )
+                            )
+                            return
+                    elif isinstance(event, NodeRunFailedEvent):
+                        # Loop run failed
+                        yield event
+                        yield LoopRunFailedEvent(
+                            loop_id=self.id,
+                            loop_node_id=self.node_id,
+                            loop_node_type=self.node_type,
+                            loop_node_data=self.node_data,
+                            start_at=start_at,
+                            inputs=inputs,
+                            steps=i,
+                            metadata={
+                                NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens,
+                                "completed_reason": "error",
+                            },
+                            error=event.error,
+                        )
+                        yield RunCompletedEvent(
+                            run_result=NodeRunResult(
+                                status=WorkflowNodeExecutionStatus.FAILED,
+                                error=event.error,
+                                metadata={
+                                    NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens
+                                },
+                            )
+                        )
+                        return
+                    else:
+                        yield self._handle_event_metadata(event=cast(InNodeEvent, event), iter_run_index=current_index)
+
+                # Remove all nodes outputs from variable pool
+                for node_id in loop_graph.node_ids:
+                    variable_pool.remove([node_id])
+
+                if check_break_result:
+                    break
+
+                # Move to next loop
+                next_index = current_index + 1
+                variable_pool.add([self.node_id, "index"], next_index)
+
+                yield LoopRunNextEvent(
+                    loop_id=self.id,
+                    loop_node_id=self.node_id,
+                    loop_node_type=self.node_type,
+                    loop_node_data=self.node_data,
+                    index=next_index,
+                    pre_loop_output=None,
+                )
+
+            # Loop completed successfully
+            yield LoopRunSucceededEvent(
+                loop_id=self.id,
+                loop_node_id=self.node_id,
+                loop_node_type=self.node_type,
+                loop_node_data=self.node_data,
+                start_at=start_at,
+                inputs=inputs,
+                steps=loop_count,
+                metadata={
+                    NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens,
+                    "completed_reason": "loop_break" if check_break_result else "loop_completed",
+                },
+            )
+
+            yield RunCompletedEvent(
+                run_result=NodeRunResult(
+                    status=WorkflowNodeExecutionStatus.SUCCEEDED,
+                    metadata={NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens},
+                )
+            )
+
+        except Exception as e:
+            # Loop failed
+            logger.exception("Loop run failed")
+            yield LoopRunFailedEvent(
+                loop_id=self.id,
+                loop_node_id=self.node_id,
+                loop_node_type=self.node_type,
+                loop_node_data=self.node_data,
+                start_at=start_at,
+                inputs=inputs,
+                steps=loop_count,
+                metadata={
+                    "total_tokens": graph_engine.graph_runtime_state.total_tokens,
+                    "completed_reason": "error",
+                },
+                error=str(e),
+            )
+
+            yield RunCompletedEvent(
+                run_result=NodeRunResult(
+                    status=WorkflowNodeExecutionStatus.FAILED,
+                    error=str(e),
+                    metadata={NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens},
+                )
+            )
+
+        finally:
+            # Clean up
+            variable_pool.remove([self.node_id, "index"])
+
+    def _handle_event_metadata(
+        self,
+        *,
+        event: BaseNodeEvent | InNodeEvent,
+        iter_run_index: int,
+    ) -> NodeRunStartedEvent | BaseNodeEvent | InNodeEvent:
+        """
+        add iteration metadata to event.
+        """
+        if not isinstance(event, BaseNodeEvent):
+            return event
+        if event.route_node_state.node_run_result:
+            metadata = event.route_node_state.node_run_result.metadata
+            if not metadata:
+                metadata = {}
+            if NodeRunMetadataKey.LOOP_ID not in metadata:
+                metadata = {
+                    **metadata,
+                    NodeRunMetadataKey.LOOP_ID: self.node_id,
+                    NodeRunMetadataKey.LOOP_INDEX: iter_run_index,
+                }
+                event.route_node_state.node_run_result.metadata = metadata
+        return event
 
     @classmethod
-    def get_conditions(cls, node_config: dict[str, Any]) -> list[Condition]:
+    def _extract_variable_selector_to_variable_mapping(
+        cls,
+        *,
+        graph_config: Mapping[str, Any],
+        node_id: str,
+        node_data: LoopNodeData,
+    ) -> Mapping[str, Sequence[str]]:
         """
-        Get conditions.
+        Extract variable selector to variable mapping
+        :param graph_config: graph config
+        :param node_id: node id
+        :param node_data: node data
+        :return:
         """
-        node_id = node_config.get("id")
-        if not node_id:
-            return []
-
-        # TODO waiting for implementation
-        return [
-            Condition(  # type: ignore
-                variable_selector=[node_id, "index"],
-                comparison_operator="≤",
-                value_type="value_selector",
-                value_selector=[],
-            )
-        ]
+        variable_mapping = {}
+
+        # init graph
+        loop_graph = Graph.init(graph_config=graph_config, root_node_id=node_data.start_node_id)
+
+        if not loop_graph:
+            raise ValueError("loop graph not found")
+
+        for sub_node_id, sub_node_config in loop_graph.node_id_config_mapping.items():
+            if sub_node_config.get("data", {}).get("loop_id") != node_id:
+                continue
+
+            # variable selector to variable mapping
+            try:
+                # Get node class
+                from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING
+
+                node_type = NodeType(sub_node_config.get("data", {}).get("type"))
+                if node_type not in NODE_TYPE_CLASSES_MAPPING:
+                    continue
+                node_version = sub_node_config.get("data", {}).get("version", "1")
+                node_cls = NODE_TYPE_CLASSES_MAPPING[node_type][node_version]
+
+                sub_node_variable_mapping = node_cls.extract_variable_selector_to_variable_mapping(
+                    graph_config=graph_config, config=sub_node_config
+                )
+                sub_node_variable_mapping = cast(dict[str, Sequence[str]], sub_node_variable_mapping)
+            except NotImplementedError:
+                sub_node_variable_mapping = {}
+
+            # remove loop variables
+            sub_node_variable_mapping = {
+                sub_node_id + "." + key: value
+                for key, value in sub_node_variable_mapping.items()
+                if value[0] != node_id
+            }
+
+            variable_mapping.update(sub_node_variable_mapping)
+
+        # remove variable out from loop
+        variable_mapping = {
+            key: value for key, value in variable_mapping.items() if value[0] not in loop_graph.node_ids
+        }
+
+        return variable_mapping

+ 20 - 0
api/core/workflow/nodes/loop/loop_start_node.py

@@ -0,0 +1,20 @@
+from core.workflow.entities.node_entities import NodeRunResult
+from core.workflow.nodes.base import BaseNode
+from core.workflow.nodes.enums import NodeType
+from core.workflow.nodes.loop.entities import LoopStartNodeData
+from models.workflow import WorkflowNodeExecutionStatus
+
+
+class LoopStartNode(BaseNode):
+    """
+    Loop Start Node.
+    """
+
+    _node_data_cls = LoopStartNodeData
+    _node_type = NodeType.LOOP_START
+
+    def _run(self) -> NodeRunResult:
+        """
+        Run the node.
+        """
+        return NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED)

+ 9 - 0
api/core/workflow/nodes/node_mapping.py

@@ -13,6 +13,7 @@ from core.workflow.nodes.iteration import IterationNode, IterationStartNode
 from core.workflow.nodes.knowledge_retrieval import KnowledgeRetrievalNode
 from core.workflow.nodes.list_operator import ListOperatorNode
 from core.workflow.nodes.llm import LLMNode
+from core.workflow.nodes.loop import LoopNode, LoopStartNode
 from core.workflow.nodes.parameter_extractor import ParameterExtractorNode
 from core.workflow.nodes.question_classifier import QuestionClassifierNode
 from core.workflow.nodes.start import StartNode
@@ -85,6 +86,14 @@ NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[BaseNode]]] = {
         LATEST_VERSION: IterationStartNode,
         "1": IterationStartNode,
     },
+    NodeType.LOOP: {
+        LATEST_VERSION: LoopNode,
+        "1": LoopNode,
+    },
+    NodeType.LOOP_START: {
+        LATEST_VERSION: LoopStartNode,
+        "1": LoopStartNode,
+    },
     NodeType.PARAMETER_EXTRACTOR: {
         LATEST_VERSION: ParameterExtractorNode,
         "1": ParameterExtractorNode,

+ 19 - 0
api/services/app_generate_service.py

@@ -137,6 +137,25 @@ class AppGenerateService:
         else:
             raise ValueError(f"Invalid app mode {app_model.mode}")
 
+    @classmethod
+    def generate_single_loop(cls, app_model: App, user: Account, node_id: str, args: Any, streaming: bool = True):
+        if app_model.mode == AppMode.ADVANCED_CHAT.value:
+            workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
+            return AdvancedChatAppGenerator.convert_to_event_stream(
+                AdvancedChatAppGenerator().single_loop_generate(
+                    app_model=app_model, workflow=workflow, node_id=node_id, user=user, args=args, streaming=streaming
+                )
+            )
+        elif app_model.mode == AppMode.WORKFLOW.value:
+            workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
+            return AdvancedChatAppGenerator.convert_to_event_stream(
+                WorkflowAppGenerator().single_loop_generate(
+                    app_model=app_model, workflow=workflow, node_id=node_id, user=user, args=args, streaming=streaming
+                )
+            )
+        else:
+            raise ValueError(f"Invalid app mode {app_model.mode}")
+
     @classmethod
     def generate_more_like_this(
         cls,

+ 3 - 0
docker/.env.example

@@ -720,6 +720,9 @@ SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128
 # SSRF Proxy server HTTPS URL
 SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128
 
+# Maximum loop count in the workflow
+LOOP_NODE_MAX_COUNT=100
+
 # ------------------------------
 # Environment Variables for web Service
 # ------------------------------

+ 1 - 0
docker/docker-compose-template.yaml

@@ -67,6 +67,7 @@ services:
       TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-}
       INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-}
       PM2_INSTANCES: ${PM2_INSTANCES:-2}
+      LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
 
   # The postgres database.
   db:

+ 2 - 0
docker/docker-compose.yaml

@@ -310,6 +310,7 @@ x-shared-env: &shared-api-worker-env
   HTTP_REQUEST_NODE_MAX_TEXT_SIZE: ${HTTP_REQUEST_NODE_MAX_TEXT_SIZE:-1048576}
   SSRF_PROXY_HTTP_URL: ${SSRF_PROXY_HTTP_URL:-http://ssrf_proxy:3128}
   SSRF_PROXY_HTTPS_URL: ${SSRF_PROXY_HTTPS_URL:-http://ssrf_proxy:3128}
+  LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
   TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
   PGUSER: ${PGUSER:-${DB_USERNAME}}
   POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-${DB_PASSWORD}}
@@ -481,6 +482,7 @@ services:
       TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-}
       INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-}
       PM2_INSTANCES: ${PM2_INSTANCES:-2}
+      LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
 
   # The postgres database.
   db:

+ 3 - 0
web/.env.example

@@ -37,3 +37,6 @@ NEXT_PUBLIC_TOP_K_MAX_VALUE=10
 
 # The maximum number of tokens for segmentation
 NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
+
+# Maximum loop count in the workflow
+NEXT_PUBLIC_LOOP_NODE_MAX_COUNT=100

+ 2 - 0
web/app/components/base/chat/chat/answer/__mocks__/workflowProcess.ts

@@ -46,6 +46,7 @@ export const mockedWorkflowProcess = {
       parent_parallel_id: null,
       parent_parallel_start_node_id: null,
       iteration_id: null,
+      loop_id: null,
     },
     {
       extras: {},
@@ -107,6 +108,7 @@ export const mockedWorkflowProcess = {
       parent_parallel_id: null,
       parent_parallel_start_node_id: null,
       iteration_id: null,
+      loop_id: null,
     },
     {
       extras: {},

+ 36 - 1
web/app/components/base/chat/chat/hooks.ts

@@ -305,7 +305,7 @@ export const useChat = (
       else
         ttsUrl = `/apps/${params.appId}/text-to-audio`
     }
-    const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => {})
+    const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => { })
     ssePost(
       url,
       {
@@ -537,6 +537,9 @@ export const useChat = (
           if (nodeStartedData.iteration_id)
             return
 
+          if (data.loop_id)
+            return
+
           responseItem.workflowProcess!.tracing!.push({
             ...nodeStartedData,
             status: WorkflowRunningStatus.Running,
@@ -552,6 +555,9 @@ export const useChat = (
           if (nodeFinishedData.iteration_id)
             return
 
+          if (data.loop_id)
+            return
+
           const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
             if (!item.execution_metadata?.parallel_id)
               return item.node_id === nodeFinishedData.node_id
@@ -576,6 +582,35 @@ export const useChat = (
         onTTSEnd: (messageId: string, audio: string) => {
           player.playAudioWithAudio(audio, false)
         },
+        onLoopStart: ({ data: loopStartedData }) => {
+          responseItem.workflowProcess!.tracing!.push({
+            ...loopStartedData,
+            status: WorkflowRunningStatus.Running,
+          } as any)
+          updateCurrentQAOnTree({
+            placeholderQuestionId,
+            questionItem,
+            responseItem,
+            parentId: data.parent_message_id,
+          })
+        },
+        onLoopFinish: ({ data: loopFinishedData }) => {
+          const tracing = responseItem.workflowProcess!.tracing!
+          const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
+            && (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
+          tracing[loopIndex] = {
+            ...tracing[loopIndex],
+            ...loopFinishedData,
+            status: WorkflowRunningStatus.Succeeded,
+          } as any
+
+          updateCurrentQAOnTree({
+            placeholderQuestionId,
+            questionItem,
+            responseItem,
+            parentId: data.parent_message_id,
+          })
+        },
       })
     return true
   }, [

+ 66 - 0
web/app/components/base/icons/src/vender/workflow/Loop.json

@@ -0,0 +1,66 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "40",
+			"height": "40",
+			"viewBox": "0 0 40 40",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"filter": "url(#filter0_dd_10886_10012)",
+					"style": "transform: scale(2.5) translate(-12px, -8px)"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "rect",
+						"attributes": {
+							"x": "8",
+							"y": "5",
+							"width": "24",
+							"height": "24",
+							"rx": "8",
+							"fill": "#06AED4"
+						},
+						"children": []
+					},
+					{
+						"type": "element",
+						"name": "rect",
+						"attributes": {
+							"x": "8.25",
+							"y": "5.25",
+							"width": "23.5",
+							"height": "23.5",
+							"rx": "7.75",
+							"stroke": "#101828",
+							"stroke-opacity": "0.04",
+							"stroke-width": "0.5"
+						},
+						"children": []
+					},
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"fill-rule": "evenodd",
+							"clip-rule": "evenodd",
+							"d": "M13.0293 14.3451C14.5076 12.885 16.9007 12.885 18.3791 14.3451L19.9999 15.9459L21.6208 14.3451C23.0992 12.885 25.4922 12.885 26.9706 14.3451C28.4541 15.8103 28.4541 18.1897 26.9707 19.6549C25.4923 21.115 23.0992 21.115 21.6208 19.655L19.9999 18.0541L18.3791 19.655C16.9007 21.115 14.5076 21.115 13.0293 19.655C11.5457 18.1897 11.5457 15.8103 13.0293 14.3451ZM18.9326 17L17.325 15.4123C16.4309 14.5292 14.9774 14.5292 14.0833 15.4123C13.1944 16.2903 13.1944 17.7097 14.0833 18.5877C14.9774 19.4708 16.4309 19.4707 17.325 18.5877C17.325 18.5877 17.325 18.5877 17.325 18.5877L18.9326 17ZM21.0673 17L22.6748 18.5877C22.6748 18.5877 22.6748 18.5877 22.6748 18.5877C23.569 19.4707 25.0224 19.4707 25.9166 18.5877C26.8055 17.7098 26.8055 16.2902 25.9166 15.4123C25.0224 14.5292 23.569 14.5292 22.6748 15.4123C22.6748 15.4123 22.6748 15.4123 22.6748 15.4123L21.0673 17Z",
+							"fill": "white"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "Loop"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/workflow/Loop.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Loop.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Loop'
+
+export default Icon

+ 36 - 0
web/app/components/base/icons/src/vender/workflow/LoopStart.json

@@ -0,0 +1,36 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "12",
+			"height": "12",
+			"viewBox": "0 0 12 12",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "icons/block-start"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Vector",
+							"d": "M6.8498 1.72732C6.3379 1.3754 5.6621 1.3754 5.1502 1.72732L2.1502 3.78982C1.74317 4.06965 1.5 4.53193 1.5 5.02588V8.99983C1.5 9.82828 2.17158 10.4998 3 10.4998H4.25C4.52614 10.4998 4.75 10.276 4.75 9.99983V8.24983C4.75 7.55948 5.30965 6.99983 6 6.99983C6.69035 6.99983 7.25 7.55948 7.25 8.24983V9.99983C7.25 10.276 7.47385 10.4998 7.75 10.4998H9C9.82845 10.4998 10.5 9.82828 10.5 8.99983V5.02588C10.5 4.53193 10.2568 4.06965 9.8498 3.78982L6.8498 1.72732Z",
+							"fill": "red"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "LoopStart"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/workflow/LoopStart.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './LoopStart.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'LoopStart'
+
+export default Icon

+ 2 - 0
web/app/components/base/icons/src/vender/workflow/index.ts

@@ -9,6 +9,8 @@ export { default as Http } from './Http'
 export { default as IfElse } from './IfElse'
 export { default as IterationStart } from './IterationStart'
 export { default as Iteration } from './Iteration'
+export { default as LoopStart } from './LoopStart'
+export { default as Loop } from './Loop'
 export { default as Jinja } from './Jinja'
 export { default as KnowledgeRetrieval } from './KnowledgeRetrieval'
 export { default as ListFilter } from './ListFilter'

+ 35 - 0
web/app/components/share/text-generation/result/index.tsx

@@ -240,10 +240,42 @@ const Result: FC<IResultProps> = ({
               } as any
             }))
           },
+          onLoopStart: ({ data }) => {
+            setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+              draft.expand = true
+              draft.tracing!.push({
+                ...data,
+                status: NodeRunningStatus.Running,
+                expand: true,
+              } as any)
+            }))
+          },
+          onLoopNext: () => {
+            setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+              draft.expand = true
+              const loops = draft.tracing.find(item => item.node_id === data.node_id
+                && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
+              loops?.details!.push([])
+            }))
+          },
+          onLoopFinish: ({ data }) => {
+            setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+              draft.expand = true
+              const loopsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id
+                && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
+              draft.tracing[loopsIndex] = {
+                ...data,
+                expand: !!data.error,
+              } as any
+            }))
+          },
           onNodeStarted: ({ data }) => {
             if (data.iteration_id)
               return
 
+            if (data.loop_id)
+              return
+
             setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
               draft.expand = true
               draft.tracing!.push({
@@ -257,6 +289,9 @@ const Result: FC<IResultProps> = ({
             if (data.iteration_id)
               return
 
+            if (data.loop_id)
+              return
+
             setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
               const currentIndex = draft.tracing!.findIndex(trace => trace.node_id === data.node_id
                 && (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || trace.parallel_id === data.execution_metadata?.parallel_id))

+ 4 - 0
web/app/components/workflow/block-icon.tsx

@@ -15,6 +15,7 @@ import {
   KnowledgeRetrieval,
   ListFilter,
   Llm,
+  Loop,
   ParameterExtractor,
   QuestionClassifier,
   TemplatingTransform,
@@ -51,6 +52,8 @@ const getIcon = (type: BlockEnum, className: string) => {
     [BlockEnum.Tool]: <VariableX className={className} />,
     [BlockEnum.IterationStart]: <VariableX className={className} />,
     [BlockEnum.Iteration]: <Iteration className={className} />,
+    [BlockEnum.LoopStart]: <VariableX className={className} />,
+    [BlockEnum.Loop]: <Loop className={className} />,
     [BlockEnum.ParameterExtractor]: <ParameterExtractor className={className} />,
     [BlockEnum.DocExtractor]: <DocsExtractor className={className} />,
     [BlockEnum.ListFilter]: <ListFilter className={className} />,
@@ -64,6 +67,7 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
   [BlockEnum.End]: 'bg-util-colors-warning-warning-500',
   [BlockEnum.IfElse]: 'bg-util-colors-cyan-cyan-500',
   [BlockEnum.Iteration]: 'bg-util-colors-cyan-cyan-500',
+  [BlockEnum.Loop]: 'bg-util-colors-cyan-cyan-500',
   [BlockEnum.HttpRequest]: 'bg-util-colors-violet-violet-500',
   [BlockEnum.Answer]: 'bg-util-colors-warning-warning-500',
   [BlockEnum.KnowledgeRetrieval]: 'bg-util-colors-green-green-500',

+ 5 - 0
web/app/components/workflow/block-selector/constants.tsx

@@ -44,6 +44,11 @@ export const BLOCKS: Block[] = [
     type: BlockEnum.Iteration,
     title: 'Iteration',
   },
+  {
+    classification: BlockClassificationEnum.Logic,
+    type: BlockEnum.Loop,
+    title: 'Loop',
+  },
   {
     classification: BlockClassificationEnum.Transform,
     type: BlockEnum.Code,

+ 4 - 1
web/app/components/workflow/candidate-node.tsx

@@ -14,7 +14,7 @@ import {
 } from './store'
 import { WorkflowHistoryEvent, useNodesInteractions, useWorkflowHistory } from './hooks'
 import { CUSTOM_NODE } from './constants'
-import { getIterationStartNode } from './utils'
+import { getIterationStartNode, getLoopStartNode } from './utils'
 import CustomNode from './nodes'
 import CustomNoteNode from './note-node'
 import { CUSTOM_NOTE_NODE } from './note-node/constants'
@@ -56,6 +56,9 @@ const CandidateNode = () => {
         })
         if (candidateNode.data.type === BlockEnum.Iteration)
           draft.push(getIterationStartNode(candidateNode.id))
+
+        if (candidateNode.data.type === BlockEnum.Loop)
+          draft.push(getLoopStartNode(candidateNode.id))
       })
       setNodes(newNodes)
       if (candidateNode.type === CUSTOM_NOTE_NODE)

+ 44 - 1
web/app/components/workflow/constants.ts

@@ -15,10 +15,12 @@ import VariableAssignerDefault from './nodes/variable-assigner/default'
 import AssignerDefault from './nodes/assigner/default'
 import EndNodeDefault from './nodes/end/default'
 import IterationDefault from './nodes/iteration/default'
+import LoopDefault from './nodes/loop/default'
 import DocExtractorDefault from './nodes/document-extractor/default'
 import ListFilterDefault from './nodes/list-operator/default'
 import IterationStartDefault from './nodes/iteration-start/default'
 import AgentDefault from './nodes/agent/default'
+import LoopStartDefault from './nodes/loop-start/default'
 
 type NodesExtraData = {
   author: string
@@ -102,6 +104,24 @@ export const NODES_EXTRA_DATA: Record<BlockEnum, NodesExtraData> = {
     getAvailableNextNodes: IterationStartDefault.getAvailableNextNodes,
     checkValid: IterationStartDefault.checkValid,
   },
+  [BlockEnum.Loop]: {
+    author: 'AICT-Team',
+    about: '',
+    availablePrevNodes: [],
+    availableNextNodes: [],
+    getAvailablePrevNodes: LoopDefault.getAvailablePrevNodes,
+    getAvailableNextNodes: LoopDefault.getAvailableNextNodes,
+    checkValid: LoopDefault.checkValid,
+  },
+  [BlockEnum.LoopStart]: {
+    author: 'AICT-Team',
+    about: '',
+    availablePrevNodes: [],
+    availableNextNodes: [],
+    getAvailablePrevNodes: LoopStartDefault.getAvailablePrevNodes,
+    getAvailableNextNodes: LoopStartDefault.getAvailableNextNodes,
+    checkValid: LoopStartDefault.checkValid,
+  },
   [BlockEnum.Code]: {
     author: 'Dify',
     about: '',
@@ -265,6 +285,18 @@ export const NODES_INITIAL_DATA = {
     desc: '',
     ...IterationStartDefault.defaultValue,
   },
+  [BlockEnum.Loop]: {
+    type: BlockEnum.Loop,
+    title: '',
+    desc: '',
+    ...LoopDefault.defaultValue,
+  },
+  [BlockEnum.LoopStart]: {
+    type: BlockEnum.LoopStart,
+    title: '',
+    desc: '',
+    ...LoopStartDefault.defaultValue,
+  },
   [BlockEnum.Code]: {
     type: BlockEnum.Code,
     title: '',
@@ -355,6 +387,7 @@ export const NODES_INITIAL_DATA = {
 export const MAX_ITERATION_PARALLEL_NUM = 10
 export const MIN_ITERATION_PARALLEL_NUM = 1
 export const DEFAULT_ITER_TIMES = 1
+export const DEFAULT_LOOP_TIMES = 1
 export const NODE_WIDTH = 240
 export const X_OFFSET = 60
 export const NODE_WIDTH_X_OFFSET = NODE_WIDTH + X_OFFSET
@@ -373,6 +406,16 @@ export const ITERATION_PADDING = {
   bottom: 20,
   left: 16,
 }
+
+export const LOOP_NODE_Z_INDEX = 1
+export const LOOP_CHILDREN_Z_INDEX = 1002
+export const LOOP_PADDING = {
+  top: 65,
+  right: 16,
+  bottom: 20,
+  left: 16,
+}
+
 export const PARALLEL_LIMIT = 10
 export const PARALLEL_DEPTH_LIMIT = 3
 
@@ -399,7 +442,7 @@ export const RETRIEVAL_OUTPUT_STRUCT = `{
 export const SUPPORT_OUTPUT_VARS_NODE = [
   BlockEnum.Start, BlockEnum.LLM, BlockEnum.KnowledgeRetrieval, BlockEnum.Code, BlockEnum.TemplateTransform,
   BlockEnum.HttpRequest, BlockEnum.Tool, BlockEnum.VariableAssigner, BlockEnum.VariableAggregator, BlockEnum.QuestionClassifier,
-  BlockEnum.ParameterExtractor, BlockEnum.Iteration,
+  BlockEnum.ParameterExtractor, BlockEnum.Iteration, BlockEnum.Loop,
   BlockEnum.DocExtractor, BlockEnum.ListFilter,
   BlockEnum.Agent,
 ]

+ 4 - 3
web/app/components/workflow/custom-edge.tsx

@@ -23,7 +23,7 @@ import type {
 } from './types'
 import { NodeRunningStatus } from './types'
 import { getEdgeColor } from './utils'
-import { ITERATION_CHILDREN_Z_INDEX } from './constants'
+import { ITERATION_CHILDREN_Z_INDEX, LOOP_CHILDREN_Z_INDEX } from './constants'
 import CustomEdgeLinearGradientRender from './custom-edge-linear-gradient-render'
 import cn from '@/utils/classnames'
 import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
@@ -56,8 +56,8 @@ const CustomEdge = ({
   })
   const [open, setOpen] = useState(false)
   const { handleNodeAdd } = useNodesInteractions()
-  const { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration)
-  const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration)
+  const { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration, (data as Edge['data'])?.isInLoop)
+  const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration, (data as Edge['data'])?.isInLoop)
   const {
     _sourceRunningStatus,
     _targetRunningStatus,
@@ -144,6 +144,7 @@ const CustomEdge = ({
             data?._hovering ? 'block' : 'hidden',
             open && '!block',
             data.isInIteration && `z-[${ITERATION_CHILDREN_Z_INDEX}]`,
+            data.isInLoop && `z-[${LOOP_CHILDREN_Z_INDEX}]`,
           )}
           style={{
             position: 'absolute',

+ 13 - 0
web/app/components/workflow/hooks/use-helpline.ts

@@ -21,6 +21,14 @@ export const useHelpline = () => {
         showVerticalHelpLineNodes: [],
       }
     }
+
+    if (node.data.isInLoop) {
+      return {
+        showHorizontalHelpLineNodes: [],
+        showVerticalHelpLineNodes: [],
+      }
+    }
+
     const showHorizontalHelpLineNodes = nodes.filter((n) => {
       if (n.id === node.id)
         return false
@@ -28,6 +36,9 @@ export const useHelpline = () => {
       if (n.data.isInIteration)
         return false
 
+      if (n.data.isInLoop)
+        return false
+
       const nY = Math.ceil(n.position.y)
       const nodeY = Math.ceil(node.position.y)
 
@@ -67,6 +78,8 @@ export const useHelpline = () => {
         return false
       if (n.data.isInIteration)
         return false
+      if (n.data.isInLoop)
+        return false
 
       const nX = Math.ceil(n.position.x)
       const nodeX = Math.ceil(node.position.x)

+ 12 - 4
web/app/components/workflow/hooks/use-nodes-data.ts

@@ -31,7 +31,7 @@ export const useNodesExtraData = () => {
   }), [t, isChatMode])
 }
 
-export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean) => {
+export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean, isInLoop?: boolean) => {
   const nodesExtraData = useNodesExtraData()
   const availablePrevBlocks = useMemo(() => {
     if (!nodeType)
@@ -48,15 +48,23 @@ export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean
   return useMemo(() => {
     return {
       availablePrevBlocks: availablePrevBlocks.filter((nType) => {
-        if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.End))
+        if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
           return false
+
+        if (isInLoop && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
+          return false
+
         return true
       }),
       availableNextBlocks: availableNextBlocks.filter((nType) => {
-        if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.End))
+        if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
           return false
+
+        if (isInLoop && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
+          return false
+
         return true
       }),
     }
-  }, [isInIteration, availablePrevBlocks, availableNextBlocks])
+  }, [isInIteration, availablePrevBlocks, availableNextBlocks, isInLoop])
 }

+ 207 - 36
web/app/components/workflow/hooks/use-nodes-interactions.ts

@@ -29,6 +29,8 @@ import {
   CUSTOM_EDGE,
   ITERATION_CHILDREN_Z_INDEX,
   ITERATION_PADDING,
+  LOOP_CHILDREN_Z_INDEX,
+  LOOP_PADDING,
   NODES_INITIAL_DATA,
   NODE_WIDTH_X_OFFSET,
   X_OFFSET,
@@ -42,9 +44,12 @@ import {
 } from '../utils'
 import { CUSTOM_NOTE_NODE } from '../note-node/constants'
 import type { IterationNodeType } from '../nodes/iteration/types'
+import type { LoopNodeType } from '../nodes/loop/types'
 import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
+import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
 import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
 import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions'
+import { useNodeLoopInteractions } from '../nodes/loop/use-interactions'
 import { useWorkflowHistoryStore } from '../workflow-history-store'
 import { useNodesSyncDraft } from './use-nodes-sync-draft'
 import { useHelpline } from './use-helpline'
@@ -73,6 +78,10 @@ export const useNodesInteractions = () => {
     handleNodeIterationChildDrag,
     handleNodeIterationChildrenCopy,
   } = useNodeIterationInteractions()
+  const {
+    handleNodeLoopChildDrag,
+    handleNodeLoopChildrenCopy,
+  } = useNodeLoopInteractions()
   const dragNodeStartPosition = useRef({ x: 0, y: 0 } as { x: number; y: number })
 
   const { saveStateToHistory, undo, redo } = useWorkflowHistory()
@@ -86,6 +95,9 @@ export const useNodesInteractions = () => {
     if (node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_NOTE_NODE)
       return
 
+    if (node.type === CUSTOM_LOOP_START_NODE || node.type === CUSTOM_NOTE_NODE)
+      return
+
     dragNodeStartPosition.current = { x: node.position.x, y: node.position.y }
   }, [workflowStore, getNodesReadOnly])
 
@@ -96,6 +108,9 @@ export const useNodesInteractions = () => {
     if (node.type === CUSTOM_ITERATION_START_NODE)
       return
 
+    if (node.type === CUSTOM_LOOP_START_NODE)
+      return
+
     const {
       getNodes,
       setNodes,
@@ -105,6 +120,7 @@ export const useNodesInteractions = () => {
     const nodes = getNodes()
 
     const { restrictPosition } = handleNodeIterationChildDrag(node)
+    const { restrictPosition: restrictLoopPosition } = handleNodeLoopChildDrag(node)
 
     const {
       showHorizontalHelpLineNodes,
@@ -120,6 +136,8 @@ export const useNodesInteractions = () => {
         currentNode.position.x = showVerticalHelpLineNodes[0].position.x
       else if (restrictPosition.x !== undefined)
         currentNode.position.x = restrictPosition.x
+      else if (restrictLoopPosition.x !== undefined)
+        currentNode.position.x = restrictLoopPosition.x
       else
         currentNode.position.x = node.position.x
 
@@ -127,12 +145,13 @@ export const useNodesInteractions = () => {
         currentNode.position.y = showHorizontalHelpLineNodes[0].position.y
       else if (restrictPosition.y !== undefined)
         currentNode.position.y = restrictPosition.y
+      else if (restrictLoopPosition.y !== undefined)
+        currentNode.position.y = restrictLoopPosition.y
       else
         currentNode.position.y = node.position.y
     })
-
     setNodes(newNodes)
-  }, [store, getNodesReadOnly, handleSetHelpline, handleNodeIterationChildDrag])
+  }, [getNodesReadOnly, store, handleNodeIterationChildDrag, handleNodeLoopChildDrag, handleSetHelpline])
 
   const handleNodeDragStop = useCallback<NodeDragHandler>((_, node) => {
     const {
@@ -163,6 +182,9 @@ export const useNodesInteractions = () => {
     if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE)
       return
 
+    if (node.type === CUSTOM_LOOP_START_NODE || node.type === CUSTOM_NOTE_NODE)
+      return
+
     const {
       getNodes,
       setNodes,
@@ -237,6 +259,9 @@ export const useNodesInteractions = () => {
     if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE)
       return
 
+    if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_LOOP_START_NODE)
+      return
+
     const {
       setEnteringNodePayload,
     } = workflowStore.getState()
@@ -311,6 +336,8 @@ export const useNodesInteractions = () => {
   const handleNodeClick = useCallback<NodeMouseHandler>((_, node) => {
     if (node.type === CUSTOM_ITERATION_START_NODE)
       return
+    if (node.type === CUSTOM_LOOP_START_NODE)
+      return
     handleNodeSelect(node.id)
   }, [handleNodeSelect])
 
@@ -344,6 +371,10 @@ export const useNodesInteractions = () => {
     if (edges.find(edge => edge.source === source && edge.sourceHandle === sourceHandle && edge.target === target && edge.targetHandle === targetHandle))
       return
 
+    const parendNode = nodes.find(node => node.id === targetNode?.parentId)
+    const isInIteration = parendNode && parendNode.data.type === BlockEnum.Iteration
+    const isInLoop = !!parendNode && parendNode.data.type === BlockEnum.Loop
+
     const newEdge = {
       id: `${source}-${sourceHandle}-${target}-${targetHandle}`,
       type: CUSTOM_EDGE,
@@ -354,10 +385,12 @@ export const useNodesInteractions = () => {
       data: {
         sourceType: nodes.find(node => node.id === source)!.data.type,
         targetType: nodes.find(node => node.id === target)!.data.type,
-        isInIteration: !!targetNode?.parentId,
-        iteration_id: targetNode?.parentId,
+        isInIteration,
+        iteration_id: isInIteration ? targetNode?.parentId : undefined,
+        isInLoop,
+        loop_id: isInLoop ? targetNode?.parentId : undefined,
       },
-      zIndex: targetNode?.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
+      zIndex: targetNode?.parentId ? (isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
     }
     const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
       [
@@ -554,6 +587,45 @@ export const useNodesInteractions = () => {
         }
       }
     }
+
+    if (currentNode.data.type === BlockEnum.Loop) {
+      const loopChildren = nodes.filter(node => node.parentId === currentNode.id)
+
+      if (loopChildren.length) {
+        if (currentNode.data._isBundled) {
+          loopChildren.forEach((child) => {
+            handleNodeDelete(child.id)
+          })
+          return handleNodeDelete(nodeId)
+        }
+        else {
+          if (loopChildren.length === 1) {
+            handleNodeDelete(loopChildren[0].id)
+            handleNodeDelete(nodeId)
+
+            return
+          }
+          const { setShowConfirm, showConfirm } = workflowStore.getState()
+
+          if (!showConfirm) {
+            setShowConfirm({
+              title: t('workflow.nodes.loop.deleteTitle'),
+              desc: t('workflow.nodes.loop.deleteDesc') || '',
+              onConfirm: () => {
+                loopChildren.forEach((child) => {
+                  handleNodeDelete(child.id)
+                })
+                handleNodeDelete(nodeId)
+                handleSyncWorkflowDraft()
+                setShowConfirm(undefined)
+              },
+            })
+            return
+          }
+        }
+      }
+    }
+
     const connectedEdges = getConnectedEdges([{ id: nodeId } as Node], edges)
     const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(connectedEdges.map(edge => ({ type: 'remove', edge })), nodes)
     const newNodes = produce(nodes, (draft: Node[]) => {
@@ -612,6 +684,7 @@ export const useNodesInteractions = () => {
     const {
       newNode,
       newIterationStartNode,
+      newLoopStartNode,
     } = generateNewNode({
       data: {
         ...NODES_INITIAL_DATA[nodeType],
@@ -640,13 +713,28 @@ export const useNodesInteractions = () => {
       }
       newNode.parentId = prevNode.parentId
       newNode.extent = prevNode.extent
+
+      const parentNode = nodes.find(node => node.id === prevNode.parentId) || null
+      const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
+      const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
+
       if (prevNode.parentId) {
-        newNode.data.isInIteration = true
-        newNode.data.iteration_id = prevNode.parentId
-        newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
-        if (newNode.data.type === BlockEnum.Answer || newNode.data.type === BlockEnum.Tool || newNode.data.type === BlockEnum.Assigner) {
-          const parentIterNodeIndex = nodes.findIndex(node => node.id === prevNode.parentId)
-          const iterNodeData: IterationNodeType = nodes[parentIterNodeIndex].data
+        newNode.data.isInIteration = isInIteration
+        newNode.data.isInLoop = isInLoop
+        if (isInIteration) {
+          newNode.data.iteration_id = parentNode.id
+          newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
+        }
+        if (isInLoop) {
+          newNode.data.loop_id = parentNode.id
+          newNode.zIndex = LOOP_CHILDREN_Z_INDEX
+        }
+        if (isInIteration && (newNode.data.type === BlockEnum.Answer || newNode.data.type === BlockEnum.Tool || newNode.data.type === BlockEnum.Assigner)) {
+          const iterNodeData: IterationNodeType = parentNode.data
+          iterNodeData._isShowTips = true
+        }
+        if (isInLoop && (newNode.data.type === BlockEnum.Answer || newNode.data.type === BlockEnum.Tool || newNode.data.type === BlockEnum.Assigner)) {
+          const iterNodeData: IterationNodeType = parentNode.data
           iterNodeData._isShowTips = true
         }
       }
@@ -661,11 +749,13 @@ export const useNodesInteractions = () => {
         data: {
           sourceType: prevNode.data.type,
           targetType: newNode.data.type,
-          isInIteration: !!prevNode.parentId,
-          iteration_id: prevNode.parentId,
+          isInIteration,
+          isInLoop,
+          iteration_id: isInIteration ? prevNode.parentId : undefined,
+          loop_id: isInLoop ? prevNode.parentId : undefined,
           _connectedNodeIsSelected: true,
         },
-        zIndex: prevNode.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
+        zIndex: prevNode.parentId ? (isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
       }
       const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
         [
@@ -686,10 +776,17 @@ export const useNodesInteractions = () => {
 
           if (node.data.type === BlockEnum.Iteration && prevNode.parentId === node.id)
             node.data._children?.push(newNode.id)
+
+          if (node.data.type === BlockEnum.Loop && prevNode.parentId === node.id)
+            node.data._children?.push(newNode.id)
         })
         draft.push(newNode)
+
         if (newIterationStartNode)
           draft.push(newIterationStartNode)
+
+        if (newLoopStartNode)
+          draft.push(newLoopStartNode)
       })
 
       if (newNode.data.type === BlockEnum.VariableAssigner || newNode.data.type === BlockEnum.VariableAggregator) {
@@ -736,10 +833,22 @@ export const useNodesInteractions = () => {
       }
       newNode.parentId = nextNode.parentId
       newNode.extent = nextNode.extent
-      if (nextNode.parentId) {
-        newNode.data.isInIteration = true
-        newNode.data.iteration_id = nextNode.parentId
-        newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
+
+      const parentNode = nodes.find(node => node.id === nextNode.parentId) || null
+      const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
+      const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
+
+      if (parentNode && nextNode.parentId) {
+        newNode.data.isInIteration = isInIteration
+        newNode.data.isInLoop = isInLoop
+        if (isInIteration) {
+          newNode.data.iteration_id = parentNode.id
+          newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
+        }
+        if (isInLoop) {
+          newNode.data.loop_id = parentNode.id
+          newNode.zIndex = LOOP_CHILDREN_Z_INDEX
+        }
       }
 
       let newEdge
@@ -755,11 +864,13 @@ export const useNodesInteractions = () => {
           data: {
             sourceType: newNode.data.type,
             targetType: nextNode.data.type,
-            isInIteration: !!nextNode.parentId,
-            iteration_id: nextNode.parentId,
+            isInIteration,
+            isInLoop,
+            iteration_id: isInIteration ? nextNode.parentId : undefined,
+            loop_id: isInLoop ? nextNode.parentId : undefined,
             _connectedNodeIsSelected: true,
           },
-          zIndex: nextNode.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
+          zIndex: nextNode.parentId ? (isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
         }
       }
 
@@ -796,10 +907,20 @@ export const useNodesInteractions = () => {
             node.data.start_node_id = newNode.id
             node.data.startNodeType = newNode.data.type
           }
+
+          if (node.data.type === BlockEnum.Loop && nextNode.parentId === node.id)
+            node.data._children?.push(newNode.id)
+
+          if (node.data.type === BlockEnum.Loop && node.data.start_node_id === nextNodeId) {
+            node.data.start_node_id = newNode.id
+            node.data.startNodeType = newNode.data.type
+          }
         })
         draft.push(newNode)
         if (newIterationStartNode)
           draft.push(newIterationStartNode)
+        if (newLoopStartNode)
+          draft.push(newLoopStartNode)
       })
       if (newEdge) {
         const newEdges = produce(edges, (draft) => {
@@ -840,10 +961,22 @@ export const useNodesInteractions = () => {
       }
       newNode.parentId = prevNode.parentId
       newNode.extent = prevNode.extent
-      if (prevNode.parentId) {
-        newNode.data.isInIteration = true
-        newNode.data.iteration_id = prevNode.parentId
-        newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
+
+      const parentNode = nodes.find(node => node.id === prevNode.parentId) || null
+      const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
+      const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
+
+      if (parentNode && prevNode.parentId) {
+        newNode.data.isInIteration = isInIteration
+        newNode.data.isInLoop = isInLoop
+        if (isInIteration) {
+          newNode.data.iteration_id = parentNode.id
+          newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
+        }
+        if (isInLoop) {
+          newNode.data.loop_id = parentNode.id
+          newNode.zIndex = LOOP_CHILDREN_Z_INDEX
+        }
       }
 
       const currentEdgeIndex = edges.findIndex(edge => edge.source === prevNodeId && edge.target === nextNodeId)
@@ -857,13 +990,20 @@ export const useNodesInteractions = () => {
         data: {
           sourceType: prevNode.data.type,
           targetType: newNode.data.type,
-          isInIteration: !!prevNode.parentId,
-          iteration_id: prevNode.parentId,
+          isInIteration,
+          isInLoop,
+          iteration_id: isInIteration ? prevNode.parentId : undefined,
+          loop_id: isInLoop ? prevNode.parentId : undefined,
           _connectedNodeIsSelected: true,
         },
-        zIndex: prevNode.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
+        zIndex: prevNode.parentId ? (isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
       }
       let newNextEdge: Edge | null = null
+
+      const nextNodeParentNode = nodes.find(node => node.id === nextNode.parentId) || null
+      const isNextNodeInIteration = !!nextNodeParentNode && nextNodeParentNode.data.type === BlockEnum.Iteration
+      const isNextNodeInLoop = !!nextNodeParentNode && nextNodeParentNode.data.type === BlockEnum.Loop
+
       if (nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier) {
         newNextEdge = {
           id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
@@ -875,11 +1015,13 @@ export const useNodesInteractions = () => {
           data: {
             sourceType: newNode.data.type,
             targetType: nextNode.data.type,
-            isInIteration: !!nextNode.parentId,
-            iteration_id: nextNode.parentId,
+            isInIteration: isNextNodeInIteration,
+            isInLoop: isNextNodeInLoop,
+            iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined,
+            loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
             _connectedNodeIsSelected: true,
           },
-          zIndex: nextNode.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
+          zIndex: nextNode.parentId ? (isNextNodeInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
         }
       }
       const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
@@ -908,10 +1050,14 @@ export const useNodesInteractions = () => {
 
           if (node.data.type === BlockEnum.Iteration && prevNode.parentId === node.id)
             node.data._children?.push(newNode.id)
+          if (node.data.type === BlockEnum.Loop && prevNode.parentId === node.id)
+            node.data._children?.push(newNode.id)
         })
         draft.push(newNode)
         if (newIterationStartNode)
           draft.push(newIterationStartNode)
+        if (newLoopStartNode)
+          draft.push(newLoopStartNode)
       })
       setNodes(newNodes)
       if (newNode.data.type === BlockEnum.VariableAssigner || newNode.data.type === BlockEnum.VariableAggregator) {
@@ -969,6 +1115,7 @@ export const useNodesInteractions = () => {
     const {
       newNode: newCurrentNode,
       newIterationStartNode,
+      newLoopStartNode,
     } = generateNewNode({
       data: {
         ...NODES_INITIAL_DATA[nodeType],
@@ -978,7 +1125,9 @@ export const useNodesInteractions = () => {
         _connectedTargetHandleIds: [],
         selected: currentNode.data.selected,
         isInIteration: currentNode.data.isInIteration,
+        isInLoop: currentNode.data.isInLoop,
         iteration_id: currentNode.data.iteration_id,
+        loop_id: currentNode.data.loop_id,
       },
       position: {
         x: currentNode.position.x,
@@ -1010,6 +1159,8 @@ export const useNodesInteractions = () => {
       draft.splice(index, 1, newCurrentNode)
       if (newIterationStartNode)
         draft.push(newIterationStartNode)
+      if (newLoopStartNode)
+        draft.push(newLoopStartNode)
     })
     setNodes(newNodes)
     const newEdges = produce(edges, (draft) => {
@@ -1058,6 +1209,9 @@ export const useNodesInteractions = () => {
     if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE)
       return
 
+    if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_LOOP_START_NODE)
+      return
+
     e.preventDefault()
     const container = document.querySelector('#workflow-container')
     const { x, y } = container!.getBoundingClientRect()
@@ -1085,13 +1239,15 @@ export const useNodesInteractions = () => {
 
     if (nodeId) {
       // If nodeId is provided, copy that specific node
-      const nodeToCopy = nodes.find(node => node.id === nodeId && node.data.type !== BlockEnum.Start && node.type !== CUSTOM_ITERATION_START_NODE)
+      const nodeToCopy = nodes.find(node => node.id === nodeId && node.data.type !== BlockEnum.Start
+        && node.type !== CUSTOM_ITERATION_START_NODE && node.type !== CUSTOM_LOOP_START_NODE)
       if (nodeToCopy)
         setClipboardElements([nodeToCopy])
     }
     else {
       // If no nodeId is provided, fall back to the current behavior
-      const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start && !node.data.isInIteration)
+      const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start
+        && !node.data.isInIteration && !node.data.isInLoop)
 
       if (bundledNodes.length) {
         setClipboardElements(bundledNodes)
@@ -1138,6 +1294,7 @@ export const useNodesInteractions = () => {
         const {
           newNode,
           newIterationStartNode,
+          newLoopStartNode,
         } = generateNewNode({
           type: nodeToPaste.type,
           data: {
@@ -1176,6 +1333,17 @@ export const useNodesInteractions = () => {
           newChildren.push(newIterationStartNode!)
         }
 
+        if (nodeToPaste.data.type === BlockEnum.Loop) {
+          newLoopStartNode!.parentId = newNode.id;
+          (newNode.data as LoopNodeType).start_node_id = newLoopStartNode!.id
+
+          newChildren = handleNodeLoopChildrenCopy(nodeToPaste.id, newNode.id)
+          newChildren.forEach((child) => {
+            newNode.data._children?.push(child.id)
+          })
+          newChildren.push(newLoopStartNode!)
+        }
+
         nodesToPaste.push(newNode)
 
         if (newChildren.length)
@@ -1206,7 +1374,7 @@ export const useNodesInteractions = () => {
       saveStateToHistory(WorkflowHistoryEvent.NodePaste)
       handleSyncWorkflowDraft()
     }
-  }, [getNodesReadOnly, workflowStore, store, reactflow, saveStateToHistory, handleSyncWorkflowDraft, handleNodeIterationChildrenCopy])
+  }, [getNodesReadOnly, workflowStore, store, reactflow, saveStateToHistory, handleSyncWorkflowDraft, handleNodeIterationChildrenCopy, handleNodeLoopChildrenCopy])
 
   const handleNodesDuplicate = useCallback((nodeId?: string) => {
     if (getNodesReadOnly())
@@ -1278,9 +1446,12 @@ export const useNodesInteractions = () => {
     })
 
     if (rightNode! && bottomNode!) {
-      if (width < rightNode!.position.x + rightNode.width! + ITERATION_PADDING.right)
+      const parentNode = nodes.find(n => n.id === rightNode.parentId)
+      const paddingMap = parentNode?.data.type === BlockEnum.Iteration ? ITERATION_PADDING : LOOP_PADDING
+
+      if (width < rightNode!.position.x + rightNode.width! + paddingMap.right)
         return
-      if (height < bottomNode.position.y + bottomNode.height! + ITERATION_PADDING.bottom)
+      if (height < bottomNode.position.y + bottomNode.height! + paddingMap.bottom)
         return
     }
     const newNodes = produce(nodes, (draft) => {

+ 3 - 0
web/app/components/workflow/hooks/use-workflow-run-event/index.ts

@@ -6,6 +6,9 @@ export * from './use-workflow-node-finished'
 export * from './use-workflow-node-iteration-started'
 export * from './use-workflow-node-iteration-next'
 export * from './use-workflow-node-iteration-finished'
+export * from './use-workflow-node-loop-started'
+export * from './use-workflow-node-loop-next'
+export * from './use-workflow-node-loop-finished'
 export * from './use-workflow-node-retry'
 export * from './use-workflow-text-chunk'
 export * from './use-workflow-text-replace'

+ 46 - 0
web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts

@@ -0,0 +1,46 @@
+import { useCallback } from 'react'
+import { useStoreApi } from 'reactflow'
+import produce from 'immer'
+import type { LoopFinishedResponse } from '@/types/workflow'
+import { useWorkflowStore } from '@/app/components/workflow/store'
+import { DEFAULT_LOOP_TIMES } from '@/app/components/workflow/constants'
+
+export const useWorkflowNodeLoopFinished = () => {
+  const store = useStoreApi()
+  const workflowStore = useWorkflowStore()
+
+  const handleWorkflowNodeLoopFinished = useCallback((params: LoopFinishedResponse) => {
+    const { data } = params
+    const {
+      workflowRunningData,
+      setWorkflowRunningData,
+      setLoopTimes,
+    } = workflowStore.getState()
+    const {
+      getNodes,
+      setNodes,
+    } = store.getState()
+    const nodes = getNodes()
+    setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
+      const currentIndex = draft.tracing!.findIndex(item => item.id === data.id)
+
+      if (currentIndex > -1) {
+        draft.tracing![currentIndex] = {
+          ...draft.tracing![currentIndex],
+          ...data,
+        }
+      }
+    }))
+    setLoopTimes(DEFAULT_LOOP_TIMES)
+    const newNodes = produce(nodes, (draft) => {
+      const currentNode = draft.find(node => node.id === data.node_id)!
+
+      currentNode.data._runningStatus = data.status
+    })
+    setNodes(newNodes)
+  }, [workflowStore, store])
+
+  return {
+    handleWorkflowNodeLoopFinished,
+  }
+}

+ 35 - 0
web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts

@@ -0,0 +1,35 @@
+import { useCallback } from 'react'
+import { useStoreApi } from 'reactflow'
+import produce from 'immer'
+import type { LoopNextResponse } from '@/types/workflow'
+import { useWorkflowStore } from '@/app/components/workflow/store'
+
+export const useWorkflowNodeLoopNext = () => {
+  const store = useStoreApi()
+  const workflowStore = useWorkflowStore()
+
+  const handleWorkflowNodeLoopNext = useCallback((params: LoopNextResponse) => {
+    const {
+      loopTimes,
+      setLoopTimes,
+    } = workflowStore.getState()
+
+    const { data } = params
+    const {
+      getNodes,
+      setNodes,
+    } = store.getState()
+
+    const nodes = getNodes()
+    const newNodes = produce(nodes, (draft) => {
+      const currentNode = draft.find(node => node.id === data.node_id)!
+      currentNode.data._loopIndex = loopTimes
+      setLoopTimes(loopTimes + 1)
+    })
+    setNodes(newNodes)
+  }, [workflowStore, store])
+
+  return {
+    handleWorkflowNodeLoopNext,
+  }
+}

+ 85 - 0
web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts

@@ -0,0 +1,85 @@
+import { useCallback } from 'react'
+import {
+  useReactFlow,
+  useStoreApi,
+} from 'reactflow'
+import produce from 'immer'
+import { useWorkflowStore } from '@/app/components/workflow/store'
+import type { LoopStartedResponse } from '@/types/workflow'
+import { NodeRunningStatus } from '@/app/components/workflow/types'
+import { DEFAULT_LOOP_TIMES } from '@/app/components/workflow/constants'
+
+export const useWorkflowNodeLoopStarted = () => {
+  const store = useStoreApi()
+  const reactflow = useReactFlow()
+  const workflowStore = useWorkflowStore()
+
+  const handleWorkflowNodeLoopStarted = useCallback((
+    params: LoopStartedResponse,
+    containerParams: {
+      clientWidth: number,
+      clientHeight: number,
+    },
+  ) => {
+    const { data } = params
+    const {
+      workflowRunningData,
+      setWorkflowRunningData,
+      setLoopTimes,
+    } = workflowStore.getState()
+    const {
+      getNodes,
+      setNodes,
+      edges,
+      setEdges,
+      transform,
+    } = store.getState()
+    const nodes = getNodes()
+    setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
+      draft.tracing!.push({
+        ...data,
+        status: NodeRunningStatus.Running,
+      })
+    }))
+    setLoopTimes(DEFAULT_LOOP_TIMES)
+
+    const {
+      setViewport,
+    } = reactflow
+    const currentNodeIndex = nodes.findIndex(node => node.id === data.node_id)
+    const currentNode = nodes[currentNodeIndex]
+    const position = currentNode.position
+    const zoom = transform[2]
+
+    if (!currentNode.parentId) {
+      setViewport({
+        x: (containerParams.clientWidth - 400 - currentNode.width! * zoom) / 2 - position.x * zoom,
+        y: (containerParams.clientHeight - currentNode.height! * zoom) / 2 - position.y * zoom,
+        zoom: transform[2],
+      })
+    }
+    const newNodes = produce(nodes, (draft) => {
+      draft[currentNodeIndex].data._runningStatus = NodeRunningStatus.Running
+      draft[currentNodeIndex].data._loopLength = data.metadata.loop_length
+      draft[currentNodeIndex].data._waitingRun = false
+    })
+    setNodes(newNodes)
+    const newEdges = produce(edges, (draft) => {
+      const incomeEdges = draft.filter(edge => edge.target === data.node_id)
+
+      incomeEdges.forEach((edge) => {
+        edge.data = {
+          ...edge.data,
+          _sourceRunningStatus: nodes.find(node => node.id === edge.source)!.data._runningStatus,
+          _targetRunningStatus: NodeRunningStatus.Running,
+          _waitingRun: false,
+        }
+      })
+    })
+    setEdges(newEdges)
+  }, [workflowStore, store, reactflow])
+
+  return {
+    handleWorkflowNodeLoopStarted,
+  }
+}

+ 9 - 0
web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts

@@ -6,6 +6,9 @@ import {
   useWorkflowNodeIterationFinished,
   useWorkflowNodeIterationNext,
   useWorkflowNodeIterationStarted,
+  useWorkflowNodeLoopFinished,
+  useWorkflowNodeLoopNext,
+  useWorkflowNodeLoopStarted,
   useWorkflowNodeRetry,
   useWorkflowNodeStarted,
   useWorkflowStarted,
@@ -22,6 +25,9 @@ export const useWorkflowRunEvent = () => {
   const { handleWorkflowNodeIterationStarted } = useWorkflowNodeIterationStarted()
   const { handleWorkflowNodeIterationNext } = useWorkflowNodeIterationNext()
   const { handleWorkflowNodeIterationFinished } = useWorkflowNodeIterationFinished()
+  const { handleWorkflowNodeLoopStarted } = useWorkflowNodeLoopStarted()
+  const { handleWorkflowNodeLoopNext } = useWorkflowNodeLoopNext()
+  const { handleWorkflowNodeLoopFinished } = useWorkflowNodeLoopFinished()
   const { handleWorkflowNodeRetry } = useWorkflowNodeRetry()
   const { handleWorkflowTextChunk } = useWorkflowTextChunk()
   const { handleWorkflowTextReplace } = useWorkflowTextReplace()
@@ -36,6 +42,9 @@ export const useWorkflowRunEvent = () => {
     handleWorkflowNodeIterationStarted,
     handleWorkflowNodeIterationNext,
     handleWorkflowNodeIterationFinished,
+    handleWorkflowNodeLoopStarted,
+    handleWorkflowNodeLoopNext,
+    handleWorkflowNodeLoopFinished,
     handleWorkflowNodeRetry,
     handleWorkflowTextChunk,
     handleWorkflowTextReplace,

+ 52 - 2
web/app/components/workflow/hooks/use-workflow-run.ts

@@ -36,6 +36,9 @@ export const useWorkflowRun = () => {
     handleWorkflowNodeIterationStarted,
     handleWorkflowNodeIterationNext,
     handleWorkflowNodeIterationFinished,
+    handleWorkflowNodeLoopStarted,
+    handleWorkflowNodeLoopNext,
+    handleWorkflowNodeLoopFinished,
     handleWorkflowNodeRetry,
     handleWorkflowAgentLog,
     handleWorkflowTextChunk,
@@ -118,6 +121,9 @@ export const useWorkflowRun = () => {
       onIterationStart,
       onIterationNext,
       onIterationFinish,
+      onLoopStart,
+      onLoopNext,
+      onLoopFinish,
       onNodeRetry,
       onAgentLog,
       onError,
@@ -162,7 +168,7 @@ export const useWorkflowRun = () => {
       else
         ttsUrl = `/apps/${params.appId}/text-to-audio`
     }
-    const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => {})
+    const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => { })
 
     ssePost(
       url,
@@ -230,6 +236,30 @@ export const useWorkflowRun = () => {
           if (onIterationFinish)
             onIterationFinish(params)
         },
+        onLoopStart: (params) => {
+          handleWorkflowNodeLoopStarted(
+            params,
+            {
+              clientWidth,
+              clientHeight,
+            },
+          )
+
+          if (onLoopStart)
+            onLoopStart(params)
+        },
+        onLoopNext: (params) => {
+          handleWorkflowNodeLoopNext(params)
+
+          if (onLoopNext)
+            onLoopNext(params)
+        },
+        onLoopFinish: (params) => {
+          handleWorkflowNodeLoopFinished(params)
+
+          if (onLoopFinish)
+            onLoopFinish(params)
+        },
         onNodeRetry: (params) => {
           handleWorkflowNodeRetry(params)
 
@@ -260,7 +290,27 @@ export const useWorkflowRun = () => {
         ...restCallback,
       },
     )
-  }, [store, workflowStore, doSyncWorkflowDraft, handleWorkflowStarted, handleWorkflowFinished, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeRetry, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowAgentLog, pathname])
+  }, [
+    store,
+    workflowStore,
+    doSyncWorkflowDraft,
+    handleWorkflowStarted,
+    handleWorkflowFinished,
+    handleWorkflowFailed,
+    handleWorkflowNodeStarted,
+    handleWorkflowNodeFinished,
+    handleWorkflowNodeIterationStarted,
+    handleWorkflowNodeIterationNext,
+    handleWorkflowNodeIterationFinished,
+    handleWorkflowNodeLoopStarted,
+    handleWorkflowNodeLoopNext,
+    handleWorkflowNodeLoopFinished,
+    handleWorkflowNodeRetry,
+    handleWorkflowTextChunk,
+    handleWorkflowTextReplace,
+    handleWorkflowAgentLog,
+    pathname],
+  )
 
   const handleStopRun = useCallback((taskId: string) => {
     const appId = useAppStore.getState().appDetail?.id

+ 3 - 0
web/app/components/workflow/hooks/use-workflow-variables.ts

@@ -44,6 +44,7 @@ export const useWorkflowVariables = () => {
     parentNode,
     valueSelector,
     isIterationItem,
+    isLoopItem,
     availableNodes,
     isChatMode,
     isConstant,
@@ -51,6 +52,7 @@ export const useWorkflowVariables = () => {
     valueSelector: ValueSelector
     parentNode?: Node | null
     isIterationItem?: boolean
+    isLoopItem?: boolean
     availableNodes: any[]
     isChatMode: boolean
     isConstant?: boolean
@@ -59,6 +61,7 @@ export const useWorkflowVariables = () => {
       parentNode,
       valueSelector,
       isIterationItem,
+      isLoopItem,
       availableNodes,
       isChatMode,
       isConstant,

+ 38 - 4
web/app/components/workflow/hooks/use-workflow.ts

@@ -57,6 +57,7 @@ import {
 import I18n from '@/context/i18n'
 import { CollectionType } from '@/app/components/tools/types'
 import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
+import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
 import { useWorkflowConfig } from '@/service/use-workflow'
 import { canFindTool } from '@/utils'
 
@@ -89,7 +90,7 @@ export const useWorkflow = () => {
     const currentNode = nodes.find(node => node.id === nodeId)
 
     if (currentNode?.parentId)
-      startNode = nodes.find(node => node.parentId === currentNode.parentId && node.type === CUSTOM_ITERATION_START_NODE)
+      startNode = nodes.find(node => node.parentId === currentNode.parentId && (node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_LOOP_START_NODE))
 
     if (!startNode)
       return []
@@ -239,6 +240,15 @@ export const useWorkflow = () => {
     return nodes.filter(node => node.parentId === nodeId)
   }, [store])
 
+  const getLoopNodeChildren = useCallback((nodeId: string) => {
+    const {
+      getNodes,
+    } = store.getState()
+    const nodes = getNodes()
+
+    return nodes.filter(node => node.parentId === nodeId)
+  }, [store])
+
   const isFromStartNode = useCallback((nodeId: string) => {
     const { getNodes } = store.getState()
     const nodes = getNodes()
@@ -280,7 +290,7 @@ export const useWorkflow = () => {
       setNodes(newNodes)
     }
 
-  // eslint-disable-next-line react-hooks/exhaustive-deps
+    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [store])
 
   const isVarUsedInNodes = useCallback((varSelector: ValueSelector) => {
@@ -425,6 +435,7 @@ export const useWorkflow = () => {
     getNode,
     getBeforeNodeById,
     getIterationNodeChildren,
+    getLoopNodeChildren,
   }
 }
 
@@ -520,7 +531,7 @@ export const useWorkflowInit = () => {
 
   useEffect(() => {
     handleGetInitialWorkflowData()
-  // eslint-disable-next-line react-hooks/exhaustive-deps
+    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [])
 
   const handleFetchPreloadData = useCallback(async () => {
@@ -537,7 +548,7 @@ export const useWorkflowInit = () => {
       workflowStore.getState().setPublishedAt(publishedWorkflow?.created_at)
     }
     catch (e) {
-
+      console.error(e)
     }
   }, [workflowStore, appDetail])
 
@@ -638,3 +649,26 @@ export const useIsNodeInIteration = (iterationId: string) => {
     isNodeInIteration,
   }
 }
+
+export const useIsNodeInLoop = (loopId: string) => {
+  const store = useStoreApi()
+
+  const isNodeInLoop = useCallback((nodeId: string) => {
+    const {
+      getNodes,
+    } = store.getState()
+    const nodes = getNodes()
+    const node = nodes.find(node => node.id === nodeId)
+
+    if (!node)
+      return false
+
+    if (node.parentId === loopId)
+      return true
+
+    return false
+  }, [loopId, store])
+  return {
+    isNodeInLoop,
+  }
+}

+ 4 - 0
web/app/components/workflow/index.tsx

@@ -59,6 +59,8 @@ import CustomNoteNode from './note-node'
 import { CUSTOM_NOTE_NODE } from './note-node/constants'
 import CustomIterationStartNode from './nodes/iteration-start'
 import { CUSTOM_ITERATION_START_NODE } from './nodes/iteration-start/constants'
+import CustomLoopStartNode from './nodes/loop-start'
+import { CUSTOM_LOOP_START_NODE } from './nodes/loop-start/constants'
 import Operator from './operator'
 import CustomEdge from './custom-edge'
 import CustomConnectionLine from './custom-connection-line'
@@ -102,6 +104,7 @@ const nodeTypes = {
   [CUSTOM_NODE]: CustomNode,
   [CUSTOM_NOTE_NODE]: CustomNoteNode,
   [CUSTOM_ITERATION_START_NODE]: CustomIterationStartNode,
+  [CUSTOM_LOOP_START_NODE]: CustomLoopStartNode,
 }
 const edgeTypes = {
   [CUSTOM_EDGE]: CustomEdge,
@@ -353,6 +356,7 @@ const Workflow: FC<WorkflowProps> = memo(({
         onSelectionDrag={handleSelectionDrag}
         onPaneContextMenu={handlePaneContextMenu}
         connectionLineComponent={CustomConnectionLine}
+        // TODO: For LOOP node, how to distinguish between ITERATION and LOOP here? Maybe both are the same?
         connectionLineContainerStyle={{ zIndex: ITERATION_CHILDREN_Z_INDEX }}
         defaultViewport={viewport}
         multiSelectionKeyCode={null}

+ 1 - 1
web/app/components/workflow/nodes/_base/components/next-step/add.tsx

@@ -38,7 +38,7 @@ const Add = ({
   const [open, setOpen] = useState(false)
   const { handleNodeAdd } = useNodesInteractions()
   const { nodesReadOnly } = useNodesReadOnly()
-  const { availableNextBlocks } = useAvailableBlocks(nodeData.type, nodeData.isInIteration)
+  const { availableNextBlocks } = useAvailableBlocks(nodeData.type, nodeData.isInIteration, nodeData.isInLoop)
   const { checkParallelLimit } = useWorkflow()
 
   const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {

+ 1 - 1
web/app/components/workflow/nodes/_base/components/next-step/operator.tsx

@@ -36,7 +36,7 @@ const ChangeItem = ({
   const {
     availablePrevBlocks,
     availableNextBlocks,
-  } = useAvailableBlocks(data.type, data.isInIteration)
+  } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop)
 
   const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
     handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue)

+ 2 - 2
web/app/components/workflow/nodes/_base/components/node-handle.tsx

@@ -47,7 +47,7 @@ export const NodeTargetHandle = memo(({
   const { handleNodeAdd } = useNodesInteractions()
   const { getNodesReadOnly } = useNodesReadOnly()
   const connected = data._connectedTargetHandleIds?.includes(handleId)
-  const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration)
+  const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop)
   const isConnectable = !!availablePrevBlocks.length
 
   const handleOpenChange = useCallback((v: boolean) => {
@@ -129,7 +129,7 @@ export const NodeSourceHandle = memo(({
   const [open, setOpen] = useState(false)
   const { handleNodeAdd } = useNodesInteractions()
   const { getNodesReadOnly } = useNodesReadOnly()
-  const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration)
+  const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop)
   const isConnectable = !!availableNextBlocks.length
   const isChatMode = useIsChatMode()
   const { checkParallelLimit } = useWorkflow()

+ 1 - 1
web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx

@@ -30,7 +30,7 @@ const ChangeBlock = ({
   const {
     availablePrevBlocks,
     availableNextBlocks,
-  } = useAvailableBlocks(nodeData.type, nodeData.isInIteration)
+  } = useAvailableBlocks(nodeData.type, nodeData.isInIteration, nodeData.isInLoop)
 
   const availableNodes = useMemo(() => {
     if (availablePrevBlocks.length && availableNextBlocks.length)

+ 1 - 1
web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx

@@ -79,7 +79,7 @@ const PanelOperatorPopup = ({
     return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
   }, [data, nodesExtraData, language, buildInTools, customTools, workflowTools])
 
-  const showChangeBlock = data.type !== BlockEnum.Start && !nodesReadOnly && data.type !== BlockEnum.Iteration
+  const showChangeBlock = data.type !== BlockEnum.Start && !nodesReadOnly && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop
 
   const link = useNodeHelpLink(data.type)
 

+ 97 - 0
web/app/components/workflow/nodes/_base/components/variable/utils.ts

@@ -13,6 +13,7 @@ import { VarType as ToolVarType } from '../../../tool/types'
 import type { ToolNodeType } from '../../../tool/types'
 import type { ParameterExtractorNodeType } from '../../../parameter-extractor/types'
 import type { IterationNodeType } from '../../../iteration/types'
+import type { LoopNodeType } from '../../../loop/types'
 import type { ListFilterNodeType } from '../../../list-operator/types'
 import { OUTPUT_FILE_SUB_VARIABLES } from '../../../constants'
 import type { DocExtractorNodeType } from '../../../document-extractor/types'
@@ -518,10 +519,61 @@ const getIterationItemType = ({
   }
 }
 
+const getLoopItemType = ({
+  valueSelector,
+  beforeNodesOutputVars,
+}: {
+  valueSelector: ValueSelector
+  beforeNodesOutputVars: NodeOutPutVar[]
+}): VarType => {
+  const outputVarNodeId = valueSelector[0]
+  const isSystem = isSystemVar(valueSelector)
+
+  const targetVar = isSystem ? beforeNodesOutputVars.find(v => v.isStartNode) : beforeNodesOutputVars.find(v => v.nodeId === outputVarNodeId)
+  if (!targetVar)
+    return VarType.string
+
+  let arrayType: VarType = VarType.string
+
+  let curr: any = targetVar.vars
+  if (isSystem) {
+    arrayType = curr.find((v: any) => v.variable === (valueSelector).join('.'))?.type
+  }
+  else {
+    (valueSelector).slice(1).forEach((key, i) => {
+      const isLast = i === valueSelector.length - 2
+      curr = curr?.find((v: any) => v.variable === key)
+      if (isLast) {
+        arrayType = curr?.type
+      }
+      else {
+        if (curr?.type === VarType.object || curr?.type === VarType.file)
+          curr = curr.children
+      }
+    })
+  }
+
+  switch (arrayType as VarType) {
+    case VarType.arrayString:
+      return VarType.string
+    case VarType.arrayNumber:
+      return VarType.number
+    case VarType.arrayObject:
+      return VarType.object
+    case VarType.array:
+      return VarType.any
+    case VarType.arrayFile:
+      return VarType.file
+    default:
+      return VarType.string
+  }
+}
+
 export const getVarType = ({
   parentNode,
   valueSelector,
   isIterationItem,
+  isLoopItem,
   availableNodes,
   isChatMode,
   isConstant,
@@ -532,6 +584,7 @@ export const getVarType = ({
   valueSelector: ValueSelector
   parentNode?: Node | null
   isIterationItem?: boolean
+  isLoopItem?: boolean
   availableNodes: any[]
   isChatMode: boolean
   isConstant?: boolean
@@ -567,6 +620,26 @@ export const getVarType = ({
     if (valueSelector[1] === 'index')
       return VarType.number
   }
+
+  const isLoopInnerVar = parentNode?.data.type === BlockEnum.Loop
+  if (isLoopItem) {
+    return getLoopItemType({
+      valueSelector,
+      beforeNodesOutputVars,
+    })
+  }
+  if (isLoopInnerVar) {
+    if (valueSelector[1] === 'item') {
+      const itemType = getLoopItemType({
+        valueSelector: (parentNode?.data as any).iterator_selector || [],
+        beforeNodesOutputVars,
+      })
+      return itemType
+    }
+    if (valueSelector[1] === 'index')
+      return VarType.number
+  }
+
   const isSystem = isSystemVar(valueSelector)
   const isEnv = isENV(valueSelector)
   const isChatVar = isConversationVar(valueSelector)
@@ -802,6 +875,14 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
       break
     }
 
+    case BlockEnum.Loop: {
+      const payload = data as LoopNodeType
+      res = payload.break_conditions?.map((c) => {
+        return c.variable_selector || []
+      }) || []
+      break
+    }
+
     case BlockEnum.ListFilter: {
       res = [(data as ListFilterNodeType).variable]
       break
@@ -1079,6 +1160,17 @@ export const updateNodeVars = (oldNode: Node, oldVarSelector: ValueSelector, new
 
         break
       }
+      case BlockEnum.Loop: {
+        const payload = data as LoopNodeType
+        if (payload.break_conditions) {
+          payload.break_conditions = payload.break_conditions.map((c) => {
+            if (c.variable_selector?.join('.') === oldVarSelector.join('.'))
+              c.variable_selector = newVarSelector
+            return c
+          })
+        }
+        break
+      }
       case BlockEnum.ListFilter: {
         const payload = data as ListFilterNodeType
         if (payload.variable.join('.') === oldVarSelector.join('.'))
@@ -1200,6 +1292,11 @@ export const getNodeOutputVars = (node: Node, isChatMode: boolean): ValueSelecto
       break
     }
 
+    case BlockEnum.Loop: {
+      res.push([id, 'output'])
+      break
+    }
+
     case BlockEnum.DocExtractor: {
       res.push([id, 'text'])
       break

+ 16 - 2
web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx

@@ -114,6 +114,9 @@ const VarReferencePicker: FC<Props> = ({
   const isInIteration = !!node?.data.isInIteration
   const iterationNode = isInIteration ? getNodes().find(n => n.id === node.parentId) : null
 
+  const isInLoop = !!node?.data.isInLoop
+  const loopNode = isInLoop ? getNodes().find(n => n.id === node.parentId) : null
+
   const triggerRef = useRef<HTMLDivElement>(null)
   const [triggerWidth, setTriggerWidth] = useState(TRIGGER_DEFAULT_WIDTH)
   useEffect(() => {
@@ -142,6 +145,14 @@ const VarReferencePicker: FC<Props> = ({
     return false
   }, [isInIteration, value, node])
 
+  const isLoopVar = useMemo(() => {
+    if (!isInLoop)
+      return false
+    if (value[0] === node?.parentId && ['item', 'index'].includes(value[1]))
+      return true
+    return false
+  }, [isInLoop, value, node])
+
   const outputVarNodeId = hasValue ? value[0] : ''
   const outputVarNode = useMemo(() => {
     if (!hasValue || isConstant)
@@ -150,11 +161,14 @@ const VarReferencePicker: FC<Props> = ({
     if (isIterationVar)
       return iterationNode?.data
 
+    if (isLoopVar)
+      return loopNode?.data
+
     if (isSystemVar(value as ValueSelector))
       return startNode?.data
 
     return getNodeInfoById(availableNodes, outputVarNodeId)?.data
-  }, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode])
+  }, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode, isLoopVar, loopNode])
 
   const varName = useMemo(() => {
     if (hasValue) {
@@ -220,7 +234,7 @@ const VarReferencePicker: FC<Props> = ({
   }, [onChange, varKindType])
 
   const type = getCurrentVariableType({
-    parentNode: iterationNode,
+    parentNode: isInIteration ? iterationNode : loopNode,
     valueSelector: value as ValueSelector,
     availableNodes,
     isChatMode,

+ 1 - 0
web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts

@@ -13,6 +13,7 @@ type Params = {
   passedInAvailableNodes?: Node[]
 }
 
+// TODO: loop type?
 const useAvailableVarList = (nodeId: string, {
   onlyLeafNodeVar,
   filterVar,

+ 5 - 0
web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts

@@ -27,6 +27,8 @@ export const useNodeHelpLink = (nodeType: BlockEnum) => {
         [BlockEnum.Assigner]: 'variable-assigner',
         [BlockEnum.Iteration]: 'iteration',
         [BlockEnum.IterationStart]: 'iteration',
+        [BlockEnum.Loop]: 'loop',
+        [BlockEnum.LoopStart]: 'loop',
         [BlockEnum.ParameterExtractor]: 'parameter-extractor',
         [BlockEnum.HttpRequest]: 'http-request',
         [BlockEnum.Tool]: 'tools',
@@ -50,11 +52,14 @@ export const useNodeHelpLink = (nodeType: BlockEnum) => {
       [BlockEnum.Assigner]: 'variable-assigner',
       [BlockEnum.Iteration]: 'iteration',
       [BlockEnum.IterationStart]: 'iteration',
+      [BlockEnum.Loop]: 'loop',
+      [BlockEnum.LoopStart]: 'loop',
       [BlockEnum.ParameterExtractor]: 'parameter-extractor',
       [BlockEnum.HttpRequest]: 'http-request',
       [BlockEnum.Tool]: 'tools',
       [BlockEnum.DocExtractor]: 'doc-extractor',
       [BlockEnum.ListFilter]: 'list-operator',
+      [BlockEnum.Agent]: 'agent',
     }
   }, [language])
 

+ 2 - 0
web/app/components/workflow/nodes/_base/hooks/use-node-info.ts

@@ -8,11 +8,13 @@ const useNodeInfo = (nodeId: string) => {
   const allNodes = getNodes()
   const node = allNodes.find(n => n.id === nodeId)
   const isInIteration = !!node?.data.isInIteration
+  const isInLoop = !!node?.data.isInLoop
   const parentNodeId = node?.parentId
   const parentNode = allNodes.find(n => n.id === parentNodeId)
   return {
     node,
     isInIteration,
+    isInLoop,
     parentNode,
   }
 }

+ 117 - 8
web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts

@@ -13,7 +13,7 @@ import type { CommonNodeType, InputVar, ValueSelector, Var, Variable } from '@/a
 import { BlockEnum, InputVarType, NodeRunningStatus, VarType } from '@/app/components/workflow/types'
 import { useStore as useAppStore } from '@/app/components/app/store'
 import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
-import { getIterationSingleNodeRunUrl, singleNodeRun } from '@/service/workflow'
+import { getIterationSingleNodeRunUrl, getLoopSingleNodeRunUrl, singleNodeRun } from '@/service/workflow'
 import Toast from '@/app/components/base/toast'
 import LLMDefault from '@/app/components/workflow/nodes/llm/default'
 import KnowledgeRetrievalDefault from '@/app/components/workflow/nodes/knowledge-retrieval/default'
@@ -28,6 +28,7 @@ import Assigner from '@/app/components/workflow/nodes/assigner/default'
 import ParameterExtractorDefault from '@/app/components/workflow/nodes/parameter-extractor/default'
 import IterationDefault from '@/app/components/workflow/nodes/iteration/default'
 import DocumentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default'
+import LoopDefault from '@/app/components/workflow/nodes/loop/default'
 import { ssePost } from '@/service/base'
 
 import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants'
@@ -45,6 +46,7 @@ const { checkValid: checkAssignerValid } = Assigner
 const { checkValid: checkParameterExtractorValid } = ParameterExtractorDefault
 const { checkValid: checkIterationValid } = IterationDefault
 const { checkValid: checkDocumentExtractorValid } = DocumentExtractorDefault
+const { checkValid: checkLoopValid } = LoopDefault
 
 // eslint-disable-next-line ts/no-unsafe-function-type
 const checkValidFns: Record<BlockEnum, Function> = {
@@ -61,6 +63,7 @@ const checkValidFns: Record<BlockEnum, Function> = {
   [BlockEnum.ParameterExtractor]: checkParameterExtractorValid,
   [BlockEnum.Iteration]: checkIterationValid,
   [BlockEnum.DocExtractor]: checkDocumentExtractorValid,
+  [BlockEnum.Loop]: checkLoopValid,
 } as any
 
 type Params<T> = {
@@ -69,6 +72,7 @@ type Params<T> = {
   defaultRunInputData: Record<string, any>
   moreDataForCheckValid?: any
   iteratorInputKey?: string
+  loopInputKey?: string
 }
 
 const varTypeToInputVarType = (type: VarType, {
@@ -100,12 +104,14 @@ const useOneStepRun = <T>({
   defaultRunInputData,
   moreDataForCheckValid,
   iteratorInputKey,
+  loopInputKey,
 }: Params<T>) => {
   const { t } = useTranslation()
   const { getBeforeNodesInSameBranch, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() as any
   const conversationVariables = useStore(s => s.conversationVariables)
   const isChatMode = useIsChatMode()
   const isIteration = data.type === BlockEnum.Iteration
+  const isLoop = data.type === BlockEnum.Loop
 
   const availableNodes = getBeforeNodesInSameBranch(id)
   const availableNodesIncludeParent = getBeforeNodesInSameBranchIncludeParent(id)
@@ -145,12 +151,14 @@ const useOneStepRun = <T>({
     setRunInputData(data)
   }, [])
   const iterationTimes = iteratorInputKey ? runInputData[iteratorInputKey].length : 0
+  const loopTimes = loopInputKey ? runInputData[loopInputKey].length : 0
   const [runResult, setRunResult] = useState<any>(null)
 
   const { handleNodeDataUpdate }: { handleNodeDataUpdate: (data: any) => void } = useNodeDataUpdate()
   const [canShowSingleRun, setCanShowSingleRun] = useState(false)
   const isShowSingleRun = data._isSingleRun && canShowSingleRun
   const [iterationRunResult, setIterationRunResult] = useState<NodeTracing[]>([])
+  const [loopRunResult, setLoopRunResult] = useState<NodeTracing[]>([])
 
   useEffect(() => {
     if (!checkValid) {
@@ -175,7 +183,7 @@ const useOneStepRun = <T>({
         })
       }
     }
-  // eslint-disable-next-line react-hooks/exhaustive-deps
+    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [data._isSingleRun])
 
   const workflowStore = useWorkflowStore()
@@ -214,10 +222,10 @@ const useOneStepRun = <T>({
     })
     let res: any
     try {
-      if (!isIteration) {
+      if (!isIteration && !isLoop) {
         res = await singleNodeRun(appId!, id, { inputs: submitData }) as any
       }
-      else {
+      else if (isIteration) {
         setIterationRunResult([])
         let _iterationResult: NodeTracing[] = []
         let _runResult: any = null
@@ -315,11 +323,111 @@ const useOneStepRun = <T>({
           },
         )
       }
-      if (res.error)
+      else if (isLoop) {
+        setLoopRunResult([])
+        let _loopResult: NodeTracing[] = []
+        let _runResult: any = null
+        ssePost(
+          getLoopSingleNodeRunUrl(isChatMode, appId!, id),
+          { body: { inputs: submitData } },
+          {
+            onWorkflowStarted: () => {
+            },
+            onWorkflowFinished: (params) => {
+              handleNodeDataUpdate({
+                id,
+                data: {
+                  ...data,
+                  _singleRunningStatus: NodeRunningStatus.Succeeded,
+                },
+              })
+              const { data: loopData } = params
+              _runResult.created_by = loopData.created_by.name
+              setRunResult(_runResult)
+            },
+            onLoopStart: (params) => {
+              const newLoopRunResult = produce(_loopResult, (draft) => {
+                draft.push({
+                  ...params.data,
+                  status: NodeRunningStatus.Running,
+                })
+              })
+              _loopResult = newLoopRunResult
+              setLoopRunResult(newLoopRunResult)
+            },
+            onLoopNext: () => {
+              // loop next trigger time is triggered one more time than loopTimes
+              if (_loopResult.length >= loopTimes!)
+                return _loopResult.length >= loopTimes!
+            },
+            onLoopFinish: (params) => {
+              _runResult = params.data
+              setRunResult(_runResult)
+
+              const loopRunResult = _loopResult
+              const currentIndex = loopRunResult.findIndex(trace => trace.id === params.data.id)
+              const newLoopRunResult = produce(loopRunResult, (draft) => {
+                if (currentIndex > -1) {
+                  draft[currentIndex] = {
+                    ...draft[currentIndex],
+                    ...data,
+                  }
+                }
+              })
+              _loopResult = newLoopRunResult
+              setLoopRunResult(newLoopRunResult)
+            },
+            onNodeStarted: (params) => {
+              const newLoopRunResult = produce(_loopResult, (draft) => {
+                draft.push({
+                  ...params.data,
+                  status: NodeRunningStatus.Running,
+                })
+              })
+              _loopResult = newLoopRunResult
+              setLoopRunResult(newLoopRunResult)
+            },
+            onNodeFinished: (params) => {
+              const loopRunResult = _loopResult
+
+              const { data } = params
+              const currentIndex = loopRunResult.findIndex(trace => trace.id === data.id)
+              const newLoopRunResult = produce(loopRunResult, (draft) => {
+                if (currentIndex > -1) {
+                  draft[currentIndex] = {
+                    ...draft[currentIndex],
+                    ...data,
+                  }
+                }
+              })
+              _loopResult = newLoopRunResult
+              setLoopRunResult(newLoopRunResult)
+            },
+            onNodeRetry: (params) => {
+              const newLoopRunResult = produce(_loopResult, (draft) => {
+                draft.push(params.data)
+              })
+              _loopResult = newLoopRunResult
+              setLoopRunResult(newLoopRunResult)
+            },
+            onError: () => {
+              handleNodeDataUpdate({
+                id,
+                data: {
+                  ...data,
+                  _singleRunningStatus: NodeRunningStatus.Failed,
+                },
+              })
+            },
+          },
+        )
+      }
+      if (res && res.error)
         throw new Error(res.error)
     }
     catch (e: any) {
-      if (!isIteration) {
+      console.error(e)
+      if (!isIteration && !isLoop) {
         handleNodeDataUpdate({
           id,
           data: {
@@ -331,7 +439,7 @@ const useOneStepRun = <T>({
       }
     }
     finally {
-      if (!isIteration) {
+      if (!isIteration && !isLoop) {
         setRunResult({
           ...res,
           total_tokens: res.execution_metadata?.total_tokens || 0,
@@ -339,7 +447,7 @@ const useOneStepRun = <T>({
         })
       }
     }
-    if (!isIteration) {
+    if (!isIteration && !isLoop) {
       handleNodeDataUpdate({
         id,
         data: {
@@ -430,6 +538,7 @@ const useOneStepRun = <T>({
     setRunInputData: handleSetRunInputData,
     runResult,
     iterationRunResult,
+    loopRunResult,
   }
 }
 

+ 39 - 8
web/app/components/workflow/nodes/_base/node.tsx

@@ -30,6 +30,7 @@ import {
   hasRetryNode,
 } from '../../utils'
 import { useNodeIterationInteractions } from '../iteration/use-interactions'
+import { useNodeLoopInteractions } from '../loop/use-interactions'
 import type { IterationNodeType } from '../iteration/types'
 import {
   NodeSourceHandle,
@@ -57,6 +58,7 @@ const BaseNode: FC<BaseNodeProps> = ({
   const nodeRef = useRef<HTMLDivElement>(null)
   const { nodesReadOnly } = useNodesReadOnly()
   const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
+  const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions()
   const toolIcon = useToolIcon(data)
 
   useEffect(() => {
@@ -73,6 +75,20 @@ const BaseNode: FC<BaseNodeProps> = ({
     }
   }, [data.isInIteration, data.selected, id, handleNodeIterationChildSizeChange])
 
+  useEffect(() => {
+    if (nodeRef.current && data.selected && data.isInLoop) {
+      const resizeObserver = new ResizeObserver(() => {
+        handleNodeLoopChildSizeChange(id)
+      })
+
+      resizeObserver.observe(nodeRef.current)
+
+      return () => {
+        resizeObserver.disconnect()
+      }
+    }
+  }, [data.isInLoop, data.selected, id, handleNodeLoopChildSizeChange])
+
   const showSelectedBorder = data.selected || data._isBundled || data._isEntering
   const {
     showRunningBorder,
@@ -98,16 +114,16 @@ const BaseNode: FC<BaseNodeProps> = ({
       )}
       ref={nodeRef}
       style={{
-        width: data.type === BlockEnum.Iteration ? data.width : 'auto',
-        height: data.type === BlockEnum.Iteration ? data.height : 'auto',
+        width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : 'auto',
+        height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
       }}
     >
       <div
         className={cn(
           'group relative pb-1 shadow-xs',
           'border border-transparent rounded-[15px]',
-          data.type !== BlockEnum.Iteration && 'w-[240px] bg-workflow-block-bg',
-          data.type === BlockEnum.Iteration && 'flex flex-col w-full h-full bg-workflow-block-bg-transparent border-workflow-block-border',
+          (data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && 'w-[240px] bg-workflow-block-bg',
+          (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'flex flex-col w-full h-full bg-workflow-block-bg-transparent border-workflow-block-border',
           !data._runningStatus && 'hover:shadow-lg',
           showRunningBorder && '!border-state-accent-solid',
           showSuccessBorder && '!border-state-success-solid',
@@ -139,6 +155,14 @@ const BaseNode: FC<BaseNodeProps> = ({
             />
           )
         }
+        {
+          data.type === BlockEnum.Loop && (
+            <NodeResizer
+              nodeId={id}
+              nodeData={data}
+            />
+          )
+        }
         {
           !data._isCandidate && (
             <NodeTargetHandle
@@ -169,7 +193,7 @@ const BaseNode: FC<BaseNodeProps> = ({
         }
         <div className={cn(
           'flex items-center px-3 pt-3 pb-2 rounded-t-2xl',
-          data.type === BlockEnum.Iteration && 'bg-transparent',
+          (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'bg-transparent',
         )}>
           <BlockIcon
             className='shrink-0 mr-2'
@@ -208,6 +232,13 @@ const BaseNode: FC<BaseNodeProps> = ({
               </div>
             )
           }
+          {
+            data._loopLength && data._loopIndex && data._runningStatus === NodeRunningStatus.Running && (
+              <div className='mr-1.5 text-xs font-medium text-primary-600'>
+                {data._loopIndex > data._loopLength ? data._loopLength : data._loopIndex}/{data._loopLength}
+              </div>
+            )
+          }
           {
             (data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running) && (
               <RiLoader2Line className='w-3.5 h-3.5 text-text-accent animate-spin' />
@@ -230,12 +261,12 @@ const BaseNode: FC<BaseNodeProps> = ({
           }
         </div>
         {
-          data.type !== BlockEnum.Iteration && (
+          data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
             cloneElement(children, { id, data })
           )
         }
         {
-          data.type === BlockEnum.Iteration && (
+          (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && (
             <div className='grow pl-1 pr-1 pb-1'>
               {cloneElement(children, { id, data })}
             </div>
@@ -258,7 +289,7 @@ const BaseNode: FC<BaseNodeProps> = ({
           )
         }
         {
-          data.desc && data.type !== BlockEnum.Iteration && (
+          data.desc && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
             <div className='px-3 pt-1 pb-2 system-xs-regular text-text-tertiary whitespace-pre-line break-words'>
               {data.desc}
             </div>

+ 2 - 2
web/app/components/workflow/nodes/_base/panel.tsx

@@ -61,14 +61,14 @@ const BasePanel: FC<BasePanelProps> = ({
     showMessageLogModal: state.showMessageLogModal,
   })))
   const showSingleRunPanel = useStore(s => s.showSingleRunPanel)
-  const panelWidth = localStorage.getItem('workflow-node-panel-width') ? parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 420
+  const panelWidth = localStorage.getItem('workflow-node-panel-width') ? Number.parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 420
   const {
     setPanelWidth,
   } = useWorkflow()
   const { handleNodeSelect } = useNodesInteractions()
   const { handleSyncWorkflowDraft } = useNodesSyncDraft()
   const { nodesReadOnly } = useNodesReadOnly()
-  const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration)
+  const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop)
   const toolIcon = useToolIcon(data)
 
   const handleResize = useCallback((width: number) => {

+ 4 - 2
web/app/components/workflow/nodes/assigner/use-config.ts

@@ -39,6 +39,8 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => {
   const currentNode = getNodes().find(n => n.id === id)
   const isInIteration = payload.isInIteration
   const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null
+  const isInLoop = payload.isInLoop
+  const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null
   const availableNodes = useMemo(() => {
     return getBeforeNodesInSameBranch(id)
   }, [getBeforeNodesInSameBranch, id])
@@ -54,13 +56,13 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => {
   const { getCurrentVariableType } = useWorkflowVariables()
   const getAssignedVarType = useCallback((valueSelector: ValueSelector) => {
     return getCurrentVariableType({
-      parentNode: iterationNode,
+      parentNode: isInIteration ? iterationNode : loopNode,
       valueSelector: valueSelector || [],
       availableNodes,
       isChatMode,
       isConstant: false,
     })
-  }, [getCurrentVariableType, iterationNode, availableNodes, isChatMode])
+  }, [getCurrentVariableType, isInIteration, iterationNode, loopNode, availableNodes, isChatMode])
 
   const handleOperationListChanges = useCallback((items: AssignerNodeOperation[]) => {
     const newInputs = produce(inputs, (draft) => {

+ 4 - 0
web/app/components/workflow/nodes/constants.ts

@@ -30,6 +30,8 @@ import ParameterExtractorNode from './parameter-extractor/node'
 import ParameterExtractorPanel from './parameter-extractor/panel'
 import IterationNode from './iteration/node'
 import IterationPanel from './iteration/panel'
+import LoopNode from './loop/node'
+import LoopPanel from './loop/panel'
 import DocExtractorNode from './document-extractor/node'
 import DocExtractorPanel from './document-extractor/panel'
 import ListFilterNode from './list-operator/node'
@@ -55,6 +57,7 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = {
   [BlockEnum.VariableAggregator]: VariableAssignerNode,
   [BlockEnum.ParameterExtractor]: ParameterExtractorNode,
   [BlockEnum.Iteration]: IterationNode,
+  [BlockEnum.Loop]: LoopNode,
   [BlockEnum.DocExtractor]: DocExtractorNode,
   [BlockEnum.ListFilter]: ListFilterNode,
   [BlockEnum.Agent]: AgentNode,
@@ -77,6 +80,7 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
   [BlockEnum.Assigner]: AssignerPanel,
   [BlockEnum.ParameterExtractor]: ParameterExtractorPanel,
   [BlockEnum.Iteration]: IterationPanel,
+  [BlockEnum.Loop]: LoopPanel,
   [BlockEnum.DocExtractor]: DocExtractorPanel,
   [BlockEnum.ListFilter]: ListFilterPanel,
   [BlockEnum.Agent]: AgentPanel,

+ 4 - 2
web/app/components/workflow/nodes/document-extractor/use-config.ts

@@ -32,6 +32,8 @@ const useConfig = (id: string, payload: DocExtractorNodeType) => {
   const currentNode = getNodes().find(n => n.id === id)
   const isInIteration = payload.isInIteration
   const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null
+  const isInLoop = payload.isInLoop
+  const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null
   const availableNodes = useMemo(() => {
     return getBeforeNodesInSameBranch(id)
   }, [getBeforeNodesInSameBranch, id])
@@ -39,14 +41,14 @@ const useConfig = (id: string, payload: DocExtractorNodeType) => {
   const { getCurrentVariableType } = useWorkflowVariables()
   const getType = useCallback((variable?: ValueSelector) => {
     const varType = getCurrentVariableType({
-      parentNode: iterationNode,
+      parentNode: isInIteration ? iterationNode : loopNode,
       valueSelector: variable || [],
       availableNodes,
       isChatMode,
       isConstant: false,
     })
     return varType
-  }, [getCurrentVariableType, availableNodes, isChatMode, iterationNode])
+  }, [getCurrentVariableType, isInIteration, availableNodes, isChatMode, iterationNode, loopNode])
 
   const handleVarChanges = useCallback((variable: ValueSelector | string) => {
     const newInputs = produce(inputs, (draft) => {

+ 3 - 2
web/app/components/workflow/nodes/if-else/types.ts

@@ -35,7 +35,7 @@ export enum ComparisonOperator {
   notExists = 'not exists',
 }
 
-export interface Condition {
+export type Condition = {
   id: string
   varType: VarType
   variable_selector?: ValueSelector
@@ -46,7 +46,7 @@ export interface Condition {
   sub_variable_condition?: CaseItem
 }
 
-export interface CaseItem {
+export type CaseItem = {
   case_id: string
   logical_operator: LogicalOperator
   conditions: Condition[]
@@ -57,6 +57,7 @@ export type IfElseNodeType = CommonNodeType & {
   conditions?: Condition[]
   cases: CaseItem[]
   isInIteration: boolean
+  isInLoop: boolean
 }
 
 export type HandleAddCondition = (caseId: string, valueSelector: ValueSelector, varItem: Var) => void

+ 1 - 0
web/app/components/workflow/nodes/if-else/use-config.ts

@@ -57,6 +57,7 @@ const useConfig = (id: string, payload: IfElseNodeType) => {
   } = useIsVarFileAttribute({
     nodeId: id,
     isInIteration: payload.isInIteration,
+    isInLoop: payload.isInLoop,
   })
 
   const varsIsVarFileAttribute = useMemo(() => {

+ 4 - 1
web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts

@@ -7,10 +7,12 @@ import { VarType } from '../../types'
 type Params = {
   nodeId: string
   isInIteration: boolean
+  isInLoop: boolean
 }
 const useIsVarFileAttribute = ({
   nodeId,
   isInIteration,
+  isInLoop,
 }: Params) => {
   const isChatMode = useIsChatMode()
   const store = useStoreApi()
@@ -20,6 +22,7 @@ const useIsVarFileAttribute = ({
   } = store.getState()
   const currentNode = getNodes().find(n => n.id === nodeId)
   const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null
+  const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null
   const availableNodes = useMemo(() => {
     return getBeforeNodesInSameBranch(nodeId)
   }, [getBeforeNodesInSameBranch, nodeId])
@@ -29,7 +32,7 @@ const useIsVarFileAttribute = ({
       return false
     const parentVariable = variable.slice(0, 2)
     const varType = getCurrentVariableType({
-      parentNode: iterationNode,
+      parentNode: isInIteration ? iterationNode : loopNode,
       valueSelector: parentVariable,
       availableNodes,
       isChatMode,

+ 4 - 2
web/app/components/workflow/nodes/list-operator/use-config.ts

@@ -27,6 +27,8 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
   const currentNode = getNodes().find(n => n.id === id)
   const isInIteration = payload.isInIteration
   const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null
+  const isInLoop = payload.isInLoop
+  const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null
   const availableNodes = useMemo(() => {
     return getBeforeNodesInSameBranch(id)
   }, [getBeforeNodesInSameBranch, id])
@@ -36,7 +38,7 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
   const { getCurrentVariableType } = useWorkflowVariables()
   const getType = useCallback((variable?: ValueSelector) => {
     const varType = getCurrentVariableType({
-      parentNode: iterationNode,
+      parentNode: isInIteration ? iterationNode : loopNode,
       valueSelector: variable || inputs.variable || [],
       availableNodes,
       isChatMode,
@@ -60,7 +62,7 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
         itemVarType = varType
     }
     return { varType, itemVarType }
-  }, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, iterationNode])
+  }, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, isInIteration, iterationNode, loopNode])
 
   const { varType, itemVarType } = getType()
 

+ 1 - 0
web/app/components/workflow/nodes/loop-start/constants.ts

@@ -0,0 +1 @@
+export const CUSTOM_LOOP_START_NODE = 'custom-loop-start'

+ 21 - 0
web/app/components/workflow/nodes/loop-start/default.ts

@@ -0,0 +1,21 @@
+import type { NodeDefault } from '../../types'
+import type { LoopStartNodeType } from './types'
+import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
+
+const nodeDefault: NodeDefault<LoopStartNodeType> = {
+  defaultValue: {},
+  getAvailablePrevNodes() {
+    return []
+  },
+  getAvailableNextNodes(isChatMode: boolean) {
+    const nodes = isChatMode ? ALL_CHAT_AVAILABLE_BLOCKS : ALL_COMPLETION_AVAILABLE_BLOCKS
+    return nodes
+  },
+  checkValid() {
+    return {
+      isValid: true,
+    }
+  },
+}
+
+export default nodeDefault

+ 42 - 0
web/app/components/workflow/nodes/loop-start/index.tsx

@@ -0,0 +1,42 @@
+import { memo } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { NodeProps } from 'reactflow'
+import { RiHome5Fill } from '@remixicon/react'
+import Tooltip from '@/app/components/base/tooltip'
+import { NodeSourceHandle } from '@/app/components/workflow/nodes/_base/components/node-handle'
+
+const LoopStartNode = ({ id, data }: NodeProps) => {
+  const { t } = useTranslation()
+
+  return (
+    <div className='group flex nodrag items-center justify-center w-11 h-11 mt-1 rounded-2xl border border-workflow-block-border bg-white'>
+      <Tooltip popupContent={t('workflow.blocks.loop-start')} asChild={false}>
+        <div className='flex items-center justify-center w-6 h-6 rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500'>
+          <RiHome5Fill className='w-3 h-3 text-text-primary-on-surface' />
+        </div>
+      </Tooltip>
+      <NodeSourceHandle
+        id={id}
+        data={data}
+        handleClassName='!top-1/2 !-right-[9px] !-translate-y-1/2'
+        handleId='source'
+      />
+    </div>
+  )
+}
+
+export const LoopStartNodeDumb = () => {
+  const { t } = useTranslation()
+
+  return (
+    <div className='relative left-[17px] top-[21px] flex nodrag items-center justify-center w-11 h-11 rounded-2xl border border-workflow-block-border bg-white z-[11]'>
+      <Tooltip popupContent={t('workflow.blocks.loop-start')} asChild={false}>
+        <div className='flex items-center justify-center w-6 h-6 rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500'>
+          <RiHome5Fill className='w-3 h-3 text-text-primary-on-surface' />
+        </div>
+      </Tooltip>
+    </div>
+  )
+}
+
+export default memo(LoopStartNode)

+ 3 - 0
web/app/components/workflow/nodes/loop-start/types.ts

@@ -0,0 +1,3 @@
+import type { CommonNodeType } from '@/app/components/workflow/types'
+
+export type LoopStartNodeType = CommonNodeType

+ 80 - 0
web/app/components/workflow/nodes/loop/add-block.tsx

@@ -0,0 +1,80 @@
+import {
+  memo,
+  useCallback,
+} from 'react'
+import {
+  RiAddLine,
+} from '@remixicon/react'
+import { useTranslation } from 'react-i18next'
+import {
+  useAvailableBlocks,
+  useNodesInteractions,
+  useNodesReadOnly,
+} from '../../hooks'
+import type { LoopNodeType } from './types'
+import cn from '@/utils/classnames'
+import BlockSelector from '@/app/components/workflow/block-selector'
+
+import type {
+  OnSelectBlock,
+} from '@/app/components/workflow/types'
+import {
+  BlockEnum,
+} from '@/app/components/workflow/types'
+
+type AddBlockProps = {
+  loopNodeId: string
+  loopNodeData: LoopNodeType
+}
+const AddBlock = ({
+  loopNodeData,
+}: AddBlockProps) => {
+  const { t } = useTranslation()
+  const { nodesReadOnly } = useNodesReadOnly()
+  const { handleNodeAdd } = useNodesInteractions()
+  const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false, true)
+
+  const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
+    handleNodeAdd(
+      {
+        nodeType: type,
+        toolDefaultValue,
+      },
+      {
+        prevNodeId: loopNodeData.start_node_id,
+        prevNodeSourceHandle: 'source',
+      },
+    )
+  }, [handleNodeAdd, loopNodeData.start_node_id])
+
+  const renderTriggerElement = useCallback((open: boolean) => {
+    return (
+      <div className={cn(
+        'relative inline-flex items-center px-3 h-8 rounded-lg border-[0.5px] border-gray-50 bg-white shadow-xs cursor-pointer hover:bg-gray-200 text-[13px] font-medium text-gray-700',
+        `${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
+        open && '!bg-gray-50',
+      )}>
+        <RiAddLine className='mr-1 w-4 h-4' />
+        {t('workflow.common.addBlock')}
+      </div>
+    )
+  }, [nodesReadOnly, t])
+
+  return (
+    <div className='absolute top-7 left-14 flex items-center h-8 z-10'>
+      <div className='group/insert relative w-16 h-0.5 bg-gray-300'>
+        <div className='absolute right-0 top-1/2 -translate-y-1/2 w-0.5 h-2 bg-primary-500'></div>
+      </div>
+      <BlockSelector
+        disabled={nodesReadOnly}
+        onSelect={handleSelect}
+        trigger={renderTriggerElement}
+        triggerInnerClassName='inline-flex'
+        popupClassName='!min-w-[256px]'
+        availableBlocksTypes={availableNextBlocks}
+      />
+    </div>
+  )
+}
+
+export default memo(AddBlock)

+ 74 - 0
web/app/components/workflow/nodes/loop/components/condition-add.tsx

@@ -0,0 +1,74 @@
+import {
+  useCallback,
+  useState,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import { RiAddLine } from '@remixicon/react'
+import type { HandleAddCondition } from '../types'
+import Button from '@/app/components/base/button'
+import {
+  PortalToFollowElem,
+  PortalToFollowElemContent,
+  PortalToFollowElemTrigger,
+} from '@/app/components/base/portal-to-follow-elem'
+import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
+import type {
+  NodeOutPutVar,
+  ValueSelector,
+  Var,
+} from '@/app/components/workflow/types'
+
+type ConditionAddProps = {
+  className?: string
+  variables: NodeOutPutVar[]
+  onSelectVariable: HandleAddCondition
+  disabled?: boolean
+}
+const ConditionAdd = ({
+  className,
+  variables,
+  onSelectVariable,
+  disabled,
+}: ConditionAddProps) => {
+  const { t } = useTranslation()
+  const [open, setOpen] = useState(false)
+
+  const handleSelectVariable = useCallback((valueSelector: ValueSelector, varItem: Var) => {
+    onSelectVariable(valueSelector, varItem)
+    setOpen(false)
+  }, [onSelectVariable, setOpen])
+
+  return (
+    <PortalToFollowElem
+      open={open}
+      onOpenChange={setOpen}
+      placement='bottom-start'
+      offset={{
+        mainAxis: 4,
+        crossAxis: 0,
+      }}
+    >
+      <PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
+        <Button
+          size='small'
+          className={className}
+          disabled={disabled}
+        >
+          <RiAddLine className='mr-1 w-3.5 h-3.5' />
+          {t('workflow.nodes.ifElse.addCondition')}
+        </Button>
+      </PortalToFollowElemTrigger>
+      <PortalToFollowElemContent className='z-[1000]'>
+        <div className='w-[296px] bg-components-panel-bg-blur rounded-lg border-[0.5px] border-components-panel-border shadow-lg'>
+          <VarReferenceVars
+            vars={variables}
+            isSupportFileVar
+            onChange={handleSelectVariable}
+          />
+        </div>
+      </PortalToFollowElemContent>
+    </PortalToFollowElem>
+  )
+}
+
+export default ConditionAdd

+ 115 - 0
web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx

@@ -0,0 +1,115 @@
+import {
+  memo,
+  useCallback,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import { ComparisonOperator, type Condition } from '../types'
+import {
+  comparisonOperatorNotRequireValue,
+  isComparisonOperatorNeedTranslate,
+  isEmptyRelatedOperator,
+} from '../utils'
+import type { ValueSelector } from '../../../types'
+import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from './../default'
+import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
+import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
+import cn from '@/utils/classnames'
+import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
+const i18nPrefix = 'workflow.nodes.ifElse'
+
+type ConditionValueProps = {
+  condition: Condition
+}
+const ConditionValue = ({
+  condition,
+}: ConditionValueProps) => {
+  const { t } = useTranslation()
+  const {
+    variable_selector,
+    comparison_operator: operator,
+    sub_variable_condition,
+  } = condition
+
+  const variableSelector = variable_selector as ValueSelector
+
+  const variableName = (isSystemVar(variableSelector) ? variableSelector.slice(0).join('.') : variableSelector.slice(1).join('.'))
+  const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}`) : operator
+  const notHasValue = comparisonOperatorNotRequireValue(operator)
+  const isEnvVar = isENV(variableSelector)
+  const isChatVar = isConversationVar(variableSelector)
+  const formatValue = useCallback((c: Condition) => {
+    const notHasValue = comparisonOperatorNotRequireValue(c.comparison_operator)
+    if (notHasValue)
+      return ''
+
+    const value = c.value as string
+    return value.replace(/{{#([^#]*)#}}/g, (a, b) => {
+      const arr: string[] = b.split('.')
+      if (isSystemVar(arr))
+        return `{{${b}}}`
+
+      return `{{${arr.slice(1).join('.')}}}`
+    })
+  }, [])
+
+  const isSelect = useCallback((c: Condition) => {
+    return c.comparison_operator === ComparisonOperator.in || c.comparison_operator === ComparisonOperator.notIn
+  }, [])
+
+  const selectName = useCallback((c: Condition) => {
+    const isSelect = c.comparison_operator === ComparisonOperator.in || c.comparison_operator === ComparisonOperator.notIn
+    if (isSelect) {
+      const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(c.value) ? c.value[0] : c.value))[0]
+      return name
+        ? t(`workflow.nodes.ifElse.optionName.${name.i18nKey}`).replace(/{{#([^#]*)#}}/g, (a, b) => {
+          const arr: string[] = b.split('.')
+          if (isSystemVar(arr))
+            return `{{${b}}}`
+
+          return `{{${arr.slice(1).join('.')}}}`
+        })
+        : ''
+    }
+    return ''
+  }, [t])
+
+  return (
+    <div className='rounded-md bg-workflow-block-parma-bg'>
+      <div className='flex items-center px-1 h-6 '>
+        {!isEnvVar && !isChatVar && <Variable02 className='shrink-0 mr-1 w-3.5 h-3.5 text-text-accent' />}
+        {isEnvVar && <Env className='shrink-0 mr-1 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
+        {isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
+
+        <div
+          className={cn(
+            'shrink-0  truncate text-xs font-medium text-text-accent',
+            !notHasValue && 'max-w-[70px]',
+          )}
+          title={variableName}
+        >
+          {variableName}
+        </div>
+        <div
+          className='shrink-0 mx-1 text-xs font-medium text-text-primary'
+          title={operatorName}
+        >
+          {operatorName}
+        </div>
+      </div>
+      <div className='ml-[10px] pl-[10px] border-l border-divider-regular'>
+        {
+          sub_variable_condition?.conditions.map((c: Condition, index) => (
+            <div className='relative flex items-center h-6 space-x-1' key={c.id}>
+              <div className='text-text-accent system-xs-medium'>{c.key}</div>
+              <div className='text-text-primary system-xs-medium'>{isComparisonOperatorNeedTranslate(c.comparison_operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${c.comparison_operator}`) : c.comparison_operator}</div>
+              {c.comparison_operator && !isEmptyRelatedOperator(c.comparison_operator) && <div className='text-text-secondary system-xs-regular'>{isSelect(c) ? selectName(c) : formatValue(c)}</div>}
+              {index !== sub_variable_condition.conditions.length - 1 && (<div className='absolute z-10 right-1 bottom-[-10px] leading-4 text-[10px] font-medium text-text-accent uppercase'>{t(`${i18nPrefix}.${sub_variable_condition.logical_operator}`)}</div>)}
+            </div>
+          ))
+        }
+      </div>
+    </div>
+  )
+}
+
+export default memo(ConditionValue)

+ 53 - 0
web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx

@@ -0,0 +1,53 @@
+import { useTranslation } from 'react-i18next'
+import { useStore } from '@/app/components/workflow/store'
+import PromptEditor from '@/app/components/base/prompt-editor'
+import { BlockEnum } from '@/app/components/workflow/types'
+import type {
+  Node,
+} from '@/app/components/workflow/types'
+
+type ConditionInputProps = {
+  disabled?: boolean
+  value: string
+  onChange: (value: string) => void
+  availableNodes: Node[]
+}
+const ConditionInput = ({
+  value,
+  onChange,
+  disabled,
+  availableNodes,
+}: ConditionInputProps) => {
+  const { t } = useTranslation()
+  const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey)
+
+  return (
+    <PromptEditor
+      key={controlPromptEditorRerenderKey}
+      compact
+      value={value}
+      placeholder={t('workflow.nodes.ifElse.enterValue') || ''}
+      workflowVariableBlock={{
+        show: true,
+        variables: [],
+        workflowNodesMap: availableNodes.reduce((acc, node) => {
+          acc[node.id] = {
+            title: node.data.title,
+            type: node.data.type,
+          }
+          if (node.data.type === BlockEnum.Start) {
+            acc.sys = {
+              title: t('workflow.blocks.start'),
+              type: BlockEnum.Start,
+            }
+          }
+          return acc
+        }, {} as any),
+      }}
+      onChange={onChange}
+      editable={!disabled}
+    />
+  )
+}
+
+export default ConditionInput

+ 330 - 0
web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx

@@ -0,0 +1,330 @@
+import {
+  useCallback,
+  useMemo,
+  useState,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import { RiDeleteBinLine } from '@remixicon/react'
+import produce from 'immer'
+import type { VarType as NumberVarType } from '../../../tool/types'
+import type {
+  Condition,
+  HandleAddSubVariableCondition,
+  HandleRemoveCondition,
+  HandleToggleSubVariableConditionLogicalOperator,
+  HandleUpdateCondition,
+  HandleUpdateSubVariableCondition,
+  handleRemoveSubVariableCondition,
+} from '../../types'
+import {
+  ComparisonOperator,
+} from '../../types'
+import ConditionNumberInput from '../condition-number-input'
+import ConditionWrap from '../condition-wrap'
+import { comparisonOperatorNotRequireValue, getOperators } from './../../utils'
+import ConditionOperator from './condition-operator'
+import ConditionInput from './condition-input'
+import { FILE_TYPE_OPTIONS, SUB_VARIABLES, TRANSFER_METHOD } from './../../default'
+import type {
+  Node,
+  NodeOutPutVar,
+  ValueSelector,
+  Var,
+} from '@/app/components/workflow/types'
+import { VarType } from '@/app/components/workflow/types'
+import cn from '@/utils/classnames'
+import { SimpleSelect as Select } from '@/app/components/base/select'
+import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
+import ConditionVarSelector from './condition-var-selector'
+
+const optionNameI18NPrefix = 'workflow.nodes.ifElse.optionName'
+
+type ConditionItemProps = {
+  className?: string
+  disabled?: boolean
+  conditionId: string // in isSubVariableKey it's the value of the parent condition's id
+  condition: Condition // condition may the condition of case or condition of sub variable
+  file?: { key: string }
+  isSubVariableKey?: boolean
+  isValueFieldShort?: boolean
+  onRemoveCondition?: HandleRemoveCondition
+  onUpdateCondition?: HandleUpdateCondition
+  onAddSubVariableCondition?: HandleAddSubVariableCondition
+  onRemoveSubVariableCondition?: handleRemoveSubVariableCondition
+  onUpdateSubVariableCondition?: HandleUpdateSubVariableCondition
+  onToggleSubVariableConditionLogicalOperator?: HandleToggleSubVariableConditionLogicalOperator
+  nodeId: string
+  availableNodes: Node[]
+  numberVariables: NodeOutPutVar[]
+  availableVars: NodeOutPutVar[]
+}
+const ConditionItem = ({
+  className,
+  disabled,
+  conditionId,
+  condition,
+  file,
+  isSubVariableKey,
+  isValueFieldShort,
+  onRemoveCondition,
+  onUpdateCondition,
+  onAddSubVariableCondition,
+  onRemoveSubVariableCondition,
+  onUpdateSubVariableCondition,
+  onToggleSubVariableConditionLogicalOperator,
+  nodeId,
+  availableNodes,
+  numberVariables,
+  availableVars,
+}: ConditionItemProps) => {
+  const { t } = useTranslation()
+
+  const [isHovered, setIsHovered] = useState(false)
+  const [open, setOpen] = useState(false)
+
+  const doUpdateCondition = useCallback((newCondition: Condition) => {
+    if (isSubVariableKey)
+      onUpdateSubVariableCondition?.(conditionId, condition.id, newCondition)
+    else
+      onUpdateCondition?.(condition.id, newCondition)
+  }, [condition, conditionId, isSubVariableKey, onUpdateCondition, onUpdateSubVariableCondition])
+
+  const canChooseOperator = useMemo(() => {
+    if (disabled)
+      return false
+
+    if (isSubVariableKey)
+      return !!condition.key
+
+    return true
+  }, [condition.key, disabled, isSubVariableKey])
+  const handleUpdateConditionOperator = useCallback((value: ComparisonOperator) => {
+    const newCondition = {
+      ...condition,
+      comparison_operator: value,
+    }
+    doUpdateCondition(newCondition)
+  }, [condition, doUpdateCondition])
+
+  const handleUpdateConditionNumberVarType = useCallback((numberVarType: NumberVarType) => {
+    const newCondition = {
+      ...condition,
+      numberVarType,
+      value: '',
+    }
+    doUpdateCondition(newCondition)
+  }, [condition, doUpdateCondition])
+
+  const isSubVariable = condition.varType === VarType.arrayFile && [ComparisonOperator.contains, ComparisonOperator.notContains, ComparisonOperator.allOf].includes(condition.comparison_operator!)
+  const fileAttr = useMemo(() => {
+    if (file)
+      return file
+    if (isSubVariableKey) {
+      return {
+        key: condition.key!,
+      }
+    }
+    return undefined
+  }, [condition.key, file, isSubVariableKey])
+
+  const isArrayValue = fileAttr?.key === 'transfer_method' || fileAttr?.key === 'type'
+
+  const handleUpdateConditionValue = useCallback((value: string) => {
+    if (value === condition.value || (isArrayValue && value === condition.value?.[0]))
+      return
+    const newCondition = {
+      ...condition,
+      value: isArrayValue ? [value] : value,
+    }
+    doUpdateCondition(newCondition)
+  }, [condition, doUpdateCondition, isArrayValue])
+
+  const isSelect = condition.comparison_operator && [ComparisonOperator.in, ComparisonOperator.notIn].includes(condition.comparison_operator)
+  const selectOptions = useMemo(() => {
+    if (isSelect) {
+      if (fileAttr?.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
+        return FILE_TYPE_OPTIONS.map(item => ({
+          name: t(`${optionNameI18NPrefix}.${item.i18nKey}`),
+          value: item.value,
+        }))
+      }
+      if (fileAttr?.key === 'transfer_method') {
+        return TRANSFER_METHOD.map(item => ({
+          name: t(`${optionNameI18NPrefix}.${item.i18nKey}`),
+          value: item.value,
+        }))
+      }
+      return []
+    }
+    return []
+  }, [condition.comparison_operator, fileAttr?.key, isSelect, t])
+
+  const isNotInput = isSelect || isSubVariable
+
+  const isSubVarSelect = isSubVariableKey
+  const subVarOptions = SUB_VARIABLES.map(item => ({
+    name: item,
+    value: item,
+  }))
+
+  const handleSubVarKeyChange = useCallback((key: string) => {
+    const newCondition = produce(condition, (draft) => {
+      draft.key = key
+      if (key === 'size')
+        draft.varType = VarType.number
+      else
+        draft.varType = VarType.string
+
+      draft.value = ''
+      draft.comparison_operator = getOperators(undefined, { key })[0]
+    })
+
+    onUpdateSubVariableCondition?.(conditionId, condition.id, newCondition)
+  }, [condition, conditionId, onUpdateSubVariableCondition])
+
+  const doRemoveCondition = useCallback(() => {
+    if (isSubVariableKey)
+      onRemoveSubVariableCondition?.(conditionId, condition.id)
+    else
+      onRemoveCondition?.(condition.id)
+  }, [condition, conditionId, isSubVariableKey, onRemoveCondition, onRemoveSubVariableCondition])
+
+  const handleVarChange = useCallback((valueSelector: ValueSelector, varItem: Var) => {
+    const newCondition = produce(condition, (draft) => {
+      draft.variable_selector = valueSelector
+      draft.varType = varItem.type
+      draft.value = ''
+      draft.comparison_operator = getOperators(varItem.type)[0]
+    })
+    doUpdateCondition(newCondition)
+    setOpen(false)
+  }, [condition, doUpdateCondition])
+
+  return (
+    <div className={cn('flex mb-1 last-of-type:mb-0', className)}>
+      <div className={cn(
+        'grow bg-components-input-bg-normal rounded-lg',
+        isHovered && 'bg-state-destructive-hover',
+      )}>
+        <div className='flex items-center p-1'>
+          <div className='grow w-0'>
+            {isSubVarSelect
+              ? (
+                <Select
+                  wrapperClassName='h-6'
+                  className='pl-0 text-xs'
+                  optionWrapClassName='w-[165px] max-h-none'
+                  defaultValue={condition.key}
+                  items={subVarOptions}
+                  onSelect={item => handleSubVarKeyChange(item.value as string)}
+                  renderTrigger={item => (
+                    item
+                      ? <div className='flex justify-start cursor-pointer'>
+                        <div className='inline-flex max-w-full px-1.5 items-center h-6 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark shadow-xs text-text-accent'>
+                          <Variable02 className='shrink-0 w-3.5 h-3.5 text-text-accent' />
+                          <div className='ml-0.5 truncate system-xs-medium'>{item?.name}</div>
+                        </div>
+                      </div>
+                      : <div className='text-left text-components-input-text-placeholder system-sm-regular'>{t('common.placeholder.select')}</div>
+                  )}
+                  hideChecked
+                />
+              )
+              : (
+                <ConditionVarSelector
+                  open={open}
+                  onOpenChange={setOpen}
+                  valueSelector={condition.variable_selector || []}
+                  varType={condition.varType}
+                  availableNodes={availableNodes}
+                  nodesOutputVars={availableVars}
+                  onChange={handleVarChange}
+                />
+              )}
+
+          </div>
+          <div className='mx-1 w-[1px] h-3 bg-divider-regular'></div>
+          <ConditionOperator
+            disabled={!canChooseOperator}
+            varType={condition.varType}
+            value={condition.comparison_operator}
+            onSelect={handleUpdateConditionOperator}
+            file={fileAttr}
+          />
+        </div>
+        {
+          !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType !== VarType.number && (
+            <div className='px-2 py-1 max-h-[100px] border-t border-t-divider-subtle overflow-y-auto'>
+              <ConditionInput
+                disabled={disabled}
+                value={condition.value as string}
+                onChange={handleUpdateConditionValue}
+                availableNodes={availableNodes}
+              />
+            </div>
+          )
+        }
+        {
+          !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType === VarType.number && (
+            <div className='px-2 py-1 pt-[3px] border-t border-t-divider-subtle'>
+              <ConditionNumberInput
+                numberVarType={condition.numberVarType}
+                onNumberVarTypeChange={handleUpdateConditionNumberVarType}
+                value={condition.value as string}
+                onValueChange={handleUpdateConditionValue}
+                variables={numberVariables}
+                isShort={isValueFieldShort}
+                unit={fileAttr?.key === 'size' ? 'Byte' : undefined}
+              />
+            </div>
+          )
+        }
+        {
+          !comparisonOperatorNotRequireValue(condition.comparison_operator) && isSelect && (
+            <div className='border-t border-t-divider-subtle'>
+              <Select
+                wrapperClassName='h-8'
+                className='px-2 text-xs rounded-t-none'
+                defaultValue={isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string)}
+                items={selectOptions}
+                onSelect={item => handleUpdateConditionValue(item.value as string)}
+                hideChecked
+                notClearable
+              />
+            </div>
+          )
+        }
+        {
+          !comparisonOperatorNotRequireValue(condition.comparison_operator) && isSubVariable && (
+            <div className='p-1'>
+              <ConditionWrap
+                isSubVariable
+                conditions={condition.sub_variable_condition?.conditions || []}
+                logicalOperator={condition.sub_variable_condition?.logical_operator}
+                conditionId={conditionId}
+                readOnly={!!disabled}
+                handleAddSubVariableCondition={onAddSubVariableCondition}
+                handleRemoveSubVariableCondition={onRemoveSubVariableCondition}
+                handleUpdateSubVariableCondition={onUpdateSubVariableCondition}
+                handleToggleSubVariableConditionLogicalOperator={onToggleSubVariableConditionLogicalOperator}
+                nodeId={nodeId}
+                availableNodes={availableNodes}
+                availableVars={availableVars}
+              />
+            </div>
+          )
+        }
+      </div>
+      <div
+        className='shrink-0 flex items-center justify-center ml-1 mt-1 w-6 h-6 rounded-lg cursor-pointer hover:bg-state-destructive-hover text-text-tertiary hover:text-text-destructive'
+        onMouseEnter={() => setIsHovered(true)}
+        onMouseLeave={() => setIsHovered(false)}
+        onClick={doRemoveCondition}
+      >
+        <RiDeleteBinLine className='w-4 h-4' />
+      </div>
+    </div>
+  )
+}
+
+export default ConditionItem

+ 94 - 0
web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx

@@ -0,0 +1,94 @@
+import {
+  useMemo,
+  useState,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import { RiArrowDownSLine } from '@remixicon/react'
+import { getOperators, isComparisonOperatorNeedTranslate } from '../../utils'
+import type { ComparisonOperator } from '../../types'
+import Button from '@/app/components/base/button'
+import {
+  PortalToFollowElem,
+  PortalToFollowElemContent,
+  PortalToFollowElemTrigger,
+} from '@/app/components/base/portal-to-follow-elem'
+import type { VarType } from '@/app/components/workflow/types'
+import cn from '@/utils/classnames'
+const i18nPrefix = 'workflow.nodes.ifElse'
+
+type ConditionOperatorProps = {
+  className?: string
+  disabled?: boolean
+  varType: VarType
+  file?: { key: string }
+  value?: string
+  onSelect: (value: ComparisonOperator) => void
+}
+const ConditionOperator = ({
+  className,
+  disabled,
+  varType,
+  file,
+  value,
+  onSelect,
+}: ConditionOperatorProps) => {
+  const { t } = useTranslation()
+  const [open, setOpen] = useState(false)
+
+  const options = useMemo(() => {
+    return getOperators(varType, file).map((o) => {
+      return {
+        label: isComparisonOperatorNeedTranslate(o) ? t(`${i18nPrefix}.comparisonOperator.${o}`) : o,
+        value: o,
+      }
+    })
+  }, [t, varType, file])
+  const selectedOption = options.find(o => Array.isArray(value) ? o.value === value[0] : o.value === value)
+  return (
+    <PortalToFollowElem
+      open={open}
+      onOpenChange={setOpen}
+      placement='bottom-end'
+      offset={{
+        mainAxis: 4,
+        crossAxis: 0,
+      }}
+    >
+      <PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
+        <Button
+          className={cn('shrink-0', !selectedOption && 'opacity-50', className)}
+          size='small'
+          variant='ghost'
+          disabled={disabled}
+        >
+          {
+            selectedOption
+              ? selectedOption.label
+              : t(`${i18nPrefix}.select`)
+          }
+          <RiArrowDownSLine className='ml-1 w-3.5 h-3.5' />
+        </Button>
+      </PortalToFollowElemTrigger>
+      <PortalToFollowElemContent className='z-10'>
+        <div className='p-1 bg-components-panel-bg-blur rounded-xl border-[0.5px] border-components-panel-border shadow-lg'>
+          {
+            options.map(option => (
+              <div
+                key={option.value}
+                className='flex items-center px-3 py-1.5 h-7 text-[13px] font-medium text-text-secondary rounded-lg cursor-pointer hover:bg-state-base-hover'
+                onClick={() => {
+                  onSelect(option.value)
+                  setOpen(false)
+                }}
+              >
+                {option.label}
+              </div>
+            ))
+          }
+        </div>
+      </PortalToFollowElemContent>
+    </PortalToFollowElem>
+  )
+}
+
+export default ConditionOperator

+ 58 - 0
web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx

@@ -0,0 +1,58 @@
+import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
+import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
+import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
+import type { Node, NodeOutPutVar, ValueSelector, Var, VarType } from '@/app/components/workflow/types'
+
+type ConditionVarSelectorProps = {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  valueSelector: ValueSelector
+  varType: VarType
+  availableNodes: Node[]
+  nodesOutputVars: NodeOutPutVar[]
+  onChange: (valueSelector: ValueSelector, varItem: Var) => void
+}
+
+const ConditionVarSelector = ({
+  open,
+  onOpenChange,
+  valueSelector,
+  varType,
+  availableNodes,
+  nodesOutputVars,
+  onChange,
+}: ConditionVarSelectorProps) => {
+  return (
+    <PortalToFollowElem
+      open={open}
+      onOpenChange={onOpenChange}
+      placement='bottom-start'
+      offset={{
+        mainAxis: 4,
+        crossAxis: 0,
+      }}
+    >
+      <PortalToFollowElemTrigger onClick={() => onOpenChange(!open)}>
+        <div className="cursor-pointer">
+          <VariableTag
+            valueSelector={valueSelector}
+            varType={varType}
+            availableNodes={availableNodes}
+            isShort
+          />
+        </div>
+      </PortalToFollowElemTrigger>
+      <PortalToFollowElemContent className='z-[1000]'>
+        <div className='w-[296px] bg-components-panel-bg-blur rounded-lg border-[0.5px] border-components-panel-border shadow-lg'>
+          <VarReferenceVars
+            vars={nodesOutputVars}
+            isSupportFileVar
+            onChange={onChange}
+          />
+        </div>
+      </PortalToFollowElemContent>
+    </PortalToFollowElem>
+  )
+}
+
+export default ConditionVarSelector

+ 126 - 0
web/app/components/workflow/nodes/loop/components/condition-list/index.tsx

@@ -0,0 +1,126 @@
+import { RiLoopLeftLine } from '@remixicon/react'
+import { useCallback, useMemo } from 'react'
+import {
+  type Condition,
+  type HandleAddSubVariableCondition,
+  type HandleRemoveCondition,
+  type HandleToggleConditionLogicalOperator,
+  type HandleToggleSubVariableConditionLogicalOperator,
+  type HandleUpdateCondition,
+  type HandleUpdateSubVariableCondition,
+  LogicalOperator,
+  type handleRemoveSubVariableCondition,
+} from '../../types'
+import ConditionItem from './condition-item'
+import type {
+  Node,
+  NodeOutPutVar,
+} from '@/app/components/workflow/types'
+import cn from '@/utils/classnames'
+
+type ConditionListProps = {
+  isSubVariable?: boolean
+  disabled?: boolean
+  conditionId?: string
+  conditions: Condition[]
+  logicalOperator?: LogicalOperator
+  onRemoveCondition?: HandleRemoveCondition
+  onUpdateCondition?: HandleUpdateCondition
+  onToggleConditionLogicalOperator?: HandleToggleConditionLogicalOperator
+  nodeId: string
+  availableNodes: Node[]
+  numberVariables: NodeOutPutVar[]
+  onAddSubVariableCondition?: HandleAddSubVariableCondition
+  onRemoveSubVariableCondition?: handleRemoveSubVariableCondition
+  onUpdateSubVariableCondition?: HandleUpdateSubVariableCondition
+  onToggleSubVariableConditionLogicalOperator?: HandleToggleSubVariableConditionLogicalOperator
+  availableVars: NodeOutPutVar[]
+}
+const ConditionList = ({
+  isSubVariable,
+  disabled,
+  conditionId,
+  conditions,
+  logicalOperator,
+  onUpdateCondition,
+  onRemoveCondition,
+  onToggleConditionLogicalOperator,
+  onAddSubVariableCondition,
+  onRemoveSubVariableCondition,
+  onUpdateSubVariableCondition,
+  onToggleSubVariableConditionLogicalOperator,
+  nodeId,
+  availableNodes,
+  numberVariables,
+  availableVars,
+}: ConditionListProps) => {
+  const doToggleConditionLogicalOperator = useCallback((conditionId?: string) => {
+    if (isSubVariable && conditionId)
+      onToggleSubVariableConditionLogicalOperator?.(conditionId)
+    else
+      onToggleConditionLogicalOperator?.()
+  }, [isSubVariable, onToggleConditionLogicalOperator, onToggleSubVariableConditionLogicalOperator])
+
+  const isValueFieldShort = useMemo(() => {
+    if (isSubVariable && conditions.length > 1)
+      return true
+
+    return false
+  }, [conditions.length, isSubVariable])
+  const conditionItemClassName = useMemo(() => {
+    if (!isSubVariable)
+      return ''
+    if (conditions.length < 2)
+      return ''
+    return logicalOperator === LogicalOperator.and ? 'pl-[51px]' : 'pl-[42px]'
+  }, [conditions.length, isSubVariable, logicalOperator])
+
+  return (
+    <div className={cn('relative', conditions.length > 1 && !isSubVariable && 'pl-[60px]')}>
+      {
+        conditions.length > 1 && (
+          <div className={cn(
+            'absolute top-0 bottom-0 left-0 w-[60px]',
+            isSubVariable && logicalOperator === LogicalOperator.and && 'left-[-10px]',
+            isSubVariable && logicalOperator === LogicalOperator.or && 'left-[-18px]',
+          )}>
+            <div className='absolute top-4 bottom-4 left-[46px] w-2.5 border border-divider-deep rounded-l-[8px] border-r-0'></div>
+            <div className='absolute top-1/2 -translate-y-1/2 right-0 w-4 h-[29px] bg-components-panel-bg'></div>
+            <div
+              className='absolute top-1/2 right-1 -translate-y-1/2 flex items-center px-1 h-[21px] rounded-md border-[0.5px] border-components-button-secondary-border shadow-xs bg-components-button-secondary-bg text-text-accent-secondary text-[10px] font-semibold cursor-pointer select-none'
+              onClick={() => doToggleConditionLogicalOperator(conditionId)}
+            >
+              {logicalOperator && logicalOperator.toUpperCase()}
+              <RiLoopLeftLine className='ml-0.5 w-3 h-3' />
+            </div>
+          </div>
+        )
+      }
+      {
+        conditions.map(condition => (
+          <ConditionItem
+            key={condition.id}
+            className={conditionItemClassName}
+            disabled={disabled}
+            conditionId={isSubVariable ? conditionId! : condition.id}
+            condition={condition}
+            isValueFieldShort={isValueFieldShort}
+            onUpdateCondition={onUpdateCondition}
+            onRemoveCondition={onRemoveCondition}
+            onAddSubVariableCondition={onAddSubVariableCondition}
+            onRemoveSubVariableCondition={onRemoveSubVariableCondition}
+            onUpdateSubVariableCondition={onUpdateSubVariableCondition}
+            onToggleSubVariableConditionLogicalOperator={onToggleSubVariableConditionLogicalOperator}
+            nodeId={nodeId}
+            availableNodes={availableNodes}
+            numberVariables={numberVariables}
+            isSubVariableKey={isSubVariable}
+            availableVars={availableVars}
+          />
+        ))
+      }
+    </div>
+  )
+}
+
+export default ConditionList

+ 168 - 0
web/app/components/workflow/nodes/loop/components/condition-number-input.tsx

@@ -0,0 +1,168 @@
+import {
+  memo,
+  useCallback,
+  useState,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import { RiArrowDownSLine } from '@remixicon/react'
+import { capitalize } from 'lodash-es'
+import { useBoolean } from 'ahooks'
+import { VarType as NumberVarType } from '../../tool/types'
+import VariableTag from '../../_base/components/variable-tag'
+import {
+  PortalToFollowElem,
+  PortalToFollowElemContent,
+  PortalToFollowElemTrigger,
+} from '@/app/components/base/portal-to-follow-elem'
+import Button from '@/app/components/base/button'
+import cn from '@/utils/classnames'
+import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
+import type {
+  NodeOutPutVar,
+  ValueSelector,
+} from '@/app/components/workflow/types'
+import { VarType } from '@/app/components/workflow/types'
+import { variableTransformer } from '@/app/components/workflow/utils'
+import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
+
+const options = [
+  NumberVarType.variable,
+  NumberVarType.constant,
+]
+
+type ConditionNumberInputProps = {
+  numberVarType?: NumberVarType
+  onNumberVarTypeChange: (v: NumberVarType) => void
+  value: string
+  onValueChange: (v: string) => void
+  variables: NodeOutPutVar[]
+  isShort?: boolean
+  unit?: string
+}
+const ConditionNumberInput = ({
+  numberVarType = NumberVarType.constant,
+  onNumberVarTypeChange,
+  value,
+  onValueChange,
+  variables,
+  isShort,
+  unit,
+}: ConditionNumberInputProps) => {
+  const { t } = useTranslation()
+  const [numberVarTypeVisible, setNumberVarTypeVisible] = useState(false)
+  const [variableSelectorVisible, setVariableSelectorVisible] = useState(false)
+  const [isFocus, {
+    setTrue: setFocus,
+    setFalse: setBlur,
+  }] = useBoolean()
+
+  const handleSelectVariable = useCallback((valueSelector: ValueSelector) => {
+    onValueChange(variableTransformer(valueSelector) as string)
+    setVariableSelectorVisible(false)
+  }, [onValueChange])
+
+  return (
+    <div className='flex items-center cursor-pointer'>
+      <PortalToFollowElem
+        open={numberVarTypeVisible}
+        onOpenChange={setNumberVarTypeVisible}
+        placement='bottom-start'
+        offset={{ mainAxis: 2, crossAxis: 0 }}
+      >
+        <PortalToFollowElemTrigger onClick={() => setNumberVarTypeVisible(v => !v)}>
+          <Button
+            className='shrink-0'
+            variant='ghost'
+            size='small'
+          >
+            {capitalize(numberVarType)}
+            <RiArrowDownSLine className='ml-[1px] w-3.5 h-3.5' />
+          </Button>
+        </PortalToFollowElemTrigger>
+        <PortalToFollowElemContent className='z-[1000]'>
+          <div className='p-1 w-[112px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg'>
+            {
+              options.map(option => (
+                <div
+                  key={option}
+                  className={cn(
+                    'flex items-center px-3 h-7 rounded-md hover:bg-state-base-hover cursor-pointer',
+                    'text-[13px] font-medium text-text-secondary',
+                    numberVarType === option && 'bg-state-base-hover',
+                  )}
+                  onClick={() => {
+                    onNumberVarTypeChange(option)
+                    setNumberVarTypeVisible(false)
+                  }}
+                >
+                  {capitalize(option)}
+                </div>
+              ))
+            }
+          </div>
+        </PortalToFollowElemContent>
+      </PortalToFollowElem>
+      <div className='mx-1 w-[1px] h-4 bg-divider-regular'></div>
+      <div className='grow w-0 ml-0.5'>
+        {
+          numberVarType === NumberVarType.variable && (
+            <PortalToFollowElem
+              open={variableSelectorVisible}
+              onOpenChange={setVariableSelectorVisible}
+              placement='bottom-start'
+              offset={{ mainAxis: 2, crossAxis: 0 }}
+            >
+              <PortalToFollowElemTrigger
+                className='w-full'
+                onClick={() => setVariableSelectorVisible(v => !v)}>
+                {
+                  value && (
+                    <VariableTag
+                      valueSelector={variableTransformer(value) as string[]}
+                      varType={VarType.number}
+                      isShort={isShort}
+                    />
+                  )
+                }
+                {
+                  !value && (
+                    <div className='flex items-center p-1 h-6 text-components-input-text-placeholder text-[13px]'>
+                      <Variable02 className='shrink-0 mr-1 w-4 h-4' />
+                      <div className='w-0 grow truncate'>{t('workflow.nodes.ifElse.selectVariable')}</div>
+                    </div>
+                  )
+                }
+              </PortalToFollowElemTrigger>
+              <PortalToFollowElemContent className='z-[1000]'>
+                <div className={cn('w-[296px] pt-1 bg-components-panel-bg-blur rounded-lg border-[0.5px] border-components-panel-border shadow-lg', isShort && 'w-[200px]')}>
+                  <VarReferenceVars
+                    vars={variables}
+                    onChange={handleSelectVariable}
+                  />
+                </div>
+              </PortalToFollowElemContent>
+            </PortalToFollowElem>
+          )
+        }
+        {
+          numberVarType === NumberVarType.constant && (
+            <div className=' relative'>
+              <input
+                className={cn('block w-full px-2 text-[13px] text-components-input-text-filled placeholder:text-components-input-text-placeholder outline-none appearance-none bg-transparent', unit && 'pr-6')}
+                type='number'
+                value={value}
+                onChange={e => onValueChange(e.target.value)}
+                placeholder={t('workflow.nodes.ifElse.enterValue') || ''}
+                onFocus={setFocus}
+                onBlur={setBlur}
+              />
+              {!isFocus && unit && <div className='absolute right-2 top-[50%] translate-y-[-50%] text-text-tertiary system-sm-regular'>{unit}</div>}
+            </div>
+          )
+        }
+      </div>
+    </div>
+  )
+}
+
+export default memo(ConditionNumberInput)

+ 98 - 0
web/app/components/workflow/nodes/loop/components/condition-value.tsx

@@ -0,0 +1,98 @@
+import {
+  memo,
+  useMemo,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import { ComparisonOperator } from '../types'
+import {
+  comparisonOperatorNotRequireValue,
+  isComparisonOperatorNeedTranslate,
+} from '../utils'
+import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from './../default'
+import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
+import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
+import cn from '@/utils/classnames'
+import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
+
+type ConditionValueProps = {
+  variableSelector: string[]
+  labelName?: string
+  operator: ComparisonOperator
+  value: string | string[]
+}
+const ConditionValue = ({
+  variableSelector,
+  labelName,
+  operator,
+  value,
+}: ConditionValueProps) => {
+  const { t } = useTranslation()
+  const variableName = labelName || (isSystemVar(variableSelector) ? variableSelector.slice(0).join('.') : variableSelector.slice(1).join('.'))
+  const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}`) : operator
+  const notHasValue = comparisonOperatorNotRequireValue(operator)
+  const isEnvVar = isENV(variableSelector)
+  const isChatVar = isConversationVar(variableSelector)
+  const formatValue = useMemo(() => {
+    if (notHasValue)
+      return ''
+
+    if (Array.isArray(value)) // transfer method
+      return value[0]
+
+    return value.replace(/{{#([^#]*)#}}/g, (a, b) => {
+      const arr: string[] = b.split('.')
+      if (isSystemVar(arr))
+        return `{{${b}}}`
+
+      return `{{${arr.slice(1).join('.')}}}`
+    })
+  }, [notHasValue, value])
+
+  const isSelect = operator === ComparisonOperator.in || operator === ComparisonOperator.notIn
+  const selectName = useMemo(() => {
+    if (isSelect) {
+      const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(value) ? value[0] : value))[0]
+      return name
+        ? t(`workflow.nodes.ifElse.optionName.${name.i18nKey}`).replace(/{{#([^#]*)#}}/g, (a, b) => {
+          const arr: string[] = b.split('.')
+          if (isSystemVar(arr))
+            return `{{${b}}}`
+
+          return `{{${arr.slice(1).join('.')}}}`
+        })
+        : ''
+    }
+    return ''
+  }, [isSelect, t, value])
+
+  return (
+    <div className='flex items-center px-1 h-6 rounded-md bg-workflow-block-parma-bg'>
+      {!isEnvVar && !isChatVar && <Variable02 className='shrink-0 mr-1 w-3.5 h-3.5 text-text-accent' />}
+      {isEnvVar && <Env className='shrink-0 mr-1 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
+      {isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
+
+      <div
+        className={cn(
+          'shrink-0  truncate text-xs font-medium text-text-accent',
+          !notHasValue && 'max-w-[70px]',
+        )}
+        title={variableName}
+      >
+        {variableName}
+      </div>
+      <div
+        className='shrink-0 mx-1 text-xs font-medium text-text-primary'
+        title={operatorName}
+      >
+        {operatorName}
+      </div>
+      {
+        !notHasValue && (
+          <div className='truncate text-xs text-text-secondary' title={formatValue}>{isSelect ? selectName : formatValue}</div>
+        )
+      }
+    </div>
+  )
+}
+
+export default memo(ConditionValue)

+ 149 - 0
web/app/components/workflow/nodes/loop/components/condition-wrap.tsx

@@ -0,0 +1,149 @@
+'use client'
+import type { FC } from 'react'
+import React, { useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import {
+  RiAddLine,
+} from '@remixicon/react'
+import type { Condition, HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition, LogicalOperator, handleRemoveSubVariableCondition } from '../types'
+import type { Node, NodeOutPutVar, Var } from '../../../types'
+import { VarType } from '../../../types'
+import { useGetAvailableVars } from '../../variable-assigner/hooks'
+import ConditionList from './condition-list'
+import ConditionAdd from './condition-add'
+import { SUB_VARIABLES } from './../default'
+import cn from '@/utils/classnames'
+import Button from '@/app/components/base/button'
+import { PortalSelect as Select } from '@/app/components/base/select'
+
+type Props = {
+  isSubVariable?: boolean
+  conditionId?: string
+  conditions: Condition[]
+  logicalOperator: LogicalOperator | undefined
+  readOnly: boolean
+  handleAddCondition?: HandleAddCondition
+  handleRemoveCondition?: HandleRemoveCondition
+  handleUpdateCondition?: HandleUpdateCondition
+  handleToggleConditionLogicalOperator?: HandleToggleConditionLogicalOperator
+  handleAddSubVariableCondition?: HandleAddSubVariableCondition
+  handleRemoveSubVariableCondition?: handleRemoveSubVariableCondition
+  handleUpdateSubVariableCondition?: HandleUpdateSubVariableCondition
+  handleToggleSubVariableConditionLogicalOperator?: HandleToggleSubVariableConditionLogicalOperator
+  nodeId: string
+  availableNodes: Node[]
+  availableVars: NodeOutPutVar[]
+}
+
+const ConditionWrap: FC<Props> = ({
+  isSubVariable,
+  conditionId,
+  conditions,
+  logicalOperator,
+  nodeId: id = '',
+  readOnly,
+  handleUpdateCondition,
+  handleAddCondition,
+  handleRemoveCondition,
+  handleToggleConditionLogicalOperator,
+  handleAddSubVariableCondition,
+  handleRemoveSubVariableCondition,
+  handleUpdateSubVariableCondition,
+  handleToggleSubVariableConditionLogicalOperator,
+  availableNodes = [],
+  availableVars = [],
+}) => {
+  const { t } = useTranslation()
+
+  const getAvailableVars = useGetAvailableVars()
+
+  const filterNumberVar = useCallback((varPayload: Var) => {
+    return varPayload.type === VarType.number
+  }, [])
+
+  const subVarOptions = SUB_VARIABLES.map(item => ({
+    name: item,
+    value: item,
+  }))
+
+  if (!conditions)
+    return <div />
+
+  return (
+    <>
+      <div>
+        <div
+          className={cn(
+            'group relative rounded-[10px] bg-components-panel-bg',
+            !isSubVariable && 'py-1 px-3 min-h-[40px] ',
+            isSubVariable && 'px-1 py-2',
+          )}
+        >
+          {
+            conditions && !!conditions.length && (
+              <div className='mb-2'>
+                <ConditionList
+                  disabled={readOnly}
+                  conditionId={conditionId}
+                  conditions={conditions}
+                  logicalOperator={logicalOperator}
+                  onUpdateCondition={handleUpdateCondition}
+                  onRemoveCondition={handleRemoveCondition}
+                  onToggleConditionLogicalOperator={handleToggleConditionLogicalOperator}
+                  nodeId={id}
+                  availableNodes={availableNodes}
+                  numberVariables={getAvailableVars(id, '', filterNumberVar)}
+                  onAddSubVariableCondition={handleAddSubVariableCondition}
+                  onRemoveSubVariableCondition={handleRemoveSubVariableCondition}
+                  onUpdateSubVariableCondition={handleUpdateSubVariableCondition}
+                  onToggleSubVariableConditionLogicalOperator={handleToggleSubVariableConditionLogicalOperator}
+                  isSubVariable={isSubVariable}
+                  availableVars={availableVars}
+                />
+              </div>
+            )
+          }
+
+          <div className={cn(
+            'flex items-center justify-between pr-[30px]',
+            !conditions.length && !isSubVariable && 'mt-1',
+            !conditions.length && isSubVariable && 'mt-2',
+            conditions.length > 1 && !isSubVariable && 'ml-[60px]',
+          )}>
+            {isSubVariable
+              ? (
+                <Select
+                  popupInnerClassName='w-[165px] max-h-none'
+                  onSelect={value => handleAddSubVariableCondition?.(conditionId!, value.value as string)}
+                  items={subVarOptions}
+                  value=''
+                  renderTrigger={() => (
+                    <Button
+                      size='small'
+                      disabled={readOnly}
+                    >
+                      <RiAddLine className='mr-1 w-3.5 h-3.5' />
+                      {t('workflow.nodes.ifElse.addSubVariable')}
+                    </Button>
+                  )}
+                  hideChecked
+                />
+              )
+              : (
+                <ConditionAdd
+                  disabled={readOnly}
+                  variables={availableVars}
+                  onSelectVariable={handleAddCondition!}
+                />
+              )}
+          </div>
+        </div>
+        {!isSubVariable && (
+          <div className='my-2 mx-3 h-[1px] bg-divider-subtle'></div>
+        )}
+      </div>
+    </>
+  )
+}
+
+export default React.memo(ConditionWrap)

+ 92 - 0
web/app/components/workflow/nodes/loop/default.ts

@@ -0,0 +1,92 @@
+import { BlockEnum } from '../../types'
+import type { NodeDefault } from '../../types'
+import { ComparisonOperator, LogicalOperator, type LoopNodeType } from './types'
+import { isEmptyRelatedOperator } from './utils'
+import { TransferMethod } from '@/types/app'
+import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
+import { LOOP_NODE_MAX_COUNT } from '@/config'
+const i18nPrefix = 'workflow.errorMsg'
+
+const nodeDefault: NodeDefault<LoopNodeType> = {
+  defaultValue: {
+    start_node_id: '',
+    break_conditions: [],
+    loop_count: 10,
+    _children: [],
+    logical_operator: LogicalOperator.and,
+  },
+  getAvailablePrevNodes(isChatMode: boolean) {
+    const nodes = isChatMode
+      ? ALL_CHAT_AVAILABLE_BLOCKS
+      : ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
+    return nodes
+  },
+  getAvailableNextNodes(isChatMode: boolean) {
+    const nodes = isChatMode ? ALL_CHAT_AVAILABLE_BLOCKS : ALL_COMPLETION_AVAILABLE_BLOCKS
+    return nodes
+  },
+  checkValid(payload: LoopNodeType, t: any) {
+    let errorMessages = ''
+
+    if (!errorMessages && (!payload.break_conditions || payload.break_conditions.length === 0))
+      errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.loop.breakCondition') })
+
+    payload.break_conditions!.forEach((condition) => {
+      if (!errorMessages && (!condition.variable_selector || condition.variable_selector.length === 0))
+        errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variable`) })
+      if (!errorMessages && !condition.comparison_operator)
+        errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.ifElse.operator') })
+      if (!errorMessages) {
+        if (condition.sub_variable_condition
+          && ![ComparisonOperator.empty, ComparisonOperator.notEmpty].includes(condition.comparison_operator!)) {
+          const isSet = condition.sub_variable_condition.conditions.every((c) => {
+            if (!c.comparison_operator)
+              return false
+
+            if (isEmptyRelatedOperator(c.comparison_operator!))
+              return true
+
+            return !!c.value
+          })
+
+          if (!isSet)
+            errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variableValue`) })
+        }
+        else {
+          if (!isEmptyRelatedOperator(condition.comparison_operator!) && !condition.value)
+            errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variableValue`) })
+        }
+      }
+    })
+
+    if (!errorMessages && (
+      Number.isNaN(Number(payload.loop_count))
+      || !Number.isInteger(Number(payload.loop_count))
+      || payload.loop_count < 1
+      || payload.loop_count > LOOP_NODE_MAX_COUNT
+    ))
+      errorMessages = t('workflow.nodes.loop.loopMaxCountError', { maxCount: LOOP_NODE_MAX_COUNT })
+
+    return {
+      isValid: !errorMessages,
+      errorMessage: errorMessages,
+    }
+  },
+}
+
+export const FILE_TYPE_OPTIONS = [
+  { value: 'image', i18nKey: 'image' },
+  { value: 'document', i18nKey: 'doc' },
+  { value: 'audio', i18nKey: 'audio' },
+  { value: 'video', i18nKey: 'video' },
+]
+
+export const TRANSFER_METHOD = [
+  { value: TransferMethod.local_file, i18nKey: 'localUpload' },
+  { value: TransferMethod.remote_url, i18nKey: 'url' },
+]
+
+export const SUB_VARIABLES = ['type', 'size', 'name', 'url', 'extension', 'mime_type', 'transfer_method']
+export const OUTPUT_FILE_SUB_VARIABLES = SUB_VARIABLES.filter(key => key !== 'transfer_method')
+
+export default nodeDefault

+ 61 - 0
web/app/components/workflow/nodes/loop/insert-block.tsx

@@ -0,0 +1,61 @@
+import {
+  memo,
+  useCallback,
+  useState,
+} from 'react'
+import cn from 'classnames'
+import { useNodesInteractions } from '../../hooks'
+import type {
+  BlockEnum,
+  OnSelectBlock,
+} from '../../types'
+import BlockSelector from '../../block-selector'
+
+type InsertBlockProps = {
+  startNodeId: string
+  availableBlocksTypes: BlockEnum[]
+}
+const InsertBlock = ({
+  startNodeId,
+  availableBlocksTypes,
+}: InsertBlockProps) => {
+  const [open, setOpen] = useState(false)
+  const { handleNodeAdd } = useNodesInteractions()
+
+  const handleOpenChange = useCallback((v: boolean) => {
+    setOpen(v)
+  }, [])
+  const handleInsert = useCallback<OnSelectBlock>((nodeType, toolDefaultValue) => {
+    handleNodeAdd(
+      {
+        nodeType,
+        toolDefaultValue,
+      },
+      {
+        nextNodeId: startNodeId,
+        nextNodeTargetHandle: 'target',
+      },
+    )
+  }, [startNodeId, handleNodeAdd])
+
+  return (
+    <div
+      className={cn(
+        'nopan nodrag',
+        'hidden group-hover/insert:block absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2',
+        open && '!block',
+      )}
+    >
+      <BlockSelector
+        open={open}
+        onOpenChange={handleOpenChange}
+        asChild
+        onSelect={handleInsert}
+        availableBlocksTypes={availableBlocksTypes}
+        triggerClassName={() => 'hover:scale-125 transition-all'}
+      />
+    </div>
+  )
+}
+
+export default memo(InsertBlock)

+ 61 - 0
web/app/components/workflow/nodes/loop/node.tsx

@@ -0,0 +1,61 @@
+import type { FC } from 'react'
+import {
+  memo,
+  useEffect,
+} from 'react'
+import {
+  Background,
+  useNodesInitialized,
+  useViewport,
+} from 'reactflow'
+import { LoopStartNodeDumb } from '../loop-start'
+import { useNodeLoopInteractions } from './use-interactions'
+import type { LoopNodeType } from './types'
+import AddBlock from './add-block'
+import cn from '@/utils/classnames'
+
+import type { NodeProps } from '@/app/components/workflow/types'
+
+const Node: FC<NodeProps<LoopNodeType>> = ({
+  id,
+  data,
+}) => {
+  const { zoom } = useViewport()
+  const nodesInitialized = useNodesInitialized()
+  const { handleNodeLoopRerender } = useNodeLoopInteractions()
+
+  useEffect(() => {
+    if (nodesInitialized)
+      handleNodeLoopRerender(id)
+  }, [nodesInitialized, id, handleNodeLoopRerender])
+
+  return (
+    <div className={cn(
+      'relative min-w-[240px] min-h-[90px] w-full h-full rounded-2xl bg-[#F0F2F7]/90',
+    )}>
+      <Background
+        id={`loop-background-${id}`}
+        className='rounded-2xl !z-0'
+        gap={[14 / zoom, 14 / zoom]}
+        size={2 / zoom}
+        color='#E4E5E7'
+      />
+      {
+        data._isCandidate && (
+          <LoopStartNodeDumb />
+        )
+      }
+      {
+        data._children!.length === 1 && (
+          <AddBlock
+            loopNodeId={id}
+            loopNodeData={data}
+          />
+        )
+      }
+
+    </div>
+  )
+}
+
+export default memo(Node)

+ 120 - 0
web/app/components/workflow/nodes/loop/panel.tsx

@@ -0,0 +1,120 @@
+import type { FC } from 'react'
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+
+import Split from '../_base/components/split'
+import ResultPanel from '../../run/result-panel'
+import InputNumberWithSlider from '../_base/components/input-number-with-slider'
+import type { LoopNodeType } from './types'
+import useConfig from './use-config'
+import ConditionWrap from './components/condition-wrap'
+import type { NodePanelProps } from '@/app/components/workflow/types'
+import Field from '@/app/components/workflow/nodes/_base/components/field'
+import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/before-run-form'
+import formatTracing from '@/app/components/workflow/run/utils/format-log'
+
+import { useLogs } from '@/app/components/workflow/run/hooks'
+import { LOOP_NODE_MAX_COUNT } from '@/config'
+
+const i18nPrefix = 'workflow.nodes.loop'
+
+const Panel: FC<NodePanelProps<LoopNodeType>> = ({
+  id,
+  data,
+}) => {
+  const { t } = useTranslation()
+
+  const {
+    readOnly,
+    inputs,
+    childrenNodeVars,
+    loopChildrenNodes,
+    isShowSingleRun,
+    hideSingleRun,
+    runningStatus,
+    handleRun,
+    handleStop,
+    runResult,
+    loopRunResult,
+    handleAddCondition,
+    handleUpdateCondition,
+    handleRemoveCondition,
+    handleToggleConditionLogicalOperator,
+    handleAddSubVariableCondition,
+    handleRemoveSubVariableCondition,
+    handleUpdateSubVariableCondition,
+    handleToggleSubVariableConditionLogicalOperator,
+    handleUpdateLoopCount,
+  } = useConfig(id, data)
+
+  const nodeInfo = formatTracing(loopRunResult, t)[0]
+  const logsParams = useLogs()
+
+  return (
+    <div className='mt-2'>
+      <div>
+        <Field
+          title={<div className='pl-3'>{t(`${i18nPrefix}.breakCondition`)}</div>}
+        >
+          <ConditionWrap
+            nodeId={id}
+            readOnly={readOnly}
+            handleAddCondition={handleAddCondition}
+            handleRemoveCondition={handleRemoveCondition}
+            handleUpdateCondition={handleUpdateCondition}
+            handleToggleConditionLogicalOperator={handleToggleConditionLogicalOperator}
+            handleAddSubVariableCondition={handleAddSubVariableCondition}
+            handleRemoveSubVariableCondition={handleRemoveSubVariableCondition}
+            handleUpdateSubVariableCondition={handleUpdateSubVariableCondition}
+            handleToggleSubVariableConditionLogicalOperator={handleToggleSubVariableConditionLogicalOperator}
+            availableNodes={loopChildrenNodes}
+            availableVars={childrenNodeVars}
+            conditions={inputs.break_conditions || []}
+            logicalOperator={inputs.logical_operator!}
+          />
+        </Field>
+        <Split />
+        <div className='mt-2'>
+          <Field
+            title={<div className='pl-3'>{t(`${i18nPrefix}.loopMaxCount`)}</div>}
+          >
+            <div className='px-3 py-2'>
+              <InputNumberWithSlider
+                min={1}
+                max={LOOP_NODE_MAX_COUNT}
+                value={inputs.loop_count}
+                onChange={(val) => {
+                  const roundedVal = Math.round(val)
+                  handleUpdateLoopCount(Number.isNaN(roundedVal) ? 1 : roundedVal)
+                }}
+              />
+            </div>
+          </Field>
+        </div>
+      </div>
+      {/* Error handling for the Loop node is currently not considered. */}
+      {/* <div className='px-4 py-2'>
+        <Field title={t(`${i18nPrefix}.errorResponseMethod`)} >
+          <Select items={responseMethod} defaultValue={inputs.error_handle_mode} onSelect={changeErrorResponseMode} allowSearch={false}>
+          </Select>
+        </Field>
+      </div> */}
+      {isShowSingleRun && (
+        <BeforeRunForm
+          nodeName={inputs.title}
+          onHide={hideSingleRun}
+          forms={[]}
+          runningStatus={runningStatus}
+          onRun={handleRun}
+          onStop={handleStop}
+          {...logsParams}
+          result={
+            <ResultPanel {...runResult} showSteps={false} nodeInfo={nodeInfo} {...logsParams} />
+          }
+        />
+      )}
+    </div>
+  )
+}
+
+export default React.memo(Panel)

+ 76 - 0
web/app/components/workflow/nodes/loop/types.ts

@@ -0,0 +1,76 @@
+import type { VarType as NumberVarType } from '../tool/types'
+import type {
+  BlockEnum,
+  CommonNodeType,
+  ErrorHandleMode,
+  ValueSelector,
+  Var,
+  VarType,
+} from '@/app/components/workflow/types'
+
+export enum LogicalOperator {
+  and = 'and',
+  or = 'or',
+}
+
+export enum ComparisonOperator {
+  contains = 'contains',
+  notContains = 'not contains',
+  startWith = 'start with',
+  endWith = 'end with',
+  is = 'is',
+  isNot = 'is not',
+  empty = 'empty',
+  notEmpty = 'not empty',
+  equal = '=',
+  notEqual = '≠',
+  largerThan = '>',
+  lessThan = '<',
+  largerThanOrEqual = '≥',
+  lessThanOrEqual = '≤',
+  isNull = 'is null',
+  isNotNull = 'is not null',
+  in = 'in',
+  notIn = 'not in',
+  allOf = 'all of',
+  exists = 'exists',
+  notExists = 'not exists',
+}
+
+export type Condition = {
+  id: string
+  varType: VarType
+  variable_selector?: ValueSelector
+  key?: string // sub variable key
+  comparison_operator?: ComparisonOperator
+  value: string | string[]
+  numberVarType?: NumberVarType
+  sub_variable_condition?: CaseItem
+}
+
+export type CaseItem = {
+  logical_operator: LogicalOperator
+  conditions: Condition[]
+}
+
+export type HandleAddCondition = (valueSelector: ValueSelector, varItem: Var) => void
+export type HandleRemoveCondition = (conditionId: string) => void
+export type HandleUpdateCondition = (conditionId: string, newCondition: Condition) => void
+export type HandleUpdateConditionLogicalOperator = (value: LogicalOperator) => void
+
+export type HandleToggleConditionLogicalOperator = () => void
+
+export type HandleAddSubVariableCondition = (conditionId: string, key?: string) => void
+export type handleRemoveSubVariableCondition = (conditionId: string, subConditionId: string) => void
+export type HandleUpdateSubVariableCondition = (conditionId: string, subConditionId: string, newSubCondition: Condition) => void
+export type HandleToggleSubVariableConditionLogicalOperator = (conditionId: string) => void
+
+export type LoopNodeType = CommonNodeType & {
+  startNodeType?: BlockEnum
+  start_node_id: string
+  loop_id?: string
+  logical_operator?: LogicalOperator
+  break_conditions?: Condition[]
+  loop_count: number
+  error_handle_mode: ErrorHandleMode // how to handle error in the iteration
+}

+ 329 - 0
web/app/components/workflow/nodes/loop/use-config.ts

@@ -0,0 +1,329 @@
+import { useCallback } from 'react'
+import produce from 'immer'
+import { useBoolean } from 'ahooks'
+import { uuid4 } from '@sentry/utils'
+import {
+  useIsChatMode,
+  useIsNodeInLoop,
+  useNodesReadOnly,
+  useWorkflow,
+} from '../../hooks'
+import { VarType } from '../../types'
+import type { ErrorHandleMode, ValueSelector, Var } from '../../types'
+import useNodeCrud from '../_base/hooks/use-node-crud'
+import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar, toNodeOutputVars } from '../_base/components/variable/utils'
+import useOneStepRun from '../_base/hooks/use-one-step-run'
+import { getOperators } from './utils'
+import { LogicalOperator } from './types'
+import type { HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition, LoopNodeType } from './types'
+import useIsVarFileAttribute from './use-is-var-file-attribute'
+
+const DELIMITER = '@@@@@'
+const useConfig = (id: string, payload: LoopNodeType) => {
+  const { nodesReadOnly: readOnly } = useNodesReadOnly()
+  const { isNodeInLoop } = useIsNodeInLoop(id)
+  const isChatMode = useIsChatMode()
+
+  const { inputs, setInputs } = useNodeCrud<LoopNodeType>(id, payload)
+
+  const filterInputVar = useCallback((varPayload: Var) => {
+    return [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject, VarType.arrayFile].includes(varPayload.type)
+  }, [])
+
+  // output
+  const { getLoopNodeChildren, getBeforeNodesInSameBranch } = useWorkflow()
+  const beforeNodes = getBeforeNodesInSameBranch(id)
+  const loopChildrenNodes = getLoopNodeChildren(id)
+  const canChooseVarNodes = [...beforeNodes, ...loopChildrenNodes]
+  const childrenNodeVars = toNodeOutputVars(loopChildrenNodes, isChatMode)
+
+  // single run
+  const loopInputKey = `${id}.input_selector`
+  const {
+    isShowSingleRun,
+    showSingleRun,
+    hideSingleRun,
+    toVarInputs,
+    runningStatus,
+    handleRun: doHandleRun,
+    handleStop,
+    runInputData,
+    setRunInputData,
+    runResult,
+    loopRunResult,
+  } = useOneStepRun<LoopNodeType>({
+    id,
+    data: inputs,
+    loopInputKey,
+    defaultRunInputData: {
+      [loopInputKey]: [''],
+    },
+  })
+
+  const [isShowLoopDetail, {
+    setTrue: doShowLoopDetail,
+    setFalse: doHideLoopDetail,
+  }] = useBoolean(false)
+
+  const hideLoopDetail = useCallback(() => {
+    hideSingleRun()
+    doHideLoopDetail()
+  }, [doHideLoopDetail, hideSingleRun])
+
+  const showLoopDetail = useCallback(() => {
+    doShowLoopDetail()
+  }, [doShowLoopDetail])
+
+  const backToSingleRun = useCallback(() => {
+    hideLoopDetail()
+    showSingleRun()
+  }, [hideLoopDetail, showSingleRun])
+
+  const {
+    getIsVarFileAttribute,
+  } = useIsVarFileAttribute({
+    nodeId: id,
+  })
+
+  const { usedOutVars, allVarObject } = (() => {
+    const vars: ValueSelector[] = []
+    const varObjs: Record<string, boolean> = {}
+    const allVarObject: Record<string, {
+      inSingleRunPassedKey: string
+    }> = {}
+    loopChildrenNodes.forEach((node) => {
+      const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0)
+      nodeVars.forEach((varSelector) => {
+        if (varSelector[0] === id) { // skip Loop node itself variable: item, index
+          return
+        }
+        const isInLoop = isNodeInLoop(varSelector[0])
+        if (isInLoop) // not pass loop inner variable
+          return
+
+        const varSectorStr = varSelector.join('.')
+        if (!varObjs[varSectorStr]) {
+          varObjs[varSectorStr] = true
+          vars.push(varSelector)
+        }
+        let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector)
+        if (typeof passToServerKeys === 'string')
+          passToServerKeys = [passToServerKeys]
+
+        passToServerKeys.forEach((key: string, index: number) => {
+          allVarObject[[varSectorStr, node.id, index].join(DELIMITER)] = {
+            inSingleRunPassedKey: key,
+          }
+        })
+      })
+    })
+    const res = toVarInputs(vars.map((item) => {
+      const varInfo = getNodeInfoById(canChooseVarNodes, item[0])
+      return {
+        label: {
+          nodeType: varInfo?.data.type,
+          nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title, // default start node title
+          variable: isSystemVar(item) ? item.join('.') : item[item.length - 1],
+        },
+        variable: `${item.join('.')}`,
+        value_selector: item,
+      }
+    }))
+    return {
+      usedOutVars: res,
+      allVarObject,
+    }
+  })()
+
+  const handleRun = useCallback((data: Record<string, any>) => {
+    const formattedData: Record<string, any> = {}
+    Object.keys(allVarObject).forEach((key) => {
+      const [varSectorStr, nodeId] = key.split(DELIMITER)
+      formattedData[`${nodeId}.${allVarObject[key].inSingleRunPassedKey}`] = data[varSectorStr]
+    })
+    formattedData[loopInputKey] = data[loopInputKey]
+    doHandleRun(formattedData)
+  }, [allVarObject, doHandleRun, loopInputKey])
+
+  const inputVarValues = (() => {
+    const vars: Record<string, any> = {}
+    Object.keys(runInputData)
+      .filter(key => ![loopInputKey].includes(key))
+      .forEach((key) => {
+        vars[key] = runInputData[key]
+      })
+    return vars
+  })()
+
+  const setInputVarValues = useCallback((newPayload: Record<string, any>) => {
+    const newVars = {
+      ...newPayload,
+      [loopInputKey]: runInputData[loopInputKey],
+    }
+    setRunInputData(newVars)
+  }, [loopInputKey, runInputData, setRunInputData])
+
+  const loop = runInputData[loopInputKey]
+  const setLoop = useCallback((newLoop: string[]) => {
+    setRunInputData({
+      ...runInputData,
+      [loopInputKey]: newLoop,
+    })
+  }, [loopInputKey, runInputData, setRunInputData])
+
+  const changeErrorResponseMode = useCallback((item: { value: unknown }) => {
+    const newInputs = produce(inputs, (draft) => {
+      draft.error_handle_mode = item.value as ErrorHandleMode
+    })
+    setInputs(newInputs)
+  }, [inputs, setInputs])
+
+  const handleAddCondition = useCallback<HandleAddCondition>((valueSelector, varItem) => {
+    const newInputs = produce(inputs, (draft) => {
+      if (!draft.break_conditions)
+        draft.break_conditions = []
+
+      draft.break_conditions?.push({
+        id: uuid4(),
+        varType: varItem.type,
+        variable_selector: valueSelector,
+        comparison_operator: getOperators(varItem.type, getIsVarFileAttribute(valueSelector) ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
+        value: '',
+      })
+    })
+    setInputs(newInputs)
+  }, [getIsVarFileAttribute, inputs, setInputs])
+
+  const handleRemoveCondition = useCallback<HandleRemoveCondition>((conditionId) => {
+    const newInputs = produce(inputs, (draft) => {
+      draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId)
+    })
+    setInputs(newInputs)
+  }, [inputs, setInputs])
+
+  const handleUpdateCondition = useCallback<HandleUpdateCondition>((conditionId, newCondition) => {
+    const newInputs = produce(inputs, (draft) => {
+      const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
+      if (targetCondition)
+        Object.assign(targetCondition, newCondition)
+    })
+    setInputs(newInputs)
+  }, [inputs, setInputs])
+
+  const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>(() => {
+    const newInputs = produce(inputs, (draft) => {
+      draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
+    })
+    setInputs(newInputs)
+  }, [inputs, setInputs])
+
+  const handleAddSubVariableCondition = useCallback<HandleAddSubVariableCondition>((conditionId: string, key?: string) => {
+    const newInputs = produce(inputs, (draft) => {
+      const condition = draft.break_conditions?.find(item => item.id === conditionId)
+      if (!condition)
+        return
+      if (!condition?.sub_variable_condition) {
+        condition.sub_variable_condition = {
+          logical_operator: LogicalOperator.and,
+          conditions: [],
+        }
+      }
+      const subVarCondition = condition.sub_variable_condition
+      if (subVarCondition) {
+        if (!subVarCondition.conditions)
+          subVarCondition.conditions = []
+
+        const svcComparisonOperators = getOperators(VarType.string, { key: key || '' })
+
+        subVarCondition.conditions.push({
+          id: uuid4(),
+          key: key || '',
+          varType: VarType.string,
+          comparison_operator: (svcComparisonOperators && svcComparisonOperators.length) ? svcComparisonOperators[0] : undefined,
+          value: '',
+        })
+      }
+    })
+    setInputs(newInputs)
+  }, [inputs, setInputs])
+
+  const handleRemoveSubVariableCondition = useCallback((conditionId: string, subConditionId: string) => {
+    const newInputs = produce(inputs, (draft) => {
+      const condition = draft.break_conditions?.find(item => item.id === conditionId)
+      if (!condition)
+        return
+      if (!condition?.sub_variable_condition)
+        return
+      const subVarCondition = condition.sub_variable_condition
+      if (subVarCondition)
+        subVarCondition.conditions = subVarCondition.conditions.filter(item => item.id !== subConditionId)
+    })
+    setInputs(newInputs)
+  }, [inputs, setInputs])
+
+  const handleUpdateSubVariableCondition = useCallback<HandleUpdateSubVariableCondition>((conditionId, subConditionId, newSubCondition) => {
+    const newInputs = produce(inputs, (draft) => {
+      const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
+      if (targetCondition && targetCondition.sub_variable_condition) {
+        const targetSubCondition = targetCondition.sub_variable_condition.conditions.find(item => item.id === subConditionId)
+        if (targetSubCondition)
+          Object.assign(targetSubCondition, newSubCondition)
+      }
+    })
+    setInputs(newInputs)
+  }, [inputs, setInputs])
+
+  const handleToggleSubVariableConditionLogicalOperator = useCallback<HandleToggleSubVariableConditionLogicalOperator>((conditionId) => {
+    const newInputs = produce(inputs, (draft) => {
+      const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
+      if (targetCondition && targetCondition.sub_variable_condition)
+        targetCondition.sub_variable_condition.logical_operator = targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
+    })
+    setInputs(newInputs)
+  }, [inputs, setInputs])
+
+  const handleUpdateLoopCount = useCallback((value: number) => {
+    const newInputs = produce(inputs, (draft) => {
+      draft.loop_count = value
+    })
+    setInputs(newInputs)
+  }, [inputs, setInputs])
+
+  return {
+    readOnly,
+    inputs,
+    filterInputVar,
+    childrenNodeVars,
+    loopChildrenNodes,
+    isShowSingleRun,
+    showSingleRun,
+    hideSingleRun,
+    isShowLoopDetail,
+    showLoopDetail,
+    hideLoopDetail,
+    backToSingleRun,
+    runningStatus,
+    handleRun,
+    handleStop,
+    runResult,
+    inputVarValues,
+    setInputVarValues,
+    usedOutVars,
+    loop,
+    setLoop,
+    loopInputKey,
+    loopRunResult,
+    handleAddCondition,
+    handleRemoveCondition,
+    handleUpdateCondition,
+    handleToggleConditionLogicalOperator,
+    handleAddSubVariableCondition,
+    handleUpdateSubVariableCondition,
+    handleRemoveSubVariableCondition,
+    handleToggleSubVariableConditionLogicalOperator,
+    handleUpdateLoopCount,
+    changeErrorResponseMode,
+  }
+}
+
+export default useConfig

+ 146 - 0
web/app/components/workflow/nodes/loop/use-interactions.ts

@@ -0,0 +1,146 @@
+import { useCallback } from 'react'
+import produce from 'immer'
+import { useTranslation } from 'react-i18next'
+import { useStoreApi } from 'reactflow'
+import type {
+  BlockEnum,
+  Node,
+} from '../../types'
+import { generateNewNode } from '../../utils'
+import {
+  LOOP_PADDING,
+  NODES_INITIAL_DATA,
+} from '../../constants'
+import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants'
+
+export const useNodeLoopInteractions = () => {
+  const { t } = useTranslation()
+  const store = useStoreApi()
+
+  const handleNodeLoopRerender = useCallback((nodeId: string) => {
+    const {
+      getNodes,
+      setNodes,
+    } = store.getState()
+
+    const nodes = getNodes()
+    const currentNode = nodes.find(n => n.id === nodeId)!
+    const childrenNodes = nodes.filter(n => n.parentId === nodeId)
+    let rightNode: Node
+    let bottomNode: Node
+
+    childrenNodes.forEach((n) => {
+      if (rightNode) {
+        if (n.position.x + n.width! > rightNode.position.x + rightNode.width!)
+          rightNode = n
+      }
+      else {
+        rightNode = n
+      }
+      if (bottomNode) {
+        if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!)
+          bottomNode = n
+      }
+      else {
+        bottomNode = n
+      }
+    })
+
+    const widthShouldExtend = rightNode! && currentNode.width! < rightNode.position.x + rightNode.width!
+    const heightShouldExtend = bottomNode! && currentNode.height! < bottomNode.position.y + bottomNode.height!
+
+    if (widthShouldExtend || heightShouldExtend) {
+      const newNodes = produce(nodes, (draft) => {
+        draft.forEach((n) => {
+          if (n.id === nodeId) {
+            if (widthShouldExtend) {
+              n.data.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right
+              n.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right
+            }
+            if (heightShouldExtend) {
+              n.data.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
+              n.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
+            }
+          }
+        })
+      })
+
+      setNodes(newNodes)
+    }
+  }, [store])
+
+  const handleNodeLoopChildDrag = useCallback((node: Node) => {
+    const { getNodes } = store.getState()
+    const nodes = getNodes()
+
+    const restrictPosition: { x?: number; y?: number } = { x: undefined, y: undefined }
+
+    if (node.data.isInLoop) {
+      const parentNode = nodes.find(n => n.id === node.parentId)
+
+      if (parentNode) {
+        if (node.position.y < LOOP_PADDING.top)
+          restrictPosition.y = LOOP_PADDING.top
+        if (node.position.x < LOOP_PADDING.left)
+          restrictPosition.x = LOOP_PADDING.left
+        if (node.position.x + node.width! > parentNode!.width! - LOOP_PADDING.right)
+          restrictPosition.x = parentNode!.width! - LOOP_PADDING.right - node.width!
+        if (node.position.y + node.height! > parentNode!.height! - LOOP_PADDING.bottom)
+          restrictPosition.y = parentNode!.height! - LOOP_PADDING.bottom - node.height!
+      }
+    }
+
+    return {
+      restrictPosition,
+    }
+  }, [store])
+
+  const handleNodeLoopChildSizeChange = useCallback((nodeId: string) => {
+    const { getNodes } = store.getState()
+    const nodes = getNodes()
+    const currentNode = nodes.find(n => n.id === nodeId)!
+    const parentId = currentNode.parentId
+
+    if (parentId)
+      handleNodeLoopRerender(parentId)
+  }, [store, handleNodeLoopRerender])
+
+  const handleNodeLoopChildrenCopy = useCallback((nodeId: string, newNodeId: string) => {
+    const { getNodes } = store.getState()
+    const nodes = getNodes()
+    const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
+
+    return childrenNodes.map((child, index) => {
+      const childNodeType = child.data.type as BlockEnum
+      const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
+      const { newNode } = generateNewNode({
+
+        data: {
+          ...NODES_INITIAL_DATA[childNodeType],
+          ...child.data,
+          selected: false,
+          _isBundled: false,
+          _connectedSourceHandleIds: [],
+          _connectedTargetHandleIds: [],
+          title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${childNodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${childNodeType}`),
+          loop_id: newNodeId,
+
+        },
+        position: child.position,
+        positionAbsolute: child.positionAbsolute,
+        parentId: newNodeId,
+        extent: child.extent,
+        zIndex: child.zIndex,
+      })
+      newNode.id = `${newNodeId}${newNode.id + index}`
+      return newNode
+    })
+  }, [store, t])
+
+  return {
+    handleNodeLoopRerender,
+    handleNodeLoopChildDrag,
+    handleNodeLoopChildSizeChange,
+    handleNodeLoopChildrenCopy,
+  }
+}

Some files were not shown because too many files changed in this diff