From 2c17bb2c3612d1e2d04471aa0072098dea9e4e86 Mon Sep 17 00:00:00 2001 From: Wood Date: Wed, 5 Mar 2025 17:41:15 +0800 Subject: [PATCH] Feature/newnew workflow loop node (#14863) Co-authored-by: arkunzz <4873204@qq.com> --- api/controllers/console/app/workflow.py | 79 ++++ .../app/apps/advanced_chat/app_generator.py | 55 +++ api/core/app/apps/advanced_chat/app_runner.py | 7 + .../advanced_chat/generate_task_pipeline.py | 60 ++- api/core/app/apps/workflow/app_generator.py | 54 +++ api/core/app/apps/workflow/app_runner.py | 7 + .../apps/workflow/generate_task_pipeline.py | 63 ++- api/core/app/apps/workflow_app_runner.py | 196 +++++++++- api/core/app/entities/app_invoke_entities.py | 20 + api/core/app/entities/queue_entities.py | 141 +++++++ api/core/app/entities/task_entities.py | 98 +++++ .../task_pipeline/workflow_cycle_manage.py | 97 ++++- .../callbacks/workflow_logging_callback.py | 39 ++ api/core/workflow/entities/node_entities.py | 3 + .../workflow/entities/workflow_entities.py | 5 +- .../workflow/graph_engine/entities/event.py | 63 ++- .../workflow/graph_engine/graph_engine.py | 7 + .../answer/answer_stream_generate_router.py | 1 + .../nodes/answer/answer_stream_processor.py | 2 +- api/core/workflow/nodes/base/__init__.py | 11 +- api/core/workflow/nodes/base/entities.py | 15 + .../nodes/end/end_stream_processor.py | 2 +- api/core/workflow/nodes/enums.py | 1 + api/core/workflow/nodes/loop/__init__.py | 5 + api/core/workflow/nodes/loop/entities.py | 47 ++- api/core/workflow/nodes/loop/loop_node.py | 363 +++++++++++++++++- .../workflow/nodes/loop/loop_start_node.py | 20 + api/core/workflow/nodes/node_mapping.py | 9 + api/services/app_generate_service.py | 19 + docker/.env.example | 3 + docker/docker-compose-template.yaml | 1 + docker/docker-compose.yaml | 2 + web/.env.example | 3 + .../chat/answer/__mocks__/workflowProcess.ts | 2 + web/app/components/base/chat/chat/hooks.ts | 37 +- .../base/icons/src/vender/workflow/Loop.json | 66 ++++ .../base/icons/src/vender/workflow/Loop.tsx | 16 + .../icons/src/vender/workflow/LoopStart.json | 36 ++ .../icons/src/vender/workflow/LoopStart.tsx | 16 + .../base/icons/src/vender/workflow/index.ts | 2 + .../share/text-generation/result/index.tsx | 35 ++ web/app/components/workflow/block-icon.tsx | 4 + .../workflow/block-selector/constants.tsx | 5 + .../components/workflow/candidate-node.tsx | 5 +- web/app/components/workflow/constants.ts | 45 ++- web/app/components/workflow/custom-edge.tsx | 7 +- .../components/workflow/hooks/use-helpline.ts | 13 + .../workflow/hooks/use-nodes-data.ts | 16 +- .../workflow/hooks/use-nodes-interactions.ts | 243 ++++++++++-- .../hooks/use-workflow-run-event/index.ts | 3 + .../use-workflow-node-loop-finished.ts | 46 +++ .../use-workflow-node-loop-next.ts | 35 ++ .../use-workflow-node-loop-started.ts | 85 ++++ .../use-workflow-run-event.ts | 9 + .../workflow/hooks/use-workflow-run.ts | 54 ++- .../workflow/hooks/use-workflow-variables.ts | 3 + .../components/workflow/hooks/use-workflow.ts | 42 +- web/app/components/workflow/index.tsx | 4 + .../nodes/_base/components/next-step/add.tsx | 2 +- .../_base/components/next-step/operator.tsx | 2 +- .../nodes/_base/components/node-handle.tsx | 4 +- .../panel-operator/change-block.tsx | 2 +- .../panel-operator/panel-operator-popup.tsx | 2 +- .../nodes/_base/components/variable/utils.ts | 97 +++++ .../variable/var-reference-picker.tsx | 18 +- .../_base/hooks/use-available-var-list.ts | 1 + .../nodes/_base/hooks/use-node-help-link.ts | 5 + .../nodes/_base/hooks/use-node-info.ts | 2 + .../nodes/_base/hooks/use-one-step-run.ts | 125 +++++- .../components/workflow/nodes/_base/node.tsx | 47 ++- .../components/workflow/nodes/_base/panel.tsx | 4 +- .../workflow/nodes/assigner/use-config.ts | 6 +- .../components/workflow/nodes/constants.ts | 4 + .../nodes/document-extractor/use-config.ts | 6 +- .../workflow/nodes/if-else/types.ts | 5 +- .../workflow/nodes/if-else/use-config.ts | 1 + .../if-else/use-is-var-file-attribute.ts | 5 +- .../nodes/list-operator/use-config.ts | 6 +- .../workflow/nodes/loop-start/constants.ts | 1 + .../workflow/nodes/loop-start/default.ts | 21 + .../workflow/nodes/loop-start/index.tsx | 42 ++ .../workflow/nodes/loop-start/types.ts | 3 + .../workflow/nodes/loop/add-block.tsx | 80 ++++ .../nodes/loop/components/condition-add.tsx | 74 ++++ .../components/condition-files-list-value.tsx | 115 ++++++ .../condition-list/condition-input.tsx | 53 +++ .../condition-list/condition-item.tsx | 330 ++++++++++++++++ .../condition-list/condition-operator.tsx | 94 +++++ .../condition-list/condition-var-selector.tsx | 58 +++ .../loop/components/condition-list/index.tsx | 126 ++++++ .../components/condition-number-input.tsx | 168 ++++++++ .../nodes/loop/components/condition-value.tsx | 98 +++++ .../nodes/loop/components/condition-wrap.tsx | 149 +++++++ .../components/workflow/nodes/loop/default.ts | 92 +++++ .../workflow/nodes/loop/insert-block.tsx | 61 +++ .../components/workflow/nodes/loop/node.tsx | 61 +++ .../components/workflow/nodes/loop/panel.tsx | 120 ++++++ .../components/workflow/nodes/loop/types.ts | 76 ++++ .../workflow/nodes/loop/use-config.ts | 329 ++++++++++++++++ .../workflow/nodes/loop/use-interactions.ts | 146 +++++++ .../nodes/loop/use-is-var-file-attribute.ts | 35 ++ .../components/workflow/nodes/loop/utils.ts | 179 +++++++++ .../workflow/panel/debug-and-preview/hooks.ts | 41 +- web/app/components/workflow/run/hooks.ts | 24 +- .../workflow/run/loop-log/index.tsx | 2 + .../run/loop-log/loop-log-trigger.tsx | 57 +++ .../run/loop-log/loop-result-panel.tsx | 128 ++++++ .../workflow/run/loop-result-panel.tsx | 122 ++++++ web/app/components/workflow/run/node.tsx | 14 + .../components/workflow/run/result-panel.tsx | 12 + .../workflow/run/special-result-panel.tsx | 21 + .../components/workflow/run/tracing-panel.tsx | 12 + .../format-log/graph-to-log-struct.spec.ts | 10 + .../utils/format-log/graph-to-log-struct.ts | 72 +++- .../workflow/run/utils/format-log/index.ts | 77 +++- .../run/utils/format-log/iteration/index.ts | 3 +- .../run/utils/format-log/loop/index.spec.ts | 22 ++ .../run/utils/format-log/loop/index.ts | 56 +++ .../run/utils/format-log/retry/index.ts | 12 +- web/app/components/workflow/store.ts | 4 + web/app/components/workflow/types.ts | 9 + web/app/components/workflow/utils.ts | 120 +++++- web/app/layout.tsx | 1 + web/config/index.ts | 9 + web/i18n/en-US/workflow.ts | 22 ++ web/i18n/zh-Hans/workflow.ts | 23 ++ web/i18n/zh-Hant/workflow.ts | 2 + web/service/base.ts | 52 ++- web/service/share.ts | 46 ++- web/service/workflow.ts | 4 + web/types/workflow.ts | 31 +- 131 files changed, 6031 insertions(+), 159 deletions(-) create mode 100644 api/core/workflow/nodes/loop/loop_start_node.py create mode 100644 web/app/components/base/icons/src/vender/workflow/Loop.json create mode 100644 web/app/components/base/icons/src/vender/workflow/Loop.tsx create mode 100644 web/app/components/base/icons/src/vender/workflow/LoopStart.json create mode 100644 web/app/components/base/icons/src/vender/workflow/LoopStart.tsx create mode 100644 web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts create mode 100644 web/app/components/workflow/nodes/loop-start/constants.ts create mode 100644 web/app/components/workflow/nodes/loop-start/default.ts create mode 100644 web/app/components/workflow/nodes/loop-start/index.tsx create mode 100644 web/app/components/workflow/nodes/loop-start/types.ts create mode 100644 web/app/components/workflow/nodes/loop/add-block.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-add.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-list/index.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-number-input.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-value.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-wrap.tsx create mode 100644 web/app/components/workflow/nodes/loop/default.ts create mode 100644 web/app/components/workflow/nodes/loop/insert-block.tsx create mode 100644 web/app/components/workflow/nodes/loop/node.tsx create mode 100644 web/app/components/workflow/nodes/loop/panel.tsx create mode 100644 web/app/components/workflow/nodes/loop/types.ts create mode 100644 web/app/components/workflow/nodes/loop/use-config.ts create mode 100644 web/app/components/workflow/nodes/loop/use-interactions.ts create mode 100644 web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts create mode 100644 web/app/components/workflow/nodes/loop/utils.ts create mode 100644 web/app/components/workflow/run/loop-log/index.tsx create mode 100644 web/app/components/workflow/run/loop-log/loop-log-trigger.tsx create mode 100644 web/app/components/workflow/run/loop-log/loop-result-panel.tsx create mode 100644 web/app/components/workflow/run/loop-result-panel.tsx create mode 100644 web/app/components/workflow/run/utils/format-log/loop/index.spec.ts create mode 100644 web/app/components/workflow/run/utils/format-log/loop/index.ts diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 0cc5f31dd..92e06d4bc 100644 --- a/api/controllers/console/app/workflow.py +++ b/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//workflows/draft/iteration/nodes//run" ) +api.add_resource( + AdvancedChatDraftRunLoopNodeApi, + "/apps//advanced-chat/workflows/draft/loop/nodes//run", +) +api.add_resource(WorkflowDraftRunLoopNodeApi, "/apps//workflows/draft/loop/nodes//run") api.add_resource(PublishedWorkflowApi, "/apps//workflows/publish") api.add_resource(PublishedAllWorkflowApi, "/apps//workflows") api.add_resource(DefaultBlockConfigsApi, "/apps//workflows/default-workflow-block-configs") diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index 5cc4b2b0e..efa6f5595 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/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, *, diff --git a/api/core/app/apps/advanced_chat/app_runner.py b/api/core/app/apps/advanced_chat/app_runner.py index 0e918e492..c83e06bf1 100644 --- a/api/core/app/apps/advanced_chat/app_runner.py +++ b/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 diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index 5dd3ba11c..66f2c754b 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/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.") diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index f13cb5300..0ee1ed559 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/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, diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py index faefcb0ed..7bbf3612c 100644 --- a/api/core/app/apps/workflow/app_runner.py +++ b/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 diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py index 9837cf997..14441ada4 100644 --- a/api/core/app/apps/workflow/generate_task_pipeline.py +++ b/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.") diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 6d3b8a996..4e0996f01 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/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]: """ diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py index 57beeaacc..56e6b46a6 100644 --- a/api/core/app/entities/app_invoke_entities.py +++ b/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 diff --git a/api/core/app/entities/queue_entities.py b/api/core/app/entities/queue_entities.py index f1cc3ac22..6dc67c5ee 100644 --- a/api/core/app/entities/queue_entities.py +++ b/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 diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index e64bd416e..0708986a6 100644 --- a/api/core/app/entities/task_entities.py +++ b/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 diff --git a/api/core/app/task_pipeline/workflow_cycle_manage.py b/api/core/app/task_pipeline/workflow_cycle_manage.py index 4710bc322..0dc2daa3b 100644 --- a/api/core/app/task_pipeline/workflow_cycle_manage.py +++ b/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 diff --git a/api/core/workflow/callbacks/workflow_logging_callback.py b/api/core/workflow/callbacks/workflow_logging_callback.py index b9c6b35ad..e6813a399 100644 --- a/api/core/workflow/callbacks/workflow_logging_callback.py +++ b/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 diff --git a/api/core/workflow/entities/node_entities.py b/api/core/workflow/entities/node_entities.py index 27c0e6702..70d40d87e 100644 --- a/api/core/workflow/entities/node_entities.py +++ b/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 diff --git a/api/core/workflow/entities/workflow_entities.py b/api/core/workflow/entities/workflow_entities.py index da56af140..8896416f1 100644 --- a/api/core/workflow/entities/workflow_entities.py +++ b/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 diff --git a/api/core/workflow/graph_engine/entities/event.py b/api/core/workflow/graph_engine/entities/event.py index 3130bb25d..caadb79cb 100644 --- a/api/core/workflow/graph_engine/entities/event.py +++ b/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 diff --git a/api/core/workflow/graph_engine/graph_engine.py b/api/core/workflow/graph_engine/graph_engine.py index 937f4a2a5..f19aa5a17 100644 --- a/api/core/workflow/graph_engine/graph_engine.py +++ b/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: diff --git a/api/core/workflow/nodes/answer/answer_stream_generate_router.py b/api/core/workflow/nodes/answer/answer_stream_generate_router.py index 7d652d39f..1d9c3e9b9 100644 --- a/api/core/workflow/nodes/answer/answer_stream_generate_router.py +++ b/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 diff --git a/api/core/workflow/nodes/answer/answer_stream_processor.py b/api/core/workflow/nodes/answer/answer_stream_processor.py index 40213bd15..4617d478d 100644 --- a/api/core/workflow/nodes/answer/answer_stream_processor.py +++ b/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 diff --git a/api/core/workflow/nodes/base/__init__.py b/api/core/workflow/nodes/base/__init__.py index 72d6392d4..0ebb0949a 100644 --- a/api/core/workflow/nodes/base/__init__.py +++ b/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", +] diff --git a/api/core/workflow/nodes/base/entities.py b/api/core/workflow/nodes/base/entities.py index 6bf8899f5..d853eb71b 100644 --- a/api/core/workflow/nodes/base/entities.py +++ b/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 diff --git a/api/core/workflow/nodes/end/end_stream_processor.py b/api/core/workflow/nodes/end/end_stream_processor.py index a770eb951..3ae5af713 100644 --- a/api/core/workflow/nodes/end/end_stream_processor.py +++ b/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 diff --git a/api/core/workflow/nodes/enums.py b/api/core/workflow/nodes/enums.py index 25e049577..d9a2c2d8a 100644 --- a/api/core/workflow/nodes/enums.py +++ b/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" diff --git a/api/core/workflow/nodes/loop/__init__.py b/api/core/workflow/nodes/loop/__init__.py index e69de29bb..9dd33be0a 100644 --- a/api/core/workflow/nodes/loop/__init__.py +++ b/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"] diff --git a/api/core/workflow/nodes/loop/entities.py b/api/core/workflow/nodes/loop/entities.py index b7cd7a948..4f9c149bd 100644 --- a/api/core/workflow/nodes/loop/entities.py +++ b/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 + +from core.workflow.nodes.base import BaseLoopNodeData, BaseLoopState, BaseNodeData +from core.workflow.utils.condition.entities import Condition -class LoopNodeData(BaseIterationNodeData): +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 diff --git a/api/core/workflow/nodes/loop/loop_node.py b/api/core/workflow/nodes/loop/loop_node.py index a366c287c..5e1f18b63 100644 --- a/api/core/workflow/nodes/loop/loop_node.py +++ b/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 [] + variable_mapping = {} - # TODO waiting for implementation - return [ - Condition( # type: ignore - variable_selector=[node_id, "index"], - comparison_operator="≤", - value_type="value_selector", - value_selector=[], - ) - ] + # 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 diff --git a/api/core/workflow/nodes/loop/loop_start_node.py b/api/core/workflow/nodes/loop/loop_start_node.py new file mode 100644 index 000000000..7fd06273f --- /dev/null +++ b/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) diff --git a/api/core/workflow/nodes/node_mapping.py b/api/core/workflow/nodes/node_mapping.py index 6341b9455..63cd28976 100644 --- a/api/core/workflow/nodes/node_mapping.py +++ b/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, diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 39b4afa25..cbea47f0b 100644 --- a/api/services/app_generate_service.py +++ b/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, diff --git a/docker/.env.example b/docker/.env.example index 438af9fc5..ff838e501 100644 --- a/docker/.env.example +++ b/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 # ------------------------------ diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 64df89944..758c1f802 100644 --- a/docker/docker-compose-template.yaml +++ b/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: diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index b62152682..7c21282cf 100644 --- a/docker/docker-compose.yaml +++ b/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: diff --git a/web/.env.example b/web/.env.example index 800dbb633..6738bfce0 100644 --- a/web/.env.example +++ b/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 diff --git a/web/app/components/base/chat/chat/answer/__mocks__/workflowProcess.ts b/web/app/components/base/chat/chat/answer/__mocks__/workflowProcess.ts index 0c5fd3946..b6bd9a6be 100644 --- a/web/app/components/base/chat/chat/answer/__mocks__/workflowProcess.ts +++ b/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: {}, diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index 59d2bcbbe..473dc42a0 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/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 }, [ diff --git a/web/app/components/base/icons/src/vender/workflow/Loop.json b/web/app/components/base/icons/src/vender/workflow/Loop.json new file mode 100644 index 000000000..5d1e6f8dc --- /dev/null +++ b/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" +} diff --git a/web/app/components/base/icons/src/vender/workflow/Loop.tsx b/web/app/components/base/icons/src/vender/workflow/Loop.tsx new file mode 100644 index 000000000..d25a16377 --- /dev/null +++ b/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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'Loop' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/LoopStart.json b/web/app/components/base/icons/src/vender/workflow/LoopStart.json new file mode 100644 index 000000000..362d54b3d --- /dev/null +++ b/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" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/workflow/LoopStart.tsx b/web/app/components/base/icons/src/vender/workflow/LoopStart.tsx new file mode 100644 index 000000000..0c93cfe8b --- /dev/null +++ b/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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'LoopStart' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/index.ts b/web/app/components/base/icons/src/vender/workflow/index.ts index 11ce55b13..f63d3158e 100644 --- a/web/app/components/base/icons/src/vender/workflow/index.ts +++ b/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' diff --git a/web/app/components/share/text-generation/result/index.tsx b/web/app/components/share/text-generation/result/index.tsx index b038f6a2d..49f972fb9 100644 --- a/web/app/components/share/text-generation/result/index.tsx +++ b/web/app/components/share/text-generation/result/index.tsx @@ -240,10 +240,42 @@ const Result: FC = ({ } 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 = ({ 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)) diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index 7f7aeca09..2c8efca73 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/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]: , [BlockEnum.IterationStart]: , [BlockEnum.Iteration]: , + [BlockEnum.LoopStart]: , + [BlockEnum.Loop]: , [BlockEnum.ParameterExtractor]: , [BlockEnum.DocExtractor]: , [BlockEnum.ListFilter]: , @@ -64,6 +67,7 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record = { [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', diff --git a/web/app/components/workflow/block-selector/constants.tsx b/web/app/components/workflow/block-selector/constants.tsx index 798e7ae3c..7e8a7f7a3 100644 --- a/web/app/components/workflow/block-selector/constants.tsx +++ b/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, diff --git a/web/app/components/workflow/candidate-node.tsx b/web/app/components/workflow/candidate-node.tsx index 16d6f852b..eb59a4618 100644 --- a/web/app/components/workflow/candidate-node.tsx +++ b/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) diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 56368d5a7..79dd541b7 100644 --- a/web/app/components/workflow/constants.ts +++ b/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 = { 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, ] diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index ce9554905..4467b0adb 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/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', diff --git a/web/app/components/workflow/hooks/use-helpline.ts b/web/app/components/workflow/hooks/use-helpline.ts index e9dc08c70..2eed71a80 100644 --- a/web/app/components/workflow/hooks/use-helpline.ts +++ b/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) diff --git a/web/app/components/workflow/hooks/use-nodes-data.ts b/web/app/components/workflow/hooks/use-nodes-data.ts index 3017f5005..c68ce92e3 100644 --- a/web/app/components/workflow/hooks/use-nodes-data.ts +++ b/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]) } diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index b445cdfdc..6dc7f7e94 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/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((_, 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((_, 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) => { diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/index.ts b/web/app/components/workflow/hooks/use-workflow-run-event/index.ts index 70528f7e7..67bc6c15e 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/index.ts +++ b/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' diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts new file mode 100644 index 000000000..38064e365 --- /dev/null +++ b/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, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts new file mode 100644 index 000000000..d3c5164dc --- /dev/null +++ b/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, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts new file mode 100644 index 000000000..533154bc3 --- /dev/null +++ b/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, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts index 8ba622081..64883076c 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts +++ b/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, diff --git a/web/app/components/workflow/hooks/use-workflow-run.ts b/web/app/components/workflow/hooks/use-workflow-run.ts index 8ed5583c5..bf6297666 100644 --- a/web/app/components/workflow/hooks/use-workflow-run.ts +++ b/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 diff --git a/web/app/components/workflow/hooks/use-workflow-variables.ts b/web/app/components/workflow/hooks/use-workflow-variables.ts index feadaf865..a2863671e 100644 --- a/web/app/components/workflow/hooks/use-workflow-variables.ts +++ b/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, diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 7920d82eb..85cde98ac 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/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, + } +} diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 25a449f00..3df424f17 100644 --- a/web/app/components/workflow/index.tsx +++ b/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 = 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} diff --git a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx index 54ab4b327..a5adff398 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx +++ b/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((type, toolDefaultValue) => { diff --git a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx index ad6c7abd0..9e3873c71 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx +++ b/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((type, toolDefaultValue) => { handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue) diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx index 65798e461..3cf7cc7e0 100644 --- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx +++ b/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() diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx index debeb9a7c..173f1ada6 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx +++ b/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) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx index 65823c43e..986a81124 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx +++ b/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) diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 2e2fc4cb2..969bdf2dd 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/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 diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index c7ef4598f..cffec7ebc 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -114,6 +114,9 @@ const VarReferencePicker: FC = ({ 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(null) const [triggerWidth, setTriggerWidth] = useState(TRIGGER_DEFAULT_WIDTH) useEffect(() => { @@ -142,6 +145,14 @@ const VarReferencePicker: FC = ({ 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 = ({ 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 = ({ }, [onChange, varKindType]) const type = getCurrentVariableType({ - parentNode: iterationNode, + parentNode: isInIteration ? iterationNode : loopNode, valueSelector: value as ValueSelector, availableNodes, isChatMode, diff --git a/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts b/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts index bd17bb1de..3e13dcb78 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts +++ b/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, diff --git a/web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts b/web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts index 2ecdf101d..866c78451 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts +++ b/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]) diff --git a/web/app/components/workflow/nodes/_base/hooks/use-node-info.ts b/web/app/components/workflow/nodes/_base/hooks/use-node-info.ts index f8f076dfd..a66e0f19b 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-node-info.ts +++ b/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, } } diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index c816ee28d..7d8b7fe08 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/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 = { @@ -61,6 +63,7 @@ const checkValidFns: Record = { [BlockEnum.ParameterExtractor]: checkParameterExtractorValid, [BlockEnum.Iteration]: checkIterationValid, [BlockEnum.DocExtractor]: checkDocumentExtractorValid, + [BlockEnum.Loop]: checkLoopValid, } as any type Params = { @@ -69,6 +72,7 @@ type Params = { defaultRunInputData: Record moreDataForCheckValid?: any iteratorInputKey?: string + loopInputKey?: string } const varTypeToInputVarType = (type: VarType, { @@ -100,12 +104,14 @@ const useOneStepRun = ({ defaultRunInputData, moreDataForCheckValid, iteratorInputKey, + loopInputKey, }: Params) => { 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 = ({ setRunInputData(data) }, []) const iterationTimes = iteratorInputKey ? runInputData[iteratorInputKey].length : 0 + const loopTimes = loopInputKey ? runInputData[loopInputKey].length : 0 const [runResult, setRunResult] = useState(null) const { handleNodeDataUpdate }: { handleNodeDataUpdate: (data: any) => void } = useNodeDataUpdate() const [canShowSingleRun, setCanShowSingleRun] = useState(false) const isShowSingleRun = data._isSingleRun && canShowSingleRun const [iterationRunResult, setIterationRunResult] = useState([]) + const [loopRunResult, setLoopRunResult] = useState([]) useEffect(() => { if (!checkValid) { @@ -175,7 +183,7 @@ const useOneStepRun = ({ }) } } - // 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 = ({ }) 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 = ({ }, ) } - 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 = ({ } } finally { - if (!isIteration) { + if (!isIteration && !isLoop) { setRunResult({ ...res, total_tokens: res.execution_metadata?.total_tokens || 0, @@ -339,7 +447,7 @@ const useOneStepRun = ({ }) } } - if (!isIteration) { + if (!isIteration && !isLoop) { handleNodeDataUpdate({ id, data: { @@ -430,6 +538,7 @@ const useOneStepRun = ({ setRunInputData: handleSetRunInputData, runResult, iterationRunResult, + loopRunResult, } } diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index e13a7a411..cccd9be9e 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/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 = ({ const nodeRef = useRef(null) const { nodesReadOnly } = useNodesReadOnly() const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions() + const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions() const toolIcon = useToolIcon(data) useEffect(() => { @@ -73,6 +75,20 @@ const BaseNode: FC = ({ } }, [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 = ({ )} 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', }} >
= ({ /> ) } + { + data.type === BlockEnum.Loop && ( + + ) + } { !data._isCandidate && ( = ({ }
= ({
) } + { + data._loopLength && data._loopIndex && data._runningStatus === NodeRunningStatus.Running && ( +
+ {data._loopIndex > data._loopLength ? data._loopLength : data._loopIndex}/{data._loopLength} +
+ ) + } { (data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running) && ( @@ -230,12 +261,12 @@ const BaseNode: FC = ({ }
{ - 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) && (
{cloneElement(children, { id, data })}
@@ -258,7 +289,7 @@ const BaseNode: FC = ({ ) } { - data.desc && data.type !== BlockEnum.Iteration && ( + data.desc && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
{data.desc}
diff --git a/web/app/components/workflow/nodes/_base/panel.tsx b/web/app/components/workflow/nodes/_base/panel.tsx index b82820328..4c839b0b7 100644 --- a/web/app/components/workflow/nodes/_base/panel.tsx +++ b/web/app/components/workflow/nodes/_base/panel.tsx @@ -61,14 +61,14 @@ const BasePanel: FC = ({ 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) => { diff --git a/web/app/components/workflow/nodes/assigner/use-config.ts b/web/app/components/workflow/nodes/assigner/use-config.ts index fc41ac16c..ad7d066ef 100644 --- a/web/app/components/workflow/nodes/assigner/use-config.ts +++ b/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) => { diff --git a/web/app/components/workflow/nodes/constants.ts b/web/app/components/workflow/nodes/constants.ts index d765b89f8..239873121 100644 --- a/web/app/components/workflow/nodes/constants.ts +++ b/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> = { [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> = { [BlockEnum.Assigner]: AssignerPanel, [BlockEnum.ParameterExtractor]: ParameterExtractorPanel, [BlockEnum.Iteration]: IterationPanel, + [BlockEnum.Loop]: LoopPanel, [BlockEnum.DocExtractor]: DocExtractorPanel, [BlockEnum.ListFilter]: ListFilterPanel, [BlockEnum.Agent]: AgentPanel, diff --git a/web/app/components/workflow/nodes/document-extractor/use-config.ts b/web/app/components/workflow/nodes/document-extractor/use-config.ts index 95ac9fe7a..8ceb15387 100644 --- a/web/app/components/workflow/nodes/document-extractor/use-config.ts +++ b/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) => { diff --git a/web/app/components/workflow/nodes/if-else/types.ts b/web/app/components/workflow/nodes/if-else/types.ts index 22238b338..0559b949b 100644 --- a/web/app/components/workflow/nodes/if-else/types.ts +++ b/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 diff --git a/web/app/components/workflow/nodes/if-else/use-config.ts b/web/app/components/workflow/nodes/if-else/use-config.ts index 41e41f6b8..827eb499f 100644 --- a/web/app/components/workflow/nodes/if-else/use-config.ts +++ b/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(() => { diff --git a/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts b/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts index 81552dbef..c0cf8cfef 100644 --- a/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts +++ b/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, diff --git a/web/app/components/workflow/nodes/list-operator/use-config.ts b/web/app/components/workflow/nodes/list-operator/use-config.ts index 00defe7a8..efbf32b8c 100644 --- a/web/app/components/workflow/nodes/list-operator/use-config.ts +++ b/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() diff --git a/web/app/components/workflow/nodes/loop-start/constants.ts b/web/app/components/workflow/nodes/loop-start/constants.ts new file mode 100644 index 000000000..3185b8421 --- /dev/null +++ b/web/app/components/workflow/nodes/loop-start/constants.ts @@ -0,0 +1 @@ +export const CUSTOM_LOOP_START_NODE = 'custom-loop-start' diff --git a/web/app/components/workflow/nodes/loop-start/default.ts b/web/app/components/workflow/nodes/loop-start/default.ts new file mode 100644 index 000000000..685f22783 --- /dev/null +++ b/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 = { + 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 diff --git a/web/app/components/workflow/nodes/loop-start/index.tsx b/web/app/components/workflow/nodes/loop-start/index.tsx new file mode 100644 index 000000000..a43ec36ff --- /dev/null +++ b/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 ( +
+ +
+ +
+
+ +
+ ) +} + +export const LoopStartNodeDumb = () => { + const { t } = useTranslation() + + return ( +
+ +
+ +
+
+
+ ) +} + +export default memo(LoopStartNode) diff --git a/web/app/components/workflow/nodes/loop-start/types.ts b/web/app/components/workflow/nodes/loop-start/types.ts new file mode 100644 index 000000000..1ba713692 --- /dev/null +++ b/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 diff --git a/web/app/components/workflow/nodes/loop/add-block.tsx b/web/app/components/workflow/nodes/loop/add-block.tsx new file mode 100644 index 000000000..0f1e58c6d --- /dev/null +++ b/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((type, toolDefaultValue) => { + handleNodeAdd( + { + nodeType: type, + toolDefaultValue, + }, + { + prevNodeId: loopNodeData.start_node_id, + prevNodeSourceHandle: 'source', + }, + ) + }, [handleNodeAdd, loopNodeData.start_node_id]) + + const renderTriggerElement = useCallback((open: boolean) => { + return ( +
+ + {t('workflow.common.addBlock')} +
+ ) + }, [nodesReadOnly, t]) + + return ( +
+
+
+
+ +
+ ) +} + +export default memo(AddBlock) diff --git a/web/app/components/workflow/nodes/loop/components/condition-add.tsx b/web/app/components/workflow/nodes/loop/components/condition-add.tsx new file mode 100644 index 000000000..dcba4c974 --- /dev/null +++ b/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 ( + + setOpen(!open)}> + + + +
+ +
+
+
+ ) +} + +export default ConditionAdd diff --git a/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx b/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx new file mode 100644 index 000000000..1cf34e5ab --- /dev/null +++ b/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 ( +
+
+ {!isEnvVar && !isChatVar && } + {isEnvVar && } + {isChatVar && } + +
+ {variableName} +
+
+ {operatorName} +
+
+
+ { + sub_variable_condition?.conditions.map((c: Condition, index) => ( +
+
{c.key}
+
{isComparisonOperatorNeedTranslate(c.comparison_operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${c.comparison_operator}`) : c.comparison_operator}
+ {c.comparison_operator && !isEmptyRelatedOperator(c.comparison_operator) &&
{isSelect(c) ? selectName(c) : formatValue(c)}
} + {index !== sub_variable_condition.conditions.length - 1 && (
{t(`${i18nPrefix}.${sub_variable_condition.logical_operator}`)}
)} +
+ )) + } +
+
+ ) +} + +export default memo(ConditionValue) diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx new file mode 100644 index 000000000..5c02f140d --- /dev/null +++ b/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 ( + { + 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 diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx new file mode 100644 index 000000000..250bde824 --- /dev/null +++ b/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 ( +
+
+
+
+ {isSubVarSelect + ? ( + handleUpdateConditionValue(item.value as string)} + hideChecked + notClearable + /> +
+ ) + } + { + !comparisonOperatorNotRequireValue(condition.comparison_operator) && isSubVariable && ( +
+ +
+ ) + } +
+
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={doRemoveCondition} + > + +
+
+ ) +} + +export default ConditionItem diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx new file mode 100644 index 000000000..ecbe53f68 --- /dev/null +++ b/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 ( + + setOpen(v => !v)}> + + + +
+ { + options.map(option => ( +
{ + onSelect(option.value) + setOpen(false) + }} + > + {option.label} +
+ )) + } +
+
+
+ ) +} + +export default ConditionOperator diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx new file mode 100644 index 000000000..68a012d1a --- /dev/null +++ b/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 ( + + onOpenChange(!open)}> +
+ +
+
+ +
+ +
+
+
+ ) +} + +export default ConditionVarSelector diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/index.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/index.tsx new file mode 100644 index 000000000..2f9db259d --- /dev/null +++ b/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 ( +
1 && !isSubVariable && 'pl-[60px]')}> + { + conditions.length > 1 && ( +
+
+
+
doToggleConditionLogicalOperator(conditionId)} + > + {logicalOperator && logicalOperator.toUpperCase()} + +
+
+ ) + } + { + conditions.map(condition => ( + + )) + } +
+ ) +} + +export default ConditionList diff --git a/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx b/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx new file mode 100644 index 000000000..5dabd967c --- /dev/null +++ b/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 ( +
+ + setNumberVarTypeVisible(v => !v)}> + + + +
+ { + options.map(option => ( +
{ + onNumberVarTypeChange(option) + setNumberVarTypeVisible(false) + }} + > + {capitalize(option)} +
+ )) + } +
+
+
+
+
+ { + numberVarType === NumberVarType.variable && ( + + setVariableSelectorVisible(v => !v)}> + { + value && ( + + ) + } + { + !value && ( +
+ +
{t('workflow.nodes.ifElse.selectVariable')}
+
+ ) + } +
+ +
+ +
+
+
+ ) + } + { + numberVarType === NumberVarType.constant && ( +
+ onValueChange(e.target.value)} + placeholder={t('workflow.nodes.ifElse.enterValue') || ''} + onFocus={setFocus} + onBlur={setBlur} + /> + {!isFocus && unit &&
{unit}
} +
+ ) + } +
+
+ ) +} + +export default memo(ConditionNumberInput) diff --git a/web/app/components/workflow/nodes/loop/components/condition-value.tsx b/web/app/components/workflow/nodes/loop/components/condition-value.tsx new file mode 100644 index 000000000..c30b7c7cc --- /dev/null +++ b/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 ( +
+ {!isEnvVar && !isChatVar && } + {isEnvVar && } + {isChatVar && } + +
+ {variableName} +
+
+ {operatorName} +
+ { + !notHasValue && ( +
{isSelect ? selectName : formatValue}
+ ) + } +
+ ) +} + +export default memo(ConditionValue) diff --git a/web/app/components/workflow/nodes/loop/components/condition-wrap.tsx b/web/app/components/workflow/nodes/loop/components/condition-wrap.tsx new file mode 100644 index 000000000..4f3a99765 --- /dev/null +++ b/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 = ({ + 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
+ + return ( + <> +
+
+ { + conditions && !!conditions.length && ( +
+ +
+ ) + } + +
1 && !isSubVariable && 'ml-[60px]', + )}> + {isSubVariable + ? ( + + + +
*/} + {isShowSingleRun && ( + + } + /> + )} +
+ ) +} + +export default React.memo(Panel) diff --git a/web/app/components/workflow/nodes/loop/types.ts b/web/app/components/workflow/nodes/loop/types.ts new file mode 100644 index 000000000..b91c24cc2 --- /dev/null +++ b/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 +} diff --git a/web/app/components/workflow/nodes/loop/use-config.ts b/web/app/components/workflow/nodes/loop/use-config.ts new file mode 100644 index 000000000..91eb81c46 --- /dev/null +++ b/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(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({ + 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 = {} + const allVarObject: Record = {} + 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) => { + const formattedData: Record = {} + 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 = {} + Object.keys(runInputData) + .filter(key => ![loopInputKey].includes(key)) + .forEach((key) => { + vars[key] = runInputData[key] + }) + return vars + })() + + const setInputVarValues = useCallback((newPayload: Record) => { + 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((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((conditionId) => { + const newInputs = produce(inputs, (draft) => { + draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId) + }) + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleUpdateCondition = useCallback((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(() => { + const newInputs = produce(inputs, (draft) => { + draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and + }) + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleAddSubVariableCondition = useCallback((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((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((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 diff --git a/web/app/components/workflow/nodes/loop/use-interactions.ts b/web/app/components/workflow/nodes/loop/use-interactions.ts new file mode 100644 index 000000000..5c01c75bf --- /dev/null +++ b/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, + } +} diff --git a/web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts b/web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts new file mode 100644 index 000000000..b354d3149 --- /dev/null +++ b/web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts @@ -0,0 +1,35 @@ +import { useMemo } from 'react' +import { useIsChatMode, useWorkflow, useWorkflowVariables } from '../../hooks' +import type { ValueSelector } from '../../types' +import { VarType } from '../../types' + +type Params = { + nodeId: string +} +const useIsVarFileAttribute = ({ + nodeId, +}: Params) => { + const isChatMode = useIsChatMode() + const { getBeforeNodesInSameBranch } = useWorkflow() + const availableNodes = useMemo(() => { + return getBeforeNodesInSameBranch(nodeId) + }, [getBeforeNodesInSameBranch, nodeId]) + const { getCurrentVariableType } = useWorkflowVariables() + const getIsVarFileAttribute = (variable: ValueSelector) => { + if (variable.length !== 3) + return false + const parentVariable = variable.slice(0, 2) + const varType = getCurrentVariableType({ + valueSelector: parentVariable, + availableNodes, + isChatMode, + isConstant: false, + }) + return varType === VarType.file + } + return { + getIsVarFileAttribute, + } +} + +export default useIsVarFileAttribute diff --git a/web/app/components/workflow/nodes/loop/utils.ts b/web/app/components/workflow/nodes/loop/utils.ts new file mode 100644 index 000000000..2bc9d8926 --- /dev/null +++ b/web/app/components/workflow/nodes/loop/utils.ts @@ -0,0 +1,179 @@ +import { ComparisonOperator } from './types' +import { VarType } from '@/app/components/workflow/types' +import type { Branch } from '@/app/components/workflow/types' + +export const isEmptyRelatedOperator = (operator: ComparisonOperator) => { + return [ComparisonOperator.empty, ComparisonOperator.notEmpty, ComparisonOperator.isNull, ComparisonOperator.isNotNull, ComparisonOperator.exists, ComparisonOperator.notExists].includes(operator) +} + +const notTranslateKey = [ + ComparisonOperator.equal, ComparisonOperator.notEqual, + ComparisonOperator.largerThan, ComparisonOperator.largerThanOrEqual, + ComparisonOperator.lessThan, ComparisonOperator.lessThanOrEqual, +] + +export const isComparisonOperatorNeedTranslate = (operator?: ComparisonOperator) => { + if (!operator) + return false + return !notTranslateKey.includes(operator) +} + +export const getOperators = (type?: VarType, file?: { key: string }) => { + const isFile = !!file + if (isFile) { + const { key } = file + + switch (key) { + case 'name': + return [ + ComparisonOperator.contains, + ComparisonOperator.notContains, + ComparisonOperator.startWith, + ComparisonOperator.endWith, + ComparisonOperator.is, + ComparisonOperator.isNot, + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ] + case 'type': + return [ + ComparisonOperator.in, + ComparisonOperator.notIn, + ] + case 'size': + return [ + ComparisonOperator.largerThan, + ComparisonOperator.largerThanOrEqual, + ComparisonOperator.lessThan, + ComparisonOperator.lessThanOrEqual, + ] + case 'extension': + return [ + ComparisonOperator.is, + ComparisonOperator.isNot, + ComparisonOperator.contains, + ComparisonOperator.notContains, + ] + case 'mime_type': + return [ + ComparisonOperator.contains, + ComparisonOperator.notContains, + ComparisonOperator.startWith, + ComparisonOperator.endWith, + ComparisonOperator.is, + ComparisonOperator.isNot, + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ] + case 'transfer_method': + return [ + ComparisonOperator.in, + ComparisonOperator.notIn, + ] + case 'url': + return [ + ComparisonOperator.contains, + ComparisonOperator.notContains, + ComparisonOperator.startWith, + ComparisonOperator.endWith, + ComparisonOperator.is, + ComparisonOperator.isNot, + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ] + } + return [] + } + switch (type) { + case VarType.string: + return [ + ComparisonOperator.contains, + ComparisonOperator.notContains, + ComparisonOperator.startWith, + ComparisonOperator.endWith, + ComparisonOperator.is, + ComparisonOperator.isNot, + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ] + case VarType.number: + return [ + ComparisonOperator.equal, + ComparisonOperator.notEqual, + ComparisonOperator.largerThan, + ComparisonOperator.lessThan, + ComparisonOperator.largerThanOrEqual, + ComparisonOperator.lessThanOrEqual, + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ] + case VarType.object: + return [ + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ] + case VarType.file: + return [ + ComparisonOperator.exists, + ComparisonOperator.notExists, + ] + case VarType.arrayString: + case VarType.arrayNumber: + return [ + ComparisonOperator.contains, + ComparisonOperator.notContains, + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ] + case VarType.array: + case VarType.arrayObject: + return [ + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ] + case VarType.arrayFile: + return [ + ComparisonOperator.contains, + ComparisonOperator.notContains, + ComparisonOperator.allOf, + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ] + default: + return [ + ComparisonOperator.is, + ComparisonOperator.isNot, + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ] + } +} + +export const comparisonOperatorNotRequireValue = (operator?: ComparisonOperator) => { + if (!operator) + return false + + return [ComparisonOperator.empty, ComparisonOperator.notEmpty, ComparisonOperator.isNull, ComparisonOperator.isNotNull, ComparisonOperator.exists, ComparisonOperator.notExists].includes(operator) +} + +export const branchNameCorrect = (branches: Branch[]) => { + const branchLength = branches.length + if (branchLength < 2) + throw new Error('if-else node branch number must than 2') + + if (branchLength === 2) { + return branches.map((branch) => { + return { + ...branch, + name: branch.id === 'false' ? 'ELSE' : 'IF', + } + }) + } + + return branches.map((branch, index) => { + return { + ...branch, + name: branch.id === 'false' ? 'ELSE' : `CASE ${index + 1}`, + } + }) +} diff --git a/web/app/components/workflow/panel/debug-and-preview/hooks.ts b/web/app/components/workflow/panel/debug-and-preview/hooks.ts index 9079f9e53..3664edb6c 100644 --- a/web/app/components/workflow/panel/debug-and-preview/hooks.ts +++ b/web/app/components/workflow/panel/debug-and-preview/hooks.ts @@ -11,7 +11,7 @@ import { uniqBy } from 'lodash-es' import { useWorkflowRun } from '../../hooks' import { NodeRunningStatus, WorkflowRunningStatus } from '../../types' import { useWorkflowStore } from '../../store' -import { DEFAULT_ITER_TIMES } from '../../constants' +import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../../constants' import type { ChatItem, ChatItemInTree, @@ -57,6 +57,7 @@ export const useChat = ( const suggestedQuestionsAbortControllerRef = useRef(null) const { setIterTimes, + setLoopTimes, } = workflowStore.getState() const handleResponding = useCallback((isResponding: boolean) => { @@ -128,20 +129,23 @@ export const useChat = ( if (stopChat && taskIdRef.current) stopChat(taskIdRef.current) setIterTimes(DEFAULT_ITER_TIMES) + setLoopTimes(DEFAULT_LOOP_TIMES) if (suggestedQuestionsAbortControllerRef.current) suggestedQuestionsAbortControllerRef.current.abort() - }, [handleResponding, setIterTimes, stopChat]) + }, [handleResponding, setIterTimes, setLoopTimes, stopChat]) const handleRestart = useCallback(() => { conversationId.current = '' taskIdRef.current = '' handleStop() setIterTimes(DEFAULT_ITER_TIMES) + setLoopTimes(DEFAULT_LOOP_TIMES) setChatTree([]) setSuggestQuestions([]) }, [ handleStop, setIterTimes, + setLoopTimes, ]) const updateCurrentQAOnTree = useCallback(({ @@ -381,8 +385,35 @@ export const useChat = ( }) } }, + onLoopStart: ({ data }) => { + responseItem.workflowProcess!.tracing!.push({ + ...data, + status: NodeRunningStatus.Running, + }) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: params.parent_message_id, + }) + }, + onLoopFinish: ({ data }) => { + const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id) + if (currentTracingIndex > -1) { + responseItem.workflowProcess!.tracing[currentTracingIndex] = { + ...responseItem.workflowProcess!.tracing[currentTracingIndex], + ...data, + } + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: params.parent_message_id, + }) + } + }, onNodeStarted: ({ data }) => { - if (data.iteration_id) + if (data.iteration_id || data.loop_id) return responseItem.workflowProcess!.tracing!.push({ @@ -397,7 +428,7 @@ export const useChat = ( }) }, onNodeRetry: ({ data }) => { - if (data.iteration_id) + if (data.iteration_id || data.loop_id) return responseItem.workflowProcess!.tracing!.push(data) @@ -410,7 +441,7 @@ export const useChat = ( }) }, onNodeFinished: ({ data }) => { - if (data.iteration_id) + if (data.iteration_id || data.loop_id) return const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id) diff --git a/web/app/components/workflow/run/hooks.ts b/web/app/components/workflow/run/hooks.ts index 55ddc4cbf..8a74688a2 100644 --- a/web/app/components/workflow/run/hooks.ts +++ b/web/app/components/workflow/run/hooks.ts @@ -7,6 +7,7 @@ import { useBoolean } from 'ahooks' import type { AgentLogItemWithChildren, IterationDurationMap, + LoopDurationMap, NodeTracing, } from '@/types/workflow' @@ -33,6 +34,18 @@ export const useLogs = () => { setIterationResultDurationMap(iterDurationMap) }, [setShowIteratingDetailTrue, setIterationResultList, setIterationResultDurationMap]) + const [showLoopingDetail, { + setTrue: setShowLoopingDetailTrue, + setFalse: setShowLoopingDetailFalse, + }] = useBoolean(false) + const [loopResultList, setLoopResultList] = useState([]) + const [loopResultDurationMap, setLoopResultDurationMap] = useState({}) + const handleShowLoopResultList = useCallback((detail: NodeTracing[][], loopDurationMap: LoopDurationMap) => { + setShowLoopingDetailTrue() + setLoopResultList(detail) + setLoopResultDurationMap(loopDurationMap) + }, [setShowLoopingDetailTrue, setLoopResultList, setLoopResultDurationMap]) + const [agentOrToolLogItemStack, setAgentOrToolLogItemStack] = useState([]) const agentOrToolLogItemStackRef = useRef(agentOrToolLogItemStack) const [agentOrToolLogListMap, setAgentOrToolLogListMap] = useState>({}) @@ -64,7 +77,7 @@ export const useLogs = () => { }, [setAgentOrToolLogItemStack, setAgentOrToolLogListMap]) return { - showSpecialResultPanel: showRetryDetail || showIteratingDetail || !!agentOrToolLogItemStack.length, + showSpecialResultPanel: showRetryDetail || showIteratingDetail || showLoopingDetail || !!agentOrToolLogItemStack.length, showRetryDetail, setShowRetryDetailTrue, setShowRetryDetailFalse, @@ -81,6 +94,15 @@ export const useLogs = () => { setIterationResultDurationMap, handleShowIterationResultList, + showLoopingDetail, + setShowLoopingDetailTrue, + setShowLoopingDetailFalse, + loopResultList, + setLoopResultList, + loopResultDurationMap, + setLoopResultDurationMap, + handleShowLoopResultList, + agentOrToolLogItemStack, agentOrToolLogListMap, handleShowAgentOrToolLog, diff --git a/web/app/components/workflow/run/loop-log/index.tsx b/web/app/components/workflow/run/loop-log/index.tsx new file mode 100644 index 000000000..b80a24d52 --- /dev/null +++ b/web/app/components/workflow/run/loop-log/index.tsx @@ -0,0 +1,2 @@ +export { default as LoopLogTrigger } from './loop-log-trigger' +export { default as LoopResultPanel } from './loop-result-panel' diff --git a/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx b/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx new file mode 100644 index 000000000..378f09119 --- /dev/null +++ b/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from 'react-i18next' +import { RiArrowRightSLine } from '@remixicon/react' +import Button from '@/app/components/base/button' +import type { + LoopDurationMap, + NodeTracing, +} from '@/types/workflow' +import { Loop } from '@/app/components/base/icons/src/vender/workflow' + +type LoopLogTriggerProps = { + nodeInfo: NodeTracing + onShowLoopResultList: (loopResultList: NodeTracing[][], loopResultDurationMap: LoopDurationMap) => void +} +const LoopLogTrigger = ({ + nodeInfo, + onShowLoopResultList, +}: LoopLogTriggerProps) => { + const { t } = useTranslation() + const getErrorCount = (details: NodeTracing[][] | undefined) => { + if (!details || details.length === 0) + return 0 + + return details.reduce((acc, loop) => { + if (loop.some(item => item.status === 'failed')) + acc++ + return acc + }, 0) + } + const getCount = (loop_curr_length: number | undefined, loop_length: number) => { + if ((loop_curr_length && loop_curr_length < loop_length) || !loop_length) + return loop_curr_length + + return loop_length + } + const handleOnShowLoopDetail = (e: React.MouseEvent) => { + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + onShowLoopResultList(nodeInfo.details || [], nodeInfo?.loopDurationMap || nodeInfo.execution_metadata?.loop_duration_map || {}) + } + return ( + + ) +} + +export default LoopLogTrigger diff --git a/web/app/components/workflow/run/loop-log/loop-result-panel.tsx b/web/app/components/workflow/run/loop-log/loop-result-panel.tsx new file mode 100644 index 000000000..cd02195a1 --- /dev/null +++ b/web/app/components/workflow/run/loop-log/loop-result-panel.tsx @@ -0,0 +1,128 @@ +'use client' +import type { FC } from 'react' +import React, { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + RiArrowLeftLine, + RiArrowRightSLine, + RiErrorWarningLine, + RiLoader2Line, +} from '@remixicon/react' +import { NodeRunningStatus } from '@/app/components/workflow/types' +import TracingPanel from '@/app/components/workflow/run/tracing-panel' +import { Loop } from '@/app/components/base/icons/src/vender/workflow' +import cn from '@/utils/classnames' +import type { LoopDurationMap, NodeTracing } from '@/types/workflow' +const i18nPrefix = 'workflow.singleRun' + +type Props = { + list: NodeTracing[][] + onBack: () => void + loopDurationMap?: LoopDurationMap +} + +const LoopResultPanel: FC = ({ + list, + onBack, + loopDurationMap, +}) => { + const { t } = useTranslation() + const [expandedLoops, setExpandedLoops] = useState>({}) + + const toggleLoop = useCallback((index: number) => { + setExpandedLoops(prev => ({ + ...prev, + [index]: !prev[index], + })) + }, []) + + const countLoopDuration = (loop: NodeTracing[], loopDurationMap: LoopDurationMap): string => { + const loopRunIndex = loop[0]?.execution_metadata?.loop_index as number + const loopRunId = loop[0]?.execution_metadata?.parallel_mode_run_id + const loopItem = loopDurationMap[loopRunId || loopRunIndex] + const duration = loopItem + return `${(duration && duration > 0.01) ? duration.toFixed(2) : 0.01}s` + } + + const loopStatusShow = (index: number, loop: NodeTracing[], loopDurationMap?: LoopDurationMap) => { + const hasFailed = loop.some(item => item.status === NodeRunningStatus.Failed) + const isRunning = loop.some(item => item.status === NodeRunningStatus.Running) + const hasDurationMap = loopDurationMap && Object.keys(loopDurationMap).length !== 0 + + if (hasFailed) + return + + if (isRunning) + return + + return ( + <> + {hasDurationMap && ( +
+ {countLoopDuration(loop, loopDurationMap)} +
+ )} + + + ) + } + + return ( +
+
{ + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + onBack() + }} + > + +
{t(`${i18nPrefix}.back`)}
+
+ {/* List */} +
+ {list.map((loop, index) => ( +
+
toggleLoop(index)} + > +
+
+ +
+ + {t(`${i18nPrefix}.loop`)} {index + 1} + + {loopStatusShow(index, loop, loopDurationMap)} +
+
+ {expandedLoops[index] &&
} +
+ +
+
+ ))} +
+
+ ) +} +export default React.memo(LoopResultPanel) diff --git a/web/app/components/workflow/run/loop-result-panel.tsx b/web/app/components/workflow/run/loop-result-panel.tsx new file mode 100644 index 000000000..3b0be7ba3 --- /dev/null +++ b/web/app/components/workflow/run/loop-result-panel.tsx @@ -0,0 +1,122 @@ +'use client' +import type { FC } from 'react' +import React, { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + RiArrowRightSLine, + RiCloseLine, +} from '@remixicon/react' +import { ArrowNarrowLeft } from '../../base/icons/src/vender/line/arrows' +import TracingPanel from './tracing-panel' +import { Loop } from '@/app/components/base/icons/src/vender/workflow' +import cn from '@/utils/classnames' +import type { NodeTracing } from '@/types/workflow' + +const i18nPrefix = 'workflow.singleRun' + +type Props = { + list: NodeTracing[][] + onHide: () => void + onBack: () => void + noWrap?: boolean +} + +const LoopResultPanel: FC = ({ + list, + onHide, + onBack, + noWrap, +}) => { + const { t } = useTranslation() + const [expandedLoops, setExpandedLoops] = useState>([]) + + const toggleLoop = useCallback((index: number) => { + setExpandedLoops(prev => ({ + ...prev, + [index]: !prev[index], + })) + }, []) + + const main = ( + <> +
+
+
+ {t(`${i18nPrefix}.testRunLoop`)} +
+
+ +
+
+
+ +
{t(`${i18nPrefix}.back`)}
+
+
+ {/* List */} +
+ {list.map((loop, index) => ( +
+
toggleLoop(index)} + > +
+
+ +
+ + {t(`${i18nPrefix}.loop`)} {index + 1} + + +
+
+ {expandedLoops[index] &&
} +
+ + +
+
+ ))} +
+ + ) + const handleNotBubble = useCallback((e: React.MouseEvent) => { + // if not do this, it will trigger the message log modal disappear(useClickAway) + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + }, []) + + if (noWrap) + return main + + return ( +
+
+ {main} +
+
+ ) +} +export default React.memo(LoopResultPanel) diff --git a/web/app/components/workflow/run/node.tsx b/web/app/components/workflow/run/node.tsx index c23fc3849..b0bf348db 100644 --- a/web/app/components/workflow/run/node.tsx +++ b/web/app/components/workflow/run/node.tsx @@ -13,6 +13,7 @@ import BlockIcon from '../block-icon' import { BlockEnum } from '../types' import { RetryLogTrigger } from './retry-log' import { IterationLogTrigger } from './iteration-log' +import { LoopLogTrigger } from './loop-log' import { AgentLogTrigger } from './agent-log' import cn from '@/utils/classnames' import StatusContainer from '@/app/components/workflow/run/status-container' @@ -21,6 +22,7 @@ import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import type { AgentLogItemWithChildren, IterationDurationMap, + LoopDurationMap, NodeTracing, } from '@/types/workflow' import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip' @@ -33,9 +35,11 @@ type Props = { hideInfo?: boolean hideProcessDetail?: boolean onShowIterationDetail?: (detail: NodeTracing[][], iterDurationMap: IterationDurationMap) => void + onShowLoopDetail?: (detail: NodeTracing[][], loopDurationMap: LoopDurationMap) => void onShowRetryDetail?: (detail: NodeTracing[]) => void onShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void notShowIterationNav?: boolean + notShowLoopNav?: boolean } const NodePanel: FC = ({ @@ -45,9 +49,11 @@ const NodePanel: FC = ({ hideInfo = false, hideProcessDetail, onShowIterationDetail, + onShowLoopDetail, onShowRetryDetail, onShowAgentOrToolLog, notShowIterationNav, + notShowLoopNav, }) => { const [collapseState, doSetCollapseState] = useState(true) const setCollapseState = useCallback((state: boolean) => { @@ -79,6 +85,7 @@ const NodePanel: FC = ({ }, [nodeInfo.expand, setCollapseState]) const isIterationNode = nodeInfo.node_type === BlockEnum.Iteration && !!nodeInfo.details?.length + const isLoopNode = nodeInfo.node_type === BlockEnum.Loop && !!nodeInfo.details?.length const isRetryNode = hasRetryNode(nodeInfo.node_type) && !!nodeInfo.retryDetail?.length const isAgentNode = nodeInfo.node_type === BlockEnum.Agent && !!nodeInfo.agentLog?.length const isToolNode = nodeInfo.node_type === BlockEnum.Tool && !!nodeInfo.agentLog?.length @@ -138,6 +145,13 @@ const NodePanel: FC = ({ onShowIterationResultList={onShowIterationDetail} /> )} + {/* The nav to the Loop detail */} + {isLoopNode && !notShowLoopNav && onShowLoopDetail && ( + + )} {isRetryNode && onShowRetryDetail && ( void + handleShowLoopResultList?: (detail: NodeTracing[][], loopDurationMap: any) => void onShowRetryDetail?: (detail: NodeTracing[]) => void handleShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void } @@ -53,11 +55,13 @@ const ResultPanel: FC = ({ exceptionCounts, execution_metadata, handleShowIterationResultList, + handleShowLoopResultList, onShowRetryDetail, handleShowAgentOrToolLog, }) => { const { t } = useTranslation() const isIterationNode = nodeInfo?.node_type === BlockEnum.Iteration && !!nodeInfo?.details?.length + const isLoopNode = nodeInfo?.node_type === BlockEnum.Loop && !!nodeInfo?.details?.length const isRetryNode = hasRetryNode(nodeInfo?.node_type) && !!nodeInfo?.retryDetail?.length const isAgentNode = nodeInfo?.node_type === BlockEnum.Agent && !!nodeInfo?.agentLog?.length const isToolNode = nodeInfo?.node_type === BlockEnum.Tool && !!nodeInfo?.agentLog?.length @@ -82,6 +86,14 @@ const ResultPanel: FC = ({ /> ) } + { + isLoopNode && handleShowLoopResultList && ( + + ) + } { isRetryNode && onShowRetryDetail && ( void + loopResultList?: NodeTracing[][] + loopResultDurationMap?: LoopDurationMap + agentOrToolLogItemStack?: AgentLogItemWithChildren[] agentOrToolLogListMap?: Record handleShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void @@ -31,6 +38,11 @@ const SpecialResultPanel = ({ iterationResultList, iterationResultDurationMap, + showLoopingDetail, + setShowLoopingDetailFalse, + loopResultList, + loopResultDurationMap, + agentOrToolLogItemStack, agentOrToolLogListMap, handleShowAgentOrToolLog, @@ -57,6 +69,15 @@ const SpecialResultPanel = ({ /> ) } + { + showLoopingDetail && !!loopResultList?.length && setShowLoopingDetailFalse && ( + + ) + } { !!agentOrToolLogItemStack?.length && agentOrToolLogListMap && handleShowAgentOrToolLog && ( = ({ iterationResultDurationMap, handleShowIterationResultList, + showLoopingDetail, + setShowLoopingDetailFalse, + loopResultList, + loopResultDurationMap, + handleShowLoopResultList, + agentOrToolLogItemStack, agentOrToolLogListMap, handleShowAgentOrToolLog, @@ -139,6 +145,7 @@ const TracingPanel: FC = ({ = ({ iterationResultList={iterationResultList} iterationResultDurationMap={iterationResultDurationMap} + showLoopingDetail={showLoopingDetail} + setShowLoopingDetailFalse={setShowLoopingDetailFalse} + loopResultList={loopResultList} + loopResultDurationMap={loopResultDurationMap} + agentOrToolLogItemStack={agentOrToolLogItemStack} agentOrToolLogListMap={agentOrToolLogListMap} handleShowAgentOrToolLog={handleShowAgentOrToolLog} diff --git a/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.spec.ts b/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.spec.ts index fccc34652..10a139ee3 100644 --- a/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.spec.ts @@ -31,6 +31,16 @@ describe('parseDSL', () => { ]) }) + it('should parse loop nodes correctly', () => { + const dsl = '(loop, loopNode, plainNode1 -> plainNode2)' + const result = parseDSL(dsl) + expect(result).toEqual([ + { id: 'loopNode', node_id: 'loopNode', title: 'loopNode', node_type: 'loop', execution_metadata: {}, status: 'succeeded' }, + { id: 'plainNode1', node_id: 'plainNode1', title: 'plainNode1', execution_metadata: { loop_id: 'loopNode', loop_index: 0 }, status: 'succeeded' }, + { id: 'plainNode2', node_id: 'plainNode2', title: 'plainNode2', execution_metadata: { loop_id: 'loopNode', loop_index: 0 }, status: 'succeeded' }, + ]) + }) + it('should parse parallel nodes correctly', () => { const dsl = '(parallel, parallelNode, nodeA, nodeB -> nodeC)' const result = parseDSL(dsl) diff --git a/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.ts b/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.ts index a6b20b056..741fa08eb 100644 --- a/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.ts +++ b/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.ts @@ -1,6 +1,7 @@ type IterationInfo = { iterationId: string; iterationIndex: number } -type NodePlain = { nodeType: 'plain'; nodeId: string; } & Partial -type NodeComplex = { nodeType: string; nodeId: string; params: (NodePlain | (NodeComplex & Partial) | Node[] | number)[] } & Partial +type LoopInfo = { loopId: string; loopIndex: number } +type NodePlain = { nodeType: 'plain'; nodeId: string; } & (Partial & Partial) +type NodeComplex = { nodeType: string; nodeId: string; params: (NodePlain | (NodeComplex & (Partial & Partial)) | Node[] | number)[] } & (Partial & Partial) type Node = NodePlain | NodeComplex /** @@ -46,9 +47,10 @@ function parseTopLevelFlow(dsl: string): string[] { * If the node is complex (e.g., has parentheses), it extracts the node type, node ID, and parameters. * @param nodeStr - The node string to parse. * @param parentIterationId - The ID of the parent iteration node (if applicable). + * @param parentLoopId - The ID of the parent loop node (if applicable). * @returns A parsed node object. */ -function parseNode(nodeStr: string, parentIterationId?: string): Node { +function parseNode(nodeStr: string, parentIterationId?: string, parentLoopId?: string): Node { // Check if the node is a complex node if (nodeStr.startsWith('(') && nodeStr.endsWith(')')) { const innerContent = nodeStr.slice(1, -1).trim() // Remove outer parentheses @@ -74,7 +76,7 @@ function parseNode(nodeStr: string, parentIterationId?: string): Node { // Extract nodeType, nodeId, and params const [nodeType, nodeId, ...paramsRaw] = parts - const params = parseParams(paramsRaw, nodeType === 'iteration' ? nodeId.trim() : parentIterationId) + const params = parseParams(paramsRaw, nodeType === 'iteration' ? nodeId.trim() : parentIterationId, nodeType === 'loop' ? nodeId.trim() : parentLoopId) const complexNode = { nodeType: nodeType.trim(), nodeId: nodeId.trim(), @@ -84,6 +86,10 @@ function parseNode(nodeStr: string, parentIterationId?: string): Node { (complexNode as any).iterationId = parentIterationId; (complexNode as any).iterationIndex = 0 // Fixed as 0 } + if (parentLoopId) { + (complexNode as any).loopId = parentLoopId; + (complexNode as any).loopIndex = 0 // Fixed as 0 + } return complexNode } @@ -93,6 +99,10 @@ function parseNode(nodeStr: string, parentIterationId?: string): Node { plainNode.iterationId = parentIterationId plainNode.iterationIndex = 0 // Fixed as 0 } + if (parentLoopId) { + plainNode.loopId = parentLoopId + plainNode.loopIndex = 0 // Fixed as 0 + } return plainNode } @@ -101,18 +111,19 @@ function parseNode(nodeStr: string, parentIterationId?: string): Node { * Supports nested flows and complex sub-nodes. * Adds iteration-specific metadata recursively. * @param paramParts - The parameters string split by commas. - * @param iterationId - The ID of the iteration node, if applicable. + * @param parentIterationId - The ID of the parent iteration node (if applicable). + * @param parentLoopId - The ID of the parent loop node (if applicable). * @returns An array of parsed parameters (plain nodes, nested nodes, or flows). */ -function parseParams(paramParts: string[], iterationId?: string): (Node | Node[] | number)[] { +function parseParams(paramParts: string[], parentIteration?: string, parentLoopId?: string): (Node | Node[] | number)[] { return paramParts.map((part) => { if (part.includes('->')) { // Parse as a flow and return an array of nodes - return parseTopLevelFlow(part).map(node => parseNode(node, iterationId)) + return parseTopLevelFlow(part).map(node => parseNode(node, parentIteration || undefined, parentLoopId || undefined)) } else if (part.startsWith('(')) { // Parse as a nested complex node - return parseNode(part, iterationId) + return parseNode(part, parentIteration || undefined, parentLoopId || undefined) } else if (!Number.isNaN(Number(part.trim()))) { // Parse as a numeric parameter @@ -120,7 +131,7 @@ function parseParams(paramParts: string[], iterationId?: string): (Node | Node[] } else { // Parse as a plain node - return parseNode(part, iterationId) + return parseNode(part, parentIteration || undefined, parentLoopId || undefined) } }) } @@ -153,7 +164,7 @@ function convertPlainNode(node: Node): NodeData[] { * Converts a retry node to node data. */ function convertRetryNode(node: Node): NodeData[] { - const { nodeId, iterationId, iterationIndex, params } = node as NodeComplex + const { nodeId, iterationId, iterationIndex, loopId, loopIndex, params } = node as NodeComplex const retryCount = params ? Number.parseInt(params[0] as unknown as string, 10) : 0 const result: NodeData[] = [ { @@ -173,6 +184,9 @@ function convertRetryNode(node: Node): NodeData[] { execution_metadata: iterationId ? { iteration_id: iterationId, iteration_index: iterationIndex || 0, + } : loopId ? { + loop_id: loopId, + loop_index: loopIndex || 0, } : {}, status: 'retry', }) @@ -216,6 +230,41 @@ function convertIterationNode(node: Node): NodeData[] { return result } +/** + * Converts an loop node to node data. + */ +function convertLoopNode(node: Node): NodeData[] { + const { nodeId, params } = node as NodeComplex + const result: NodeData[] = [ + { + id: nodeId, + node_id: nodeId, + title: nodeId, + node_type: 'loop', + status: 'succeeded', + execution_metadata: {}, + }, + ] + + params?.forEach((param: any) => { + if (Array.isArray(param)) { + param.forEach((childNode: Node) => { + const childData = convertToNodeData([childNode]) + childData.forEach((data) => { + data.execution_metadata = { + ...data.execution_metadata, + loop_id: nodeId, + loop_index: 0, + } + }) + result.push(...childData) + }) + } + }) + + return result +} + /** * Converts a parallel node to node data. */ @@ -290,6 +339,9 @@ function convertToNodeData(nodes: Node[], parentParallelId?: string, parentStart case 'iteration': result.push(...convertIterationNode(node)) break + case 'loop': + result.push(...convertLoopNode(node)) + break case 'parallel': result.push(...convertParallelNode(node, parentParallelId, parentStartNodeId)) break diff --git a/web/app/components/workflow/run/utils/format-log/index.ts b/web/app/components/workflow/run/utils/format-log/index.ts index 4e8f6c33c..4f97814e4 100644 --- a/web/app/components/workflow/run/utils/format-log/index.ts +++ b/web/app/components/workflow/run/utils/format-log/index.ts @@ -1,9 +1,80 @@ import type { NodeTracing } from '@/types/workflow' -import formatIterationNode from './iteration' +import { addChildrenToIterationNode } from './iteration' +import { addChildrenToLoopNode } from './loop' import formatParallelNode from './parallel' import formatRetryNode from './retry' import formatAgentNode from './agent' import { cloneDeep } from 'lodash-es' +import { BlockEnum } from '../../../types' + +const formatIterationAndLoopNode = (list: NodeTracing[], t: any) => { + const clonedList = cloneDeep(list) + + // Identify all loop and iteration nodes + const loopNodeIds = clonedList + .filter(item => item.node_type === BlockEnum.Loop) + .map(item => item.node_id) + + const iterationNodeIds = clonedList + .filter(item => item.node_type === BlockEnum.Iteration) + .map(item => item.node_id) + + // Identify all child nodes for both loop and iteration + const loopChildrenNodeIds = clonedList + .filter(item => item.execution_metadata?.loop_id && loopNodeIds.includes(item.execution_metadata.loop_id)) + .map(item => item.node_id) + + const iterationChildrenNodeIds = clonedList + .filter(item => item.execution_metadata?.iteration_id && iterationNodeIds.includes(item.execution_metadata.iteration_id)) + .map(item => item.node_id) + + // Filter out child nodes as they will be included in their parent nodes + const result = clonedList + .filter(item => !loopChildrenNodeIds.includes(item.node_id) && !iterationChildrenNodeIds.includes(item.node_id)) + .map((item) => { + // Process Loop nodes + if (item.node_type === BlockEnum.Loop) { + const childrenNodes = clonedList.filter(child => child.execution_metadata?.loop_id === item.node_id) + const error = childrenNodes.find(child => child.status === 'failed') + if (error) { + item.status = 'failed' + item.error = error.error + } + const addedChildrenList = addChildrenToLoopNode(item, childrenNodes) + + // Handle parallel nodes in loop node + if (addedChildrenList.details && addedChildrenList.details.length > 0) { + addedChildrenList.details = addedChildrenList.details.map((row) => { + return formatParallelNode(row, t) + }) + } + return addedChildrenList + } + + // Process Iteration nodes + if (item.node_type === BlockEnum.Iteration) { + const childrenNodes = clonedList.filter(child => child.execution_metadata?.iteration_id === item.node_id) + const error = childrenNodes.find(child => child.status === 'failed') + if (error) { + item.status = 'failed' + item.error = error.error + } + const addedChildrenList = addChildrenToIterationNode(item, childrenNodes) + + // Handle parallel nodes in iteration node + if (addedChildrenList.details && addedChildrenList.details.length > 0) { + addedChildrenList.details = addedChildrenList.details.map((row) => { + return formatParallelNode(row, t) + }) + } + return addedChildrenList + } + + return item + }) + + return result +} const formatToTracingNodeList = (list: NodeTracing[], t: any) => { const allItems = cloneDeep([...list]).sort((a, b) => a.index - b.index) @@ -14,8 +85,8 @@ const formatToTracingNodeList = (list: NodeTracing[], t: any) => { const formattedAgentList = formatAgentNode(allItems) const formattedRetryList = formatRetryNode(formattedAgentList) // retry one node // would change the structure of the list. Iteration and parallel can include each other. - const formattedIterationList = formatIterationNode(formattedRetryList, t) - const formattedParallelList = formatParallelNode(formattedIterationList, t) + const formattedLoopAndIterationList = formatIterationAndLoopNode(formattedRetryList, t) + const formattedParallelList = formatParallelNode(formattedLoopAndIterationList, t) const result = formattedParallelList // console.log(allItems) diff --git a/web/app/components/workflow/run/utils/format-log/iteration/index.ts b/web/app/components/workflow/run/utils/format-log/iteration/index.ts index d923d4678..d0224d025 100644 --- a/web/app/components/workflow/run/utils/format-log/iteration/index.ts +++ b/web/app/components/workflow/run/utils/format-log/iteration/index.ts @@ -1,7 +1,8 @@ import { BlockEnum } from '@/app/components/workflow/types' import type { NodeTracing } from '@/types/workflow' import formatParallelNode from '../parallel' -function addChildrenToIterationNode(iterationNode: NodeTracing, childrenNodes: NodeTracing[]): NodeTracing { + +export function addChildrenToIterationNode(iterationNode: NodeTracing, childrenNodes: NodeTracing[]): NodeTracing { const details: NodeTracing[][] = [] childrenNodes.forEach((item, index) => { if (!item.execution_metadata) return diff --git a/web/app/components/workflow/run/utils/format-log/loop/index.spec.ts b/web/app/components/workflow/run/utils/format-log/loop/index.spec.ts new file mode 100644 index 000000000..3371328fc --- /dev/null +++ b/web/app/components/workflow/run/utils/format-log/loop/index.spec.ts @@ -0,0 +1,22 @@ +import format from '.' +import graphToLogStruct from '../graph-to-log-struct' + +describe('loop', () => { + const list = graphToLogStruct('start -> (loop, loopNode, plainNode1 -> plainNode2)') + const [startNode, loopNode, ...loops] = list + const result = format(list as any, () => { }) + test('result should have no nodes in loop node', () => { + expect((result as any).find((item: any) => !!item.execution_metadata?.loop_id)).toBeUndefined() + }) + test('loop should put nodes in details', () => { + expect(result as any).toEqual([ + startNode, + { + ...loopNode, + details: [ + [loops[0], loops[1]], + ], + }, + ]) + }) +}) diff --git a/web/app/components/workflow/run/utils/format-log/loop/index.ts b/web/app/components/workflow/run/utils/format-log/loop/index.ts new file mode 100644 index 000000000..b12e12e48 --- /dev/null +++ b/web/app/components/workflow/run/utils/format-log/loop/index.ts @@ -0,0 +1,56 @@ +import { BlockEnum } from '@/app/components/workflow/types' +import type { NodeTracing } from '@/types/workflow' +import formatParallelNode from '../parallel' + +export function addChildrenToLoopNode(loopNode: NodeTracing, childrenNodes: NodeTracing[]): NodeTracing { + const details: NodeTracing[][] = [] + childrenNodes.forEach((item) => { + if (!item.execution_metadata) return + const { parallel_mode_run_id, loop_index = 0 } = item.execution_metadata + const runIndex: number = (parallel_mode_run_id || loop_index) as number + if (!details[runIndex]) + details[runIndex] = [] + + details[runIndex].push(item) + }) + return { + ...loopNode, + details, + } +} + +const format = (list: NodeTracing[], t: any): NodeTracing[] => { + const loopNodeIds = list + .filter(item => item.node_type === BlockEnum.Loop) + .map(item => item.node_id) + const loopChildrenNodeIds = list + .filter(item => item.execution_metadata?.loop_id && loopNodeIds.includes(item.execution_metadata.loop_id)) + .map(item => item.node_id) + // move loop children nodes to loop node's details field + const result = list + .filter(item => !loopChildrenNodeIds.includes(item.node_id)) + .map((item) => { + if (item.node_type === BlockEnum.Loop) { + const childrenNodes = list.filter(child => child.execution_metadata?.loop_id === item.node_id) + const error = childrenNodes.find(child => child.status === 'failed') + if (error) { + item.status = 'failed' + item.error = error.error + } + const addedChildrenList = addChildrenToLoopNode(item, childrenNodes) + // handle parallel node in loop node + if (addedChildrenList.details && addedChildrenList.details.length > 0) { + addedChildrenList.details = addedChildrenList.details.map((row) => { + return formatParallelNode(row, t) + }) + } + return addedChildrenList + } + + return item + }) + + return result +} + +export default format diff --git a/web/app/components/workflow/run/utils/format-log/retry/index.ts b/web/app/components/workflow/run/utils/format-log/retry/index.ts index b8dd0bfa8..5226f7979 100644 --- a/web/app/components/workflow/run/utils/format-log/retry/index.ts +++ b/web/app/components/workflow/run/utils/format-log/retry/index.ts @@ -12,6 +12,7 @@ const format = (list: NodeTracing[]): NodeTracing[] => { }).map((item) => { const { execution_metadata } = item const isInIteration = !!execution_metadata?.iteration_id + const isInLoop = !!execution_metadata?.loop_id const nodeId = item.node_id const isRetryBelongNode = retryNodeIds.includes(nodeId) @@ -19,11 +20,18 @@ const format = (list: NodeTracing[]): NodeTracing[] => { return { ...item, retryDetail: retryNodes.filter((node) => { - if (!isInIteration) + if (!isInIteration && !isInLoop) return node.node_id === nodeId // retry node in iteration - return node.node_id === nodeId && node.execution_metadata?.iteration_index === execution_metadata?.iteration_index + if (isInIteration) + return node.node_id === nodeId && node.execution_metadata?.iteration_index === execution_metadata?.iteration_index + + // retry node in loop + if (isInLoop) + return node.node_id === nodeId && node.execution_metadata?.loop_index === execution_metadata?.loop_index + + return false }), } } diff --git a/web/app/components/workflow/store.ts b/web/app/components/workflow/store.ts index 6bd47eaa0..246c33155 100644 --- a/web/app/components/workflow/store.ts +++ b/web/app/components/workflow/store.ts @@ -169,6 +169,8 @@ type Shape = { setShowTips: (showTips: string) => void iterTimes: number setIterTimes: (iterTimes: number) => void + loopTimes: number + setLoopTimes: (loopTimes: number) => void iterParallelLogMap: Map> setIterParallelLogMap: (iterParallelLogMap: Map>) => void versionHistory: VersionHistory[] @@ -290,6 +292,8 @@ export const createWorkflowStore = () => { setShowTips: showTips => set(() => ({ showTips })), iterTimes: 1, setIterTimes: iterTimes => set(() => ({ iterTimes })), + loopTimes: 1, + setLoopTimes: loopTimes => set(() => ({ loopTimes })), iterParallelLogMap: new Map>(), setIterParallelLogMap: iterParallelLogMap => set(() => ({ iterParallelLogMap })), diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 10df1b02b..9243beb6d 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -36,6 +36,8 @@ export enum BlockEnum { IterationStart = 'iteration-start', Assigner = 'assigner', // is now named as VariableAssigner Agent = 'agent', + Loop = 'loop', + LoopStart = 'loop-start', } export enum ControlMode { @@ -79,6 +81,10 @@ export type CommonNodeType = { type: BlockEnum width?: number height?: number + _loopLength?: number + _loopIndex?: number + isInLoop?: boolean + loop_id?: string error_strategy?: ErrorHandleTypeEnum retry_config?: WorkflowRetryConfig default_value?: DefaultValueForm[] @@ -94,6 +100,8 @@ export type CommonEdgeType = { _waitingRun?: boolean isInIteration?: boolean iteration_id?: string + isInLoop?: boolean + loop_id?: string sourceType: BlockEnum targetType: BlockEnum } @@ -168,6 +176,7 @@ export enum InputVarType { iterator = 'iterator', // iteration input singleFile = 'file', multiFiles = 'file-list', + loop = 'loop', // loop input } export type InputVar = { diff --git a/web/app/components/workflow/utils.ts b/web/app/components/workflow/utils.ts index c81fc6736..dd0beece5 100644 --- a/web/app/components/workflow/utils.ts +++ b/web/app/components/workflow/utils.ts @@ -30,15 +30,19 @@ import { DEFAULT_RETRY_MAX, ITERATION_CHILDREN_Z_INDEX, ITERATION_NODE_Z_INDEX, + LOOP_CHILDREN_Z_INDEX, + LOOP_NODE_Z_INDEX, NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION, } from './constants' import { CUSTOM_ITERATION_START_NODE } from './nodes/iteration-start/constants' +import { CUSTOM_LOOP_START_NODE } from './nodes/loop-start/constants' import type { QuestionClassifierNodeType } from './nodes/question-classifier/types' import type { IfElseNodeType } from './nodes/if-else/types' import { branchNameCorrect } from './nodes/if-else/utils' import type { ToolNodeType } from './nodes/tool/types' import type { IterationNodeType } from './nodes/iteration/types' +import type { LoopNodeType } from './nodes/loop/types' import { CollectionType } from '@/app/components/tools/types' import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { canFindTool, correctModelProvider } from '@/utils' @@ -118,9 +122,31 @@ export function getIterationStartNode(iterationId: string): Node { }).newNode } +export function getLoopStartNode(loopId: string): Node { + return generateNewNode({ + id: `${loopId}start`, + type: CUSTOM_LOOP_START_NODE, + data: { + title: '', + desc: '', + type: BlockEnum.LoopStart, + isInLoop: true, + }, + position: { + x: 24, + y: 68, + }, + zIndex: LOOP_CHILDREN_Z_INDEX, + parentId: loopId, + selectable: false, + draggable: false, + }).newNode +} + export function generateNewNode({ data, position, id, zIndex, type, ...rest }: Omit & { id?: string }): { newNode: Node newIterationStartNode?: Node + newLoopStartNode?: Node } { const newNode = { id: id || `${Date.now()}`, @@ -129,7 +155,7 @@ export function generateNewNode({ data, position, id, zIndex, type, ...rest }: O position, targetPosition: Position.Left, sourcePosition: Position.Right, - zIndex: data.type === BlockEnum.Iteration ? ITERATION_NODE_Z_INDEX : zIndex, + zIndex: data.type === BlockEnum.Iteration ? ITERATION_NODE_Z_INDEX : (data.type === BlockEnum.Loop ? LOOP_NODE_Z_INDEX : zIndex), ...rest, } as Node @@ -143,6 +169,16 @@ export function generateNewNode({ data, position, id, zIndex, type, ...rest }: O } } + if (data.type === BlockEnum.Loop) { + const newLoopStartNode = getLoopStartNode(newNode.id); + (newNode.data as LoopNodeType).start_node_id = newLoopStartNode.id; + (newNode.data as LoopNodeType)._children = [newLoopStartNode.id] + return { + newNode, + newLoopStartNode, + } + } + return { newNode, } @@ -150,6 +186,7 @@ export function generateNewNode({ data, position, id, zIndex, type, ...rest }: O export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { const hasIterationNode = nodes.some(node => node.data.type === BlockEnum.Iteration) + const hasLoopNode = nodes.some(node => node.data.type === BlockEnum.Loop) if (!hasIterationNode) { return { @@ -157,15 +194,26 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { edges, } } + + if (!hasLoopNode) { + return { + nodes, + edges, + } + } + const nodesMap = nodes.reduce((prev, next) => { prev[next.id] = next return prev }, {} as Record) + const iterationNodesWithStartNode = [] const iterationNodesWithoutStartNode = [] + const loopNodesWithStartNode = [] + const loopNodesWithoutStartNode = [] for (let i = 0; i < nodes.length; i++) { - const currentNode = nodes[i] as Node + const currentNode = nodes[i] as Node if (currentNode.data.type === BlockEnum.Iteration) { if (currentNode.data.start_node_id) { @@ -176,7 +224,18 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { iterationNodesWithoutStartNode.push(currentNode) } } + + if (currentNode.data.type === BlockEnum.Loop) { + if (currentNode.data.start_node_id) { + if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_LOOP_START_NODE) + loopNodesWithStartNode.push(currentNode) + } + else { + loopNodesWithoutStartNode.push(currentNode) + } + } } + const newIterationStartNodesMap = {} as Record const newIterationStartNodes = [...iterationNodesWithStartNode, ...iterationNodesWithoutStartNode].map((iterationNode, index) => { const newNode = getIterationStartNode(iterationNode.id) @@ -184,13 +243,28 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { newIterationStartNodesMap[iterationNode.id] = newNode return newNode }) - const newEdges = iterationNodesWithStartNode.map((iterationNode) => { - const newNode = newIterationStartNodesMap[iterationNode.id] - const startNode = nodesMap[iterationNode.data.start_node_id] + + const newLoopStartNodesMap = {} as Record + const newLoopStartNodes = [...loopNodesWithStartNode, ...loopNodesWithoutStartNode].map((loopNode, index) => { + const newNode = getLoopStartNode(loopNode.id) + newNode.id = newNode.id + index + newLoopStartNodesMap[loopNode.id] = newNode + return newNode + }) + + const newEdges = [...iterationNodesWithStartNode, ...loopNodesWithStartNode].map((nodeItem) => { + const isIteration = nodeItem.data.type === BlockEnum.Iteration + const newNode = (isIteration ? newIterationStartNodesMap : newLoopStartNodesMap)[nodeItem.id] + const startNode = nodesMap[nodeItem.data.start_node_id] const source = newNode.id const sourceHandle = 'source' const target = startNode.id const targetHandle = 'target' + + const parentNode = nodes.find(node => node.id === startNode.parentId) || null + const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration + const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop + return { id: `${source}-${sourceHandle}-${target}-${targetHandle}`, type: 'custom', @@ -201,20 +275,25 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { data: { sourceType: newNode.data.type, targetType: startNode.data.type, - isInIteration: true, - iteration_id: startNode.parentId, + isInIteration, + iteration_id: isInIteration ? startNode.parentId : undefined, + isInLoop, + loop_id: isInLoop ? startNode.parentId : undefined, _connectedNodeIsSelected: true, }, - zIndex: ITERATION_CHILDREN_Z_INDEX, + zIndex: isIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX, } }) nodes.forEach((node) => { if (node.data.type === BlockEnum.Iteration && newIterationStartNodesMap[node.id]) (node.data as IterationNodeType).start_node_id = newIterationStartNodesMap[node.id].id + + if (node.data.type === BlockEnum.Loop && newLoopStartNodesMap[node.id]) + (node.data as LoopNodeType).start_node_id = newLoopStartNodesMap[node.id].id }) return { - nodes: [...nodes, ...newIterationStartNodes], + nodes: [...nodes, ...newIterationStartNodes, ...newLoopStartNodes], edges: [...edges, ...newEdges], } } @@ -232,7 +311,7 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { }) } - const iterationNodeMap = nodes.reduce((acc, node) => { + const iterationOrLoopNodeMap = nodes.reduce((acc, node) => { if (node.parentId) { if (acc[node.parentId]) acc[node.parentId].push(node.id) @@ -276,12 +355,19 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { if (node.data.type === BlockEnum.Iteration) { const iterationNodeData = node.data as IterationNodeType - iterationNodeData._children = iterationNodeMap[node.id] || [] + iterationNodeData._children = iterationOrLoopNodeMap[node.id] || [] iterationNodeData.is_parallel = iterationNodeData.is_parallel || false iterationNodeData.parallel_nums = iterationNodeData.parallel_nums || 10 iterationNodeData.error_handle_mode = iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated } + // TODO: loop error handle mode + if (node.data.type === BlockEnum.Loop) { + const loopNodeData = node.data as LoopNodeType + loopNodeData._children = iterationOrLoopNodeMap[node.id] || [] + loopNodeData.error_handle_mode = loopNodeData.error_handle_mode || ErrorHandleMode.Terminated + } + // legacy provider handle if (node.data.type === BlockEnum.LLM) (node as any).data.model.provider = correctModelProvider((node as any).data.model.provider) @@ -359,7 +445,7 @@ export const getLayoutByDagre = (originNodes: Node[], originEdges: Edge[]) => { const dagreGraph = new dagre.graphlib.Graph() dagreGraph.setDefaultEdgeLabel(() => ({})) const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE) - const edges = cloneDeep(originEdges).filter(edge => !edge.data?.isInIteration) + const edges = cloneDeep(originEdges).filter(edge => (!edge.data?.isInIteration && !edge.data?.isInLoop)) dagreGraph.setGraph({ rankdir: 'LR', align: 'UL', @@ -397,6 +483,7 @@ export const canRunBySingle = (nodeType: BlockEnum) => { || nodeType === BlockEnum.Iteration || nodeType === BlockEnum.Agent || nodeType === BlockEnum.DocExtractor + || nodeType === BlockEnum.Loop } type ConnectedSourceOrTargetNodesChange = { @@ -487,15 +574,22 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => { if (outgoers.length) { outgoers.forEach((outgoer) => { list.push(outgoer) + if (outgoer.data.type === BlockEnum.Iteration) list.push(...nodes.filter(node => node.parentId === outgoer.id)) + if (outgoer.data.type === BlockEnum.Loop) + list.push(...nodes.filter(node => node.parentId === outgoer.id)) + traverse(outgoer, depth + 1) }) } else { list.push(root) + if (root.data.type === BlockEnum.Iteration) list.push(...nodes.filter(node => node.parentId === root.id)) + if (root.data.type === BlockEnum.Loop) + list.push(...nodes.filter(node => node.parentId === root.id)) } } @@ -654,7 +748,7 @@ export const getParallelInfo = (nodes: Node[], edges: Edge[], parentNodeId?: str if (!parentNode) throw new Error('Parent node not found') - startNode = nodes.find(node => node.id === (parentNode.data as IterationNodeType).start_node_id) + startNode = nodes.find(node => node.id === (parentNode.data as (IterationNodeType | LoopNodeType)).start_node_id) } else { startNode = nodes.find(node => node.data.type === BlockEnum.Start) diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 2900a314e..72077567b 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -49,6 +49,7 @@ const LocaleLayout = ({ data-public-text-generation-timeout-ms={process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS} data-public-top-k-max-value={process.env.NEXT_PUBLIC_TOP_K_MAX_VALUE} data-public-indexing-max-segmentation-tokens-length={process.env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH} + data-public-loop-node-max-count={process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT} > diff --git a/web/config/index.ts b/web/config/index.ts index 5600022ba..703c5d4c1 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -276,3 +276,12 @@ export const GITHUB_ACCESS_TOKEN = process.env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN | export const SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS = '.difypkg,.difybndl' export const FULL_DOC_PREVIEW_LENGTH = 50 + +let loopNodeMaxCount = 100 + +if (process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT && process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT !== '') + loopNodeMaxCount = Number.parseInt(process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT) +else if (globalThis.document?.body?.getAttribute('data-public-loop-node-max-count') && globalThis.document.body.getAttribute('data-public-loop-node-max-count') !== '') + loopNodeMaxCount = Number.parseInt(globalThis.document.body.getAttribute('data-public-loop-node-max-count') as string) + +export const LOOP_NODE_MAX_COUNT = loopNodeMaxCount diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 89344f01a..c25321c70 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -205,6 +205,7 @@ const translation = { testRunIteration: 'Test Run Iteration', back: 'Back', iteration: 'Iteration', + loop: 'Loop', }, tabs: { 'searchBlock': 'Search block', @@ -242,6 +243,8 @@ const translation = { 'document-extractor': 'Doc Extractor', 'list-operator': 'List Operator', 'agent': 'Agent', + 'loop-start': 'Loop Start', + 'loop': 'Loop', }, blocksAbout: { 'start': 'Define the initial parameters for launching a workflow', @@ -258,6 +261,7 @@ const translation = { 'assigner': 'The variable assignment node is used for assigning values to writable variables(like conversation variables).', 'variable-aggregator': 'Aggregate multi-branch variables into a single variable for unified configuration of downstream nodes.', 'iteration': 'Perform multiple steps on a list object until all results are outputted.', + 'loop': 'Execute a loop of logic until the termination condition is met or the maximum loop count is reached.', 'parameter-extractor': 'Use LLM to extract structured parameters from natural language for tool invocations or HTTP requests.', 'document-extractor': 'Used to parse uploaded documents into text content that is easily understandable by LLM.', 'list-operator': 'Used to filter or sort array content.', @@ -657,6 +661,24 @@ const translation = { }, answerNodeWarningDesc: 'Parallel mode warning: Answer nodes, conversation variable assignments, and persistent read/write operations within iterations may cause exceptions.', }, + loop: { + deleteTitle: 'Delete Loop Node?', + deleteDesc: 'Deleting the loop node will remove all child nodes', + input: 'Input', + output: 'Output Variable', + loop_one: '{{count}} Loop', + loop_other: '{{count}} Loops', + currentLoop: 'Current Loop', + breakCondition: 'Loop Termination Condition', + loopMaxCount: 'Maximum Loop Count', + loopMaxCountError: 'Please enter a valid maximum loop count, ranging from 1 to {{maxCount}}', + errorResponseMethod: 'Error Response Method', + ErrorMethod: { + operationTerminated: 'Terminated', + continueOnError: 'Continue on Error', + removeAbnormalOutput: 'Remove Abnormal Output', + }, + }, note: { addNote: 'Add Note', editor: { diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 3bbc107cf..d0b34c793 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -203,8 +203,10 @@ const translation = { startRun: '开始运行', running: '运行中', testRunIteration: '测试运行迭代', + testRunLoop: '测试运行循环', back: '返回', iteration: '迭代', + loop: '循环', }, tabs: { 'searchBlock': '搜索节点', @@ -242,6 +244,8 @@ const translation = { 'document-extractor': '文档提取器', 'list-operator': '列表操作', 'agent': 'Agent', + 'loop-start': '循环开始', + 'loop': '循环', }, blocksAbout: { 'start': '定义一个 workflow 流程启动的初始参数', @@ -258,6 +262,7 @@ const translation = { 'assigner': '变量赋值节点用于向可写入变量(例如会话变量)进行变量赋值。', 'variable-aggregator': '将多路分支的变量聚合为一个变量,以实现下游节点统一配置。', 'iteration': '对列表对象执行多次步骤直至输出所有结果。', + 'loop': '循环执行一段逻辑直到满足结束条件或者到达循环次数上限。', 'parameter-extractor': '利用 LLM 从自然语言内推理提取出结构化参数,用于后置的工具调用或 HTTP 请求。', 'document-extractor': '用于将用户上传的文档解析为 LLM 便于理解的文本内容。', 'list-operator': '用于过滤或排序数组内容。', @@ -657,6 +662,24 @@ const translation = { }, answerNodeWarningDesc: '并行模式警告:在迭代中,回答节点、会话变量赋值和工具持久读/写操作可能会导致异常。', }, + loop: { + deleteTitle: '删除循环节点?', + deleteDesc: '删除循环节点将删除所有子节点', + input: '输入', + output: '输出变量', + loop_one: '{{count}} 个循环', + loop_other: '{{count}} 个循环', + currentLoop: '当前循环', + breakCondition: '循环终止条件', + loopMaxCount: '最大循环次数', + loopMaxCountError: '请输入正确的 最大循环次数,范围为 1 到 {{maxCount}}', + errorResponseMethod: '错误响应方法', + ErrorMethod: { + operationTerminated: '错误时终止', + continueOnError: '忽略错误并继续', + removeAbnormalOutput: '移除错误输出', + }, + }, note: { addNote: '添加注释', editor: { diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 8b010346a..1e4fd2ef2 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -243,6 +243,8 @@ const translation = { 'list-operator': '清單運算子', 'document-extractor': '文件提取器', 'agent': '代理', + 'loop-start': '循環開始', + 'loop': '循環', }, blocksAbout: { 'start': '定義一個 workflow 流程啟動的參數', diff --git a/web/service/base.ts b/web/service/base.ts index 38aaae0b1..f683011fd 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -8,6 +8,9 @@ import type { IterationFinishedResponse, IterationNextResponse, IterationStartedResponse, + LoopFinishedResponse, + LoopNextResponse, + LoopStartedResponse, NodeFinishedResponse, NodeStartedResponse, ParallelBranchFinishedResponse, @@ -54,6 +57,9 @@ export type IOnTextChunk = (textChunk: TextChunkResponse) => void export type IOnTTSChunk = (messageId: string, audioStr: string, audioType?: string) => void export type IOnTTSEnd = (messageId: string, audioStr: string, audioType?: string) => void export type IOnTextReplace = (textReplace: TextReplaceResponse) => void +export type IOnLoopStarted = (workflowStarted: LoopStartedResponse) => void +export type IOnLoopNext = (workflowStarted: LoopNextResponse) => void +export type IOnLoopFinished = (workflowFinished: LoopFinishedResponse) => void export type IOnAgentLog = (agentLog: AgentLogResponse) => void export type IOtherOptions = { @@ -86,6 +92,9 @@ export type IOtherOptions = { onTTSChunk?: IOnTTSChunk onTTSEnd?: IOnTTSEnd onTextReplace?: IOnTextReplace + onLoopStart?: IOnLoopStarted + onLoopNext?: IOnLoopNext + onLoopFinish?: IOnLoopFinished onAgentLog?: IOnAgentLog } @@ -125,6 +134,9 @@ const handleStream = ( onIterationStart?: IOnIterationStarted, onIterationNext?: IOnIterationNext, onIterationFinish?: IOnIterationFinished, + onLoopStart?: IOnLoopStarted, + onLoopNext?: IOnLoopNext, + onLoopFinish?: IOnLoopFinished, onNodeRetry?: IOnNodeRetry, onParallelBranchStarted?: IOnParallelBranchStarted, onParallelBranchFinished?: IOnParallelBranchFinished, @@ -218,6 +230,15 @@ const handleStream = ( else if (bufferObj.event === 'iteration_completed') { onIterationFinish?.(bufferObj as IterationFinishedResponse) } + else if (bufferObj.event === 'loop_started') { + onLoopStart?.(bufferObj as LoopStartedResponse) + } + else if (bufferObj.event === 'loop_next') { + onLoopNext?.(bufferObj as LoopNextResponse) + } + else if (bufferObj.event === 'loop_completed') { + onLoopFinish?.(bufferObj as LoopFinishedResponse) + } else if (bufferObj.event === 'node_retry') { onNodeRetry?.(bufferObj as NodeFinishedResponse) } @@ -332,6 +353,9 @@ export const ssePost = ( onAgentLog, onError, getAbortController, + onLoopStart, + onLoopNext, + onLoopFinish, } = otherOptions const abortController = new AbortController() @@ -361,7 +385,7 @@ export const ssePost = ( options.body = JSON.stringify(body) const accessToken = getAccessToken(isPublicAPI) - options.headers!.set('Authorization', `Bearer ${accessToken}`) + ;(options.headers as Headers).set('Authorization', `Bearer ${accessToken}`) globalThis.fetch(urlWithPrefix, options as RequestInit) .then((res) => { @@ -400,7 +424,31 @@ export const ssePost = ( return } onData?.(str, isFirstMessage, moreInfo) - }, onCompleted, onThought, onMessageEnd, onMessageReplace, onFile, onWorkflowStarted, onWorkflowFinished, onNodeStarted, onNodeFinished, onIterationStart, onIterationNext, onIterationFinish, onNodeRetry, onParallelBranchStarted, onParallelBranchFinished, onTextChunk, onTTSChunk, onTTSEnd, onTextReplace, onAgentLog) + }, + onCompleted, + onThought, + onMessageEnd, + onMessageReplace, + onFile, + onWorkflowStarted, + onWorkflowFinished, + onNodeStarted, + onNodeFinished, + onIterationStart, + onIterationNext, + onIterationFinish, + onLoopStart, + onLoopNext, + onLoopFinish, + onNodeRetry, + onParallelBranchStarted, + onParallelBranchFinished, + onTextChunk, + onTTSChunk, + onTTSEnd, + onTextReplace, + onAgentLog, + ) }).catch((e) => { if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().errorMessage.includes('TypeError: Cannot assign to read only property')) Toast.notify({ type: 'error', message: e }) diff --git a/web/service/share.ts b/web/service/share.ts index 0e46e30d0..8579967ec 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -1,4 +1,26 @@ -import type { IOnCompleted, IOnData, IOnError, IOnFile, IOnIterationFinished, IOnIterationNext, IOnIterationStarted, IOnMessageEnd, IOnMessageReplace, IOnNodeFinished, IOnNodeStarted, IOnTTSChunk, IOnTTSEnd, IOnTextChunk, IOnTextReplace, IOnThought, IOnWorkflowFinished, IOnWorkflowStarted } from './base' +import type { + IOnCompleted, + IOnData, + IOnError, + IOnFile, + IOnIterationFinished, + IOnIterationNext, + IOnIterationStarted, + IOnLoopFinished, + IOnLoopNext, + IOnLoopStarted, + IOnMessageEnd, + IOnMessageReplace, + IOnNodeFinished, + IOnNodeStarted, + IOnTTSChunk, + IOnTTSEnd, + IOnTextChunk, + IOnTextReplace, + IOnThought, + IOnWorkflowFinished, + IOnWorkflowStarted, +} from './base' import { del as consoleDel, get as consoleGet, patch as consolePatch, post as consolePost, delPublic as del, getPublic as get, patchPublic as patch, postPublic as post, ssePost, @@ -78,6 +100,9 @@ export const sendWorkflowMessage = async ( onIterationStart, onIterationNext, onIterationFinish, + onLoopStart, + onLoopNext, + onLoopFinish, onTextChunk, onTextReplace, }: { @@ -88,6 +113,9 @@ export const sendWorkflowMessage = async ( onIterationStart: IOnIterationStarted onIterationNext: IOnIterationNext onIterationFinish: IOnIterationFinished + onLoopStart: IOnLoopStarted + onLoopNext: IOnLoopNext + onLoopFinish: IOnLoopFinished onTextChunk: IOnTextChunk onTextReplace: IOnTextReplace }, @@ -99,7 +127,21 @@ export const sendWorkflowMessage = async ( ...body, response_mode: 'streaming', }, - }, { onNodeStarted, onWorkflowStarted, onWorkflowFinished, isPublicAPI: !isInstalledApp, onNodeFinished, onIterationStart, onIterationNext, onIterationFinish, onTextChunk, onTextReplace }) + }, { + onNodeStarted, + onWorkflowStarted, + onWorkflowFinished, + isPublicAPI: !isInstalledApp, + onNodeFinished, + onIterationStart, + onIterationNext, + onIterationFinish, + onLoopStart, + onLoopNext, + onLoopFinish, + onTextChunk, + onTextReplace, + }) } export const fetchAppInfo = async () => { diff --git a/web/service/workflow.ts b/web/service/workflow.ts index b2c8d323b..398331477 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -42,6 +42,10 @@ export const getIterationSingleNodeRunUrl = (isChatFlow: boolean, appId: string, return `apps/${appId}/${isChatFlow ? 'advanced-chat/' : ''}workflows/draft/iteration/nodes/${nodeId}/run` } +export const getLoopSingleNodeRunUrl = (isChatFlow: boolean, appId: string, nodeId: string) => { + return `apps/${appId}/${isChatFlow ? 'advanced-chat/' : ''}workflows/draft/loop/nodes/${nodeId}/run` +} + export const publishWorkflow = (url: string) => { return post(url) } diff --git a/web/types/workflow.ts b/web/types/workflow.ts index 31ea75958..28d08d36e 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -30,6 +30,7 @@ export type NodeTracing = { predecessor_node_id: string node_id: string iteration_id?: string + loop_id?: string node_type: BlockEnum title: string inputs: any @@ -45,12 +46,15 @@ export type NodeTracing = { currency: string iteration_id?: string iteration_index?: number + loop_id?: string + loop_index?: number parallel_id?: string parallel_start_node_id?: string parent_parallel_id?: string parent_parallel_start_node_id?: string parallel_mode_run_id?: string iteration_duration_map?: IterationDurationMap + loop_duration_map?: LoopDurationMap error_strategy?: ErrorHandleTypeEnum agent_log?: AgentLogItem[] tool_info?: { @@ -61,6 +65,8 @@ export type NodeTracing = { metadata: { iterator_length: number iterator_index: number + loop_length: number + loop_index: number } created_at: number created_by: { @@ -69,10 +75,11 @@ export type NodeTracing = { email: string } iterDurationMap?: IterationDurationMap + loopDurationMap?: LoopDurationMap finished_at: number extras?: any expand?: boolean // for UI - details?: NodeTracing[][] // iteration detail + details?: NodeTracing[][] // iteration or loop detail retryDetail?: NodeTracing[] // retry detail retry_index?: number parallelDetail?: { // parallel detail. if is in parallel, this field will be set @@ -204,6 +211,27 @@ export type IterationFinishedResponse = { data: NodeTracing } +export type LoopStartedResponse = { + task_id: string + workflow_run_id: string + event: string + data: NodeTracing +} + +export type LoopNextResponse = { + task_id: string + workflow_run_id: string + event: string + data: NodeTracing +} + +export type LoopFinishedResponse = { + task_id: string + workflow_run_id: string + event: string + data: NodeTracing +} + export type ParallelBranchStartedResponse = { task_id: string workflow_run_id: string @@ -290,6 +318,7 @@ export type ConversationVariableResponse = { } export type IterationDurationMap = Record +export type LoopDurationMap = Record export type WorkflowConfigResponse = { parallel_depth_limit: number