Bläddra i källkod

Feature/newnew workflow loop node (#14863)

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

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

@@ -246,6 +246,80 @@ class WorkflowDraftRunIterationNodeApi(Resource):
raise InternalServerError()


class AdvancedChatDraftRunLoopNodeApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
def post(self, app_model: App, node_id: str):
"""
Run draft workflow loop node
"""
# The role of the current user in the ta table must be admin, owner, or editor
if not current_user.is_editor:
raise Forbidden()

if not isinstance(current_user, Account):
raise Forbidden()

parser = reqparse.RequestParser()
parser.add_argument("inputs", type=dict, location="json")
args = parser.parse_args()

try:
response = AppGenerateService.generate_single_loop(
app_model=app_model, user=current_user, node_id=node_id, args=args, streaming=True
)

return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except ValueError as e:
raise e
except Exception:
logging.exception("internal server error.")
raise InternalServerError()


class WorkflowDraftRunLoopNodeApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
def post(self, app_model: App, node_id: str):
"""
Run draft workflow loop node
"""
# The role of the current user in the ta table must be admin, owner, or editor
if not current_user.is_editor:
raise Forbidden()

if not isinstance(current_user, Account):
raise Forbidden()

parser = reqparse.RequestParser()
parser.add_argument("inputs", type=dict, location="json")
args = parser.parse_args()

try:
response = AppGenerateService.generate_single_loop(
app_model=app_model, user=current_user, node_id=node_id, args=args, streaming=True
)

return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except ValueError as e:
raise e
except Exception:
logging.exception("internal server error.")
raise InternalServerError()


class DraftWorkflowRunApi(Resource):
@setup_required
@login_required
@@ -512,6 +586,11 @@ api.add_resource(
api.add_resource(
WorkflowDraftRunIterationNodeApi, "/apps/<uuid:app_id>/workflows/draft/iteration/nodes/<string:node_id>/run"
)
api.add_resource(
AdvancedChatDraftRunLoopNodeApi,
"/apps/<uuid:app_id>/advanced-chat/workflows/draft/loop/nodes/<string:node_id>/run",
)
api.add_resource(WorkflowDraftRunLoopNodeApi, "/apps/<uuid:app_id>/workflows/draft/loop/nodes/<string:node_id>/run")
api.add_resource(PublishedWorkflowApi, "/apps/<uuid:app_id>/workflows/publish")
api.add_resource(PublishedAllWorkflowApi, "/apps/<uuid:app_id>/workflows")
api.add_resource(DefaultBlockConfigsApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs")

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

@@ -223,6 +223,61 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
stream=streaming,
)

def single_loop_generate(
self,
app_model: App,
workflow: Workflow,
node_id: str,
user: Account | EndUser,
args: Mapping,
streaming: bool = True,
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], Any, None]:
"""
Generate App response.

:param app_model: App
:param workflow: Workflow
:param user: account or end user
:param args: request args
:param invoke_from: invoke from source
:param stream: is stream
"""
if not node_id:
raise ValueError("node_id is required")

if args.get("inputs") is None:
raise ValueError("inputs is required")

# convert to app config
app_config = AdvancedChatAppConfigManager.get_app_config(app_model=app_model, workflow=workflow)

# init application generate entity
application_generate_entity = AdvancedChatAppGenerateEntity(
task_id=str(uuid.uuid4()),
app_config=app_config,
conversation_id=None,
inputs={},
query="",
files=[],
user_id=user.id,
stream=streaming,
invoke_from=InvokeFrom.DEBUGGER,
extras={"auto_generate_conversation_name": False},
single_loop_run=AdvancedChatAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args["inputs"]),
)
contexts.tenant_id.set(application_generate_entity.app_config.tenant_id)
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock())

return self._generate(
workflow=workflow,
user=user,
invoke_from=InvokeFrom.DEBUGGER,
application_generate_entity=application_generate_entity,
conversation=None,
stream=streaming,
)

def _generate(
self,
*,

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

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

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

@@ -23,10 +23,14 @@ from core.app.entities.queue_entities import (
QueueIterationCompletedEvent,
QueueIterationNextEvent,
QueueIterationStartEvent,
QueueLoopCompletedEvent,
QueueLoopNextEvent,
QueueLoopStartEvent,
QueueMessageReplaceEvent,
QueueNodeExceptionEvent,
QueueNodeFailedEvent,
QueueNodeInIterationFailedEvent,
QueueNodeInLoopFailedEvent,
QueueNodeRetryEvent,
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
@@ -372,7 +376,13 @@ class AdvancedChatAppGenerateTaskPipeline:

if node_finish_resp:
yield node_finish_resp
elif isinstance(event, QueueNodeFailedEvent | QueueNodeInIterationFailedEvent | QueueNodeExceptionEvent):
elif isinstance(
event,
QueueNodeFailedEvent
| QueueNodeInIterationFailedEvent
| QueueNodeInLoopFailedEvent
| QueueNodeExceptionEvent,
):
with Session(db.engine, expire_on_commit=False) as session:
workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_failed(
session=session, event=event
@@ -472,6 +482,54 @@ class AdvancedChatAppGenerateTaskPipeline:
)

yield iter_finish_resp
elif isinstance(event, QueueLoopStartEvent):
if not self._workflow_run_id:
raise ValueError("workflow run not initialized.")

with Session(db.engine, expire_on_commit=False) as session:
workflow_run = self._workflow_cycle_manager._get_workflow_run(
session=session, workflow_run_id=self._workflow_run_id
)
loop_start_resp = self._workflow_cycle_manager._workflow_loop_start_to_stream_response(
session=session,
task_id=self._application_generate_entity.task_id,
workflow_run=workflow_run,
event=event,
)

yield loop_start_resp
elif isinstance(event, QueueLoopNextEvent):
if not self._workflow_run_id:
raise ValueError("workflow run not initialized.")

with Session(db.engine, expire_on_commit=False) as session:
workflow_run = self._workflow_cycle_manager._get_workflow_run(
session=session, workflow_run_id=self._workflow_run_id
)
loop_next_resp = self._workflow_cycle_manager._workflow_loop_next_to_stream_response(
session=session,
task_id=self._application_generate_entity.task_id,
workflow_run=workflow_run,
event=event,
)

yield loop_next_resp
elif isinstance(event, QueueLoopCompletedEvent):
if not self._workflow_run_id:
raise ValueError("workflow run not initialized.")

with Session(db.engine, expire_on_commit=False) as session:
workflow_run = self._workflow_cycle_manager._get_workflow_run(
session=session, workflow_run_id=self._workflow_run_id
)
loop_finish_resp = self._workflow_cycle_manager._workflow_loop_completed_to_stream_response(
session=session,
task_id=self._application_generate_entity.task_id,
workflow_run=workflow_run,
event=event,
)

yield loop_finish_resp
elif isinstance(event, QueueWorkflowSucceededEvent):
if not self._workflow_run_id:
raise ValueError("workflow run not initialized.")

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

@@ -250,6 +250,60 @@ class WorkflowAppGenerator(BaseAppGenerator):
streaming=streaming,
)

def single_loop_generate(
self,
app_model: App,
workflow: Workflow,
node_id: str,
user: Account | EndUser,
args: Mapping[str, Any],
streaming: bool = True,
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], None, None]:
"""
Generate App response.

:param app_model: App
:param workflow: Workflow
:param user: account or end user
:param args: request args
:param invoke_from: invoke from source
:param stream: is stream
"""
if not node_id:
raise ValueError("node_id is required")

if args.get("inputs") is None:
raise ValueError("inputs is required")

# convert to app config
app_config = WorkflowAppConfigManager.get_app_config(app_model=app_model, workflow=workflow)

# init application generate entity
application_generate_entity = WorkflowAppGenerateEntity(
task_id=str(uuid.uuid4()),
app_config=app_config,
inputs={},
files=[],
user_id=user.id,
stream=streaming,
invoke_from=InvokeFrom.DEBUGGER,
extras={"auto_generate_conversation_name": False},
single_loop_run=WorkflowAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args["inputs"]),
workflow_run_id=str(uuid.uuid4()),
)
contexts.tenant_id.set(application_generate_entity.app_config.tenant_id)
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock())

return self._generate(
app_model=app_model,
workflow=workflow,
user=user,
invoke_from=InvokeFrom.DEBUGGER,
application_generate_entity=application_generate_entity,
streaming=streaming,
)

def _generate_worker(
self,
flask_app: Flask,

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

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

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

@@ -18,9 +18,13 @@ from core.app.entities.queue_entities import (
QueueIterationCompletedEvent,
QueueIterationNextEvent,
QueueIterationStartEvent,
QueueLoopCompletedEvent,
QueueLoopNextEvent,
QueueLoopStartEvent,
QueueNodeExceptionEvent,
QueueNodeFailedEvent,
QueueNodeInIterationFailedEvent,
QueueNodeInLoopFailedEvent,
QueueNodeRetryEvent,
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
@@ -323,7 +327,13 @@ class WorkflowAppGenerateTaskPipeline:

if node_success_response:
yield node_success_response
elif isinstance(event, QueueNodeFailedEvent | QueueNodeInIterationFailedEvent | QueueNodeExceptionEvent):
elif isinstance(
event,
QueueNodeFailedEvent
| QueueNodeInIterationFailedEvent
| QueueNodeInLoopFailedEvent
| QueueNodeExceptionEvent,
):
with Session(db.engine, expire_on_commit=False) as session:
workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_failed(
session=session,
@@ -429,6 +439,57 @@ class WorkflowAppGenerateTaskPipeline:

yield iter_finish_resp

elif isinstance(event, QueueLoopStartEvent):
if not self._workflow_run_id:
raise ValueError("workflow run not initialized.")

with Session(db.engine, expire_on_commit=False) as session:
workflow_run = self._workflow_cycle_manager._get_workflow_run(
session=session, workflow_run_id=self._workflow_run_id
)
loop_start_resp = self._workflow_cycle_manager._workflow_loop_start_to_stream_response(
session=session,
task_id=self._application_generate_entity.task_id,
workflow_run=workflow_run,
event=event,
)

yield loop_start_resp

elif isinstance(event, QueueLoopNextEvent):
if not self._workflow_run_id:
raise ValueError("workflow run not initialized.")

with Session(db.engine, expire_on_commit=False) as session:
workflow_run = self._workflow_cycle_manager._get_workflow_run(
session=session, workflow_run_id=self._workflow_run_id
)
loop_next_resp = self._workflow_cycle_manager._workflow_loop_next_to_stream_response(
session=session,
task_id=self._application_generate_entity.task_id,
workflow_run=workflow_run,
event=event,
)

yield loop_next_resp

elif isinstance(event, QueueLoopCompletedEvent):
if not self._workflow_run_id:
raise ValueError("workflow run not initialized.")

with Session(db.engine, expire_on_commit=False) as session:
workflow_run = self._workflow_cycle_manager._get_workflow_run(
session=session, workflow_run_id=self._workflow_run_id
)
loop_finish_resp = self._workflow_cycle_manager._workflow_loop_completed_to_stream_response(
session=session,
task_id=self._application_generate_entity.task_id,
workflow_run=workflow_run,
event=event,
)

yield loop_finish_resp

elif isinstance(event, QueueWorkflowSucceededEvent):
if not self._workflow_run_id:
raise ValueError("workflow run not initialized.")

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

@@ -9,9 +9,13 @@ from core.app.entities.queue_entities import (
QueueIterationCompletedEvent,
QueueIterationNextEvent,
QueueIterationStartEvent,
QueueLoopCompletedEvent,
QueueLoopNextEvent,
QueueLoopStartEvent,
QueueNodeExceptionEvent,
QueueNodeFailedEvent,
QueueNodeInIterationFailedEvent,
QueueNodeInLoopFailedEvent,
QueueNodeRetryEvent,
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
@@ -38,7 +42,12 @@ from core.workflow.graph_engine.entities.event import (
IterationRunNextEvent,
IterationRunStartedEvent,
IterationRunSucceededEvent,
LoopRunFailedEvent,
LoopRunNextEvent,
LoopRunStartedEvent,
LoopRunSucceededEvent,
NodeInIterationFailedEvent,
NodeInLoopFailedEvent,
NodeRunExceptionEvent,
NodeRunFailedEvent,
NodeRunRetrieverResourceEvent,
@@ -173,6 +182,96 @@ class WorkflowBasedAppRunner(AppRunner):

return graph, variable_pool

def _get_graph_and_variable_pool_of_single_loop(
self,
workflow: Workflow,
node_id: str,
user_inputs: dict,
) -> tuple[Graph, VariablePool]:
"""
Get variable pool of single loop
"""
# fetch workflow graph
graph_config = workflow.graph_dict
if not graph_config:
raise ValueError("workflow graph not found")

graph_config = cast(dict[str, Any], graph_config)

if "nodes" not in graph_config or "edges" not in graph_config:
raise ValueError("nodes or edges not found in workflow graph")

if not isinstance(graph_config.get("nodes"), list):
raise ValueError("nodes in workflow graph must be a list")

if not isinstance(graph_config.get("edges"), list):
raise ValueError("edges in workflow graph must be a list")

# filter nodes only in loop
node_configs = [
node
for node in graph_config.get("nodes", [])
if node.get("id") == node_id or node.get("data", {}).get("loop_id", "") == node_id
]

graph_config["nodes"] = node_configs

node_ids = [node.get("id") for node in node_configs]

# filter edges only in loop
edge_configs = [
edge
for edge in graph_config.get("edges", [])
if (edge.get("source") is None or edge.get("source") in node_ids)
and (edge.get("target") is None or edge.get("target") in node_ids)
]

graph_config["edges"] = edge_configs

# init graph
graph = Graph.init(graph_config=graph_config, root_node_id=node_id)

if not graph:
raise ValueError("graph not found in workflow")

# fetch node config from node id
loop_node_config = None
for node in node_configs:
if node.get("id") == node_id:
loop_node_config = node
break

if not loop_node_config:
raise ValueError("loop node id not found in workflow graph")

# Get node class
node_type = NodeType(loop_node_config.get("data", {}).get("type"))
node_version = loop_node_config.get("data", {}).get("version", "1")
node_cls = NODE_TYPE_CLASSES_MAPPING[node_type][node_version]

# init variable pool
variable_pool = VariablePool(
system_variables={},
user_inputs={},
environment_variables=workflow.environment_variables,
)

try:
variable_mapping = node_cls.extract_variable_selector_to_variable_mapping(
graph_config=workflow.graph_dict, config=loop_node_config
)
except NotImplementedError:
variable_mapping = {}

WorkflowEntry.mapping_user_inputs_to_variable_pool(
variable_mapping=variable_mapping,
user_inputs=user_inputs,
variable_pool=variable_pool,
tenant_id=workflow.tenant_id,
)

return graph, variable_pool

def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent) -> None:
"""
Handle event
@@ -216,6 +315,7 @@ class WorkflowBasedAppRunner(AppRunner):
node_run_index=event.route_node_state.index,
predecessor_node_id=event.predecessor_node_id,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
parallel_mode_run_id=event.parallel_mode_run_id,
inputs=inputs,
process_data=process_data,
@@ -240,6 +340,7 @@ class WorkflowBasedAppRunner(AppRunner):
node_run_index=event.route_node_state.index,
predecessor_node_id=event.predecessor_node_id,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
parallel_mode_run_id=event.parallel_mode_run_id,
agent_strategy=event.agent_strategy,
)
@@ -272,6 +373,7 @@ class WorkflowBasedAppRunner(AppRunner):
outputs=outputs,
execution_metadata=execution_metadata,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
)
)
elif isinstance(event, NodeRunFailedEvent):
@@ -302,6 +404,7 @@ class WorkflowBasedAppRunner(AppRunner):
if event.route_node_state.node_run_result
else {},
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
)
)
elif isinstance(event, NodeRunExceptionEvent):
@@ -332,6 +435,7 @@ class WorkflowBasedAppRunner(AppRunner):
if event.route_node_state.node_run_result
else {},
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
)
)
elif isinstance(event, NodeInIterationFailedEvent):
@@ -362,18 +466,49 @@ class WorkflowBasedAppRunner(AppRunner):
error=event.error,
)
)
elif isinstance(event, NodeInLoopFailedEvent):
self._publish_event(
QueueNodeInLoopFailedEvent(
node_execution_id=event.id,
node_id=event.node_id,
node_type=event.node_type,
node_data=event.node_data,
parallel_id=event.parallel_id,
parallel_start_node_id=event.parallel_start_node_id,
parent_parallel_id=event.parent_parallel_id,
parent_parallel_start_node_id=event.parent_parallel_start_node_id,
start_at=event.route_node_state.start_at,
inputs=event.route_node_state.node_run_result.inputs
if event.route_node_state.node_run_result
else {},
process_data=event.route_node_state.node_run_result.process_data
if event.route_node_state.node_run_result
else {},
outputs=event.route_node_state.node_run_result.outputs or {}
if event.route_node_state.node_run_result
else {},
execution_metadata=event.route_node_state.node_run_result.metadata
if event.route_node_state.node_run_result
else {},
in_loop_id=event.in_loop_id,
error=event.error,
)
)
elif isinstance(event, NodeRunStreamChunkEvent):
self._publish_event(
QueueTextChunkEvent(
text=event.chunk_content,
from_variable_selector=event.from_variable_selector,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
)
)
elif isinstance(event, NodeRunRetrieverResourceEvent):
self._publish_event(
QueueRetrieverResourcesEvent(
retriever_resources=event.retriever_resources, in_iteration_id=event.in_iteration_id
retriever_resources=event.retriever_resources,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
)
)
elif isinstance(event, AgentLogEvent):
@@ -397,6 +532,7 @@ class WorkflowBasedAppRunner(AppRunner):
parent_parallel_id=event.parent_parallel_id,
parent_parallel_start_node_id=event.parent_parallel_start_node_id,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
)
)
elif isinstance(event, ParallelBranchRunSucceededEvent):
@@ -407,6 +543,7 @@ class WorkflowBasedAppRunner(AppRunner):
parent_parallel_id=event.parent_parallel_id,
parent_parallel_start_node_id=event.parent_parallel_start_node_id,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
)
)
elif isinstance(event, ParallelBranchRunFailedEvent):
@@ -417,6 +554,7 @@ class WorkflowBasedAppRunner(AppRunner):
parent_parallel_id=event.parent_parallel_id,
parent_parallel_start_node_id=event.parent_parallel_start_node_id,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
error=event.error,
)
)
@@ -476,6 +614,62 @@ class WorkflowBasedAppRunner(AppRunner):
error=event.error if isinstance(event, IterationRunFailedEvent) else None,
)
)
elif isinstance(event, LoopRunStartedEvent):
self._publish_event(
QueueLoopStartEvent(
node_execution_id=event.loop_id,
node_id=event.loop_node_id,
node_type=event.loop_node_type,
node_data=event.loop_node_data,
parallel_id=event.parallel_id,
parallel_start_node_id=event.parallel_start_node_id,
parent_parallel_id=event.parent_parallel_id,
parent_parallel_start_node_id=event.parent_parallel_start_node_id,
start_at=event.start_at,
node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps,
inputs=event.inputs,
predecessor_node_id=event.predecessor_node_id,
metadata=event.metadata,
)
)
elif isinstance(event, LoopRunNextEvent):
self._publish_event(
QueueLoopNextEvent(
node_execution_id=event.loop_id,
node_id=event.loop_node_id,
node_type=event.loop_node_type,
node_data=event.loop_node_data,
parallel_id=event.parallel_id,
parallel_start_node_id=event.parallel_start_node_id,
parent_parallel_id=event.parent_parallel_id,
parent_parallel_start_node_id=event.parent_parallel_start_node_id,
index=event.index,
node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps,
output=event.pre_loop_output,
parallel_mode_run_id=event.parallel_mode_run_id,
duration=event.duration,
)
)
elif isinstance(event, (LoopRunSucceededEvent | LoopRunFailedEvent)):
self._publish_event(
QueueLoopCompletedEvent(
node_execution_id=event.loop_id,
node_id=event.loop_node_id,
node_type=event.loop_node_type,
node_data=event.loop_node_data,
parallel_id=event.parallel_id,
parallel_start_node_id=event.parallel_start_node_id,
parent_parallel_id=event.parent_parallel_id,
parent_parallel_start_node_id=event.parent_parallel_start_node_id,
start_at=event.start_at,
node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps,
inputs=event.inputs,
outputs=event.outputs,
metadata=event.metadata,
steps=event.steps,
error=event.error if isinstance(event, LoopRunFailedEvent) else None,
)
)

def get_workflow(self, app_model: App, workflow_id: str) -> Optional[Workflow]:
"""

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

@@ -187,6 +187,16 @@ class AdvancedChatAppGenerateEntity(ConversationAppGenerateEntity):

single_iteration_run: Optional[SingleIterationRunEntity] = None

class SingleLoopRunEntity(BaseModel):
"""
Single Loop Run Entity.
"""

node_id: str
inputs: Mapping

single_loop_run: Optional[SingleLoopRunEntity] = None


class WorkflowAppGenerateEntity(AppGenerateEntity):
"""
@@ -206,3 +216,13 @@ class WorkflowAppGenerateEntity(AppGenerateEntity):
inputs: dict

single_iteration_run: Optional[SingleIterationRunEntity] = None

class SingleLoopRunEntity(BaseModel):
"""
Single Loop Run Entity.
"""

node_id: str
inputs: dict

single_loop_run: Optional[SingleLoopRunEntity] = None

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

@@ -30,6 +30,9 @@ class QueueEvent(StrEnum):
ITERATION_START = "iteration_start"
ITERATION_NEXT = "iteration_next"
ITERATION_COMPLETED = "iteration_completed"
LOOP_START = "loop_start"
LOOP_NEXT = "loop_next"
LOOP_COMPLETED = "loop_completed"
NODE_STARTED = "node_started"
NODE_SUCCEEDED = "node_succeeded"
NODE_FAILED = "node_failed"
@@ -149,6 +152,89 @@ class QueueIterationCompletedEvent(AppQueueEvent):
error: Optional[str] = None


class QueueLoopStartEvent(AppQueueEvent):
"""
QueueLoopStartEvent entity
"""

event: QueueEvent = QueueEvent.LOOP_START
node_execution_id: str
node_id: str
node_type: NodeType
node_data: BaseNodeData
parallel_id: Optional[str] = None
"""parallel id if node is in parallel"""
parallel_start_node_id: Optional[str] = None
"""parallel start node id if node is in parallel"""
parent_parallel_id: Optional[str] = None
"""parent parallel id if node is in parallel"""
parent_parallel_start_node_id: Optional[str] = None
"""parent parallel start node id if node is in parallel"""
start_at: datetime

node_run_index: int
inputs: Optional[Mapping[str, Any]] = None
predecessor_node_id: Optional[str] = None
metadata: Optional[Mapping[str, Any]] = None


class QueueLoopNextEvent(AppQueueEvent):
"""
QueueLoopNextEvent entity
"""

event: QueueEvent = QueueEvent.LOOP_NEXT

index: int
node_execution_id: str
node_id: str
node_type: NodeType
node_data: BaseNodeData
parallel_id: Optional[str] = None
"""parallel id if node is in parallel"""
parallel_start_node_id: Optional[str] = None
"""parallel start node id if node is in parallel"""
parent_parallel_id: Optional[str] = None
"""parent parallel id if node is in parallel"""
parent_parallel_start_node_id: Optional[str] = None
"""parent parallel start node id if node is in parallel"""
parallel_mode_run_id: Optional[str] = None
"""iteratoin run in parallel mode run id"""
node_run_index: int
output: Optional[Any] = None # output for the current loop
duration: Optional[float] = None


class QueueLoopCompletedEvent(AppQueueEvent):
"""
QueueLoopCompletedEvent entity
"""

event: QueueEvent = QueueEvent.LOOP_COMPLETED

node_execution_id: str
node_id: str
node_type: NodeType
node_data: BaseNodeData
parallel_id: Optional[str] = None
"""parallel id if node is in parallel"""
parallel_start_node_id: Optional[str] = None
"""parallel start node id if node is in parallel"""
parent_parallel_id: Optional[str] = None
"""parent parallel id if node is in parallel"""
parent_parallel_start_node_id: Optional[str] = None
"""parent parallel start node id if node is in parallel"""
start_at: datetime

node_run_index: int
inputs: Optional[Mapping[str, Any]] = None
outputs: Optional[Mapping[str, Any]] = None
metadata: Optional[Mapping[str, Any]] = None
steps: int = 0

error: Optional[str] = None


class QueueTextChunkEvent(AppQueueEvent):
"""
QueueTextChunkEvent entity
@@ -160,6 +246,8 @@ class QueueTextChunkEvent(AppQueueEvent):
"""from variable selector"""
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""


class QueueAgentMessageEvent(AppQueueEvent):
@@ -189,6 +277,8 @@ class QueueRetrieverResourcesEvent(AppQueueEvent):
retriever_resources: list[dict]
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""


class QueueAnnotationReplyEvent(AppQueueEvent):
@@ -278,6 +368,8 @@ class QueueNodeStartedEvent(AppQueueEvent):
"""parent parallel start node id if node is in parallel"""
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""
start_at: datetime
parallel_mode_run_id: Optional[str] = None
"""iteratoin run in parallel mode run id"""
@@ -305,6 +397,8 @@ class QueueNodeSucceededEvent(AppQueueEvent):
"""parent parallel start node id if node is in parallel"""
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""
start_at: datetime

inputs: Optional[Mapping[str, Any]] = None
@@ -315,6 +409,8 @@ class QueueNodeSucceededEvent(AppQueueEvent):
error: Optional[str] = None
"""single iteration duration map"""
iteration_duration_map: Optional[dict[str, float]] = None
"""single loop duration map"""
loop_duration_map: Optional[dict[str, float]] = None


class QueueAgentLogEvent(AppQueueEvent):
@@ -368,6 +464,41 @@ class QueueNodeInIterationFailedEvent(AppQueueEvent):
"""parent parallel start node id if node is in parallel"""
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""
start_at: datetime

inputs: Optional[Mapping[str, Any]] = None
process_data: Optional[Mapping[str, Any]] = None
outputs: Optional[Mapping[str, Any]] = None
execution_metadata: Optional[Mapping[NodeRunMetadataKey, Any]] = None

error: str


class QueueNodeInLoopFailedEvent(AppQueueEvent):
"""
QueueNodeInLoopFailedEvent entity
"""

event: QueueEvent = QueueEvent.NODE_FAILED

node_execution_id: str
node_id: str
node_type: NodeType
node_data: BaseNodeData
parallel_id: Optional[str] = None
"""parallel id if node is in parallel"""
parallel_start_node_id: Optional[str] = None
"""parallel start node id if node is in parallel"""
parent_parallel_id: Optional[str] = None
"""parent parallel id if node is in parallel"""
parent_parallel_start_node_id: Optional[str] = None
"""parent parallel start node id if node is in parallel"""
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""
start_at: datetime

inputs: Optional[Mapping[str, Any]] = None
@@ -399,6 +530,8 @@ class QueueNodeExceptionEvent(AppQueueEvent):
"""parent parallel start node id if node is in parallel"""
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""
start_at: datetime

inputs: Optional[Mapping[str, Any]] = None
@@ -430,6 +563,8 @@ class QueueNodeFailedEvent(AppQueueEvent):
"""parent parallel start node id if node is in parallel"""
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""
start_at: datetime

inputs: Optional[Mapping[str, Any]] = None
@@ -549,6 +684,8 @@ class QueueParallelBranchRunStartedEvent(AppQueueEvent):
"""parent parallel start node id if node is in parallel"""
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""


class QueueParallelBranchRunSucceededEvent(AppQueueEvent):
@@ -566,6 +703,8 @@ class QueueParallelBranchRunSucceededEvent(AppQueueEvent):
"""parent parallel start node id if node is in parallel"""
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""


class QueueParallelBranchRunFailedEvent(AppQueueEvent):
@@ -583,4 +722,6 @@ class QueueParallelBranchRunFailedEvent(AppQueueEvent):
"""parent parallel start node id if node is in parallel"""
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""
error: str

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

@@ -59,6 +59,9 @@ class StreamEvent(Enum):
ITERATION_STARTED = "iteration_started"
ITERATION_NEXT = "iteration_next"
ITERATION_COMPLETED = "iteration_completed"
LOOP_STARTED = "loop_started"
LOOP_NEXT = "loop_next"
LOOP_COMPLETED = "loop_completed"
TEXT_CHUNK = "text_chunk"
TEXT_REPLACE = "text_replace"
AGENT_LOG = "agent_log"
@@ -248,6 +251,7 @@ class NodeStartStreamResponse(StreamResponse):
parent_parallel_id: Optional[str] = None
parent_parallel_start_node_id: Optional[str] = None
iteration_id: Optional[str] = None
loop_id: Optional[str] = None
parallel_run_id: Optional[str] = None
agent_strategy: Optional[AgentNodeStrategyInit] = None

@@ -275,6 +279,7 @@ class NodeStartStreamResponse(StreamResponse):
"parent_parallel_id": self.data.parent_parallel_id,
"parent_parallel_start_node_id": self.data.parent_parallel_start_node_id,
"iteration_id": self.data.iteration_id,
"loop_id": self.data.loop_id,
},
}

@@ -310,6 +315,7 @@ class NodeFinishStreamResponse(StreamResponse):
parent_parallel_id: Optional[str] = None
parent_parallel_start_node_id: Optional[str] = None
iteration_id: Optional[str] = None
loop_id: Optional[str] = None

event: StreamEvent = StreamEvent.NODE_FINISHED
workflow_run_id: str
@@ -342,6 +348,7 @@ class NodeFinishStreamResponse(StreamResponse):
"parent_parallel_id": self.data.parent_parallel_id,
"parent_parallel_start_node_id": self.data.parent_parallel_start_node_id,
"iteration_id": self.data.iteration_id,
"loop_id": self.data.loop_id,
},
}

@@ -377,6 +384,7 @@ class NodeRetryStreamResponse(StreamResponse):
parent_parallel_id: Optional[str] = None
parent_parallel_start_node_id: Optional[str] = None
iteration_id: Optional[str] = None
loop_id: Optional[str] = None
retry_index: int = 0

event: StreamEvent = StreamEvent.NODE_RETRY
@@ -410,6 +418,7 @@ class NodeRetryStreamResponse(StreamResponse):
"parent_parallel_id": self.data.parent_parallel_id,
"parent_parallel_start_node_id": self.data.parent_parallel_start_node_id,
"iteration_id": self.data.iteration_id,
"loop_id": self.data.loop_id,
"retry_index": self.data.retry_index,
},
}
@@ -430,6 +439,7 @@ class ParallelBranchStartStreamResponse(StreamResponse):
parent_parallel_id: Optional[str] = None
parent_parallel_start_node_id: Optional[str] = None
iteration_id: Optional[str] = None
loop_id: Optional[str] = None
created_at: int

event: StreamEvent = StreamEvent.PARALLEL_BRANCH_STARTED
@@ -452,6 +462,7 @@ class ParallelBranchFinishedStreamResponse(StreamResponse):
parent_parallel_id: Optional[str] = None
parent_parallel_start_node_id: Optional[str] = None
iteration_id: Optional[str] = None
loop_id: Optional[str] = None
status: str
error: Optional[str] = None
created_at: int
@@ -548,6 +559,93 @@ class IterationNodeCompletedStreamResponse(StreamResponse):
data: Data


class LoopNodeStartStreamResponse(StreamResponse):
"""
NodeStartStreamResponse entity
"""

class Data(BaseModel):
"""
Data entity
"""

id: str
node_id: str
node_type: str
title: str
created_at: int
extras: dict = {}
metadata: Mapping = {}
inputs: Mapping = {}
parallel_id: Optional[str] = None
parallel_start_node_id: Optional[str] = None

event: StreamEvent = StreamEvent.LOOP_STARTED
workflow_run_id: str
data: Data


class LoopNodeNextStreamResponse(StreamResponse):
"""
NodeStartStreamResponse entity
"""

class Data(BaseModel):
"""
Data entity
"""

id: str
node_id: str
node_type: str
title: str
index: int
created_at: int
pre_loop_output: Optional[Any] = None
extras: dict = {}
parallel_id: Optional[str] = None
parallel_start_node_id: Optional[str] = None
parallel_mode_run_id: Optional[str] = None
duration: Optional[float] = None

event: StreamEvent = StreamEvent.LOOP_NEXT
workflow_run_id: str
data: Data


class LoopNodeCompletedStreamResponse(StreamResponse):
"""
NodeCompletedStreamResponse entity
"""

class Data(BaseModel):
"""
Data entity
"""

id: str
node_id: str
node_type: str
title: str
outputs: Optional[Mapping] = None
created_at: int
extras: Optional[dict] = None
inputs: Optional[Mapping] = None
status: WorkflowNodeExecutionStatus
error: Optional[str] = None
elapsed_time: float
total_tokens: int
execution_metadata: Optional[Mapping] = None
finished_at: int
steps: int
parallel_id: Optional[str] = None
parallel_start_node_id: Optional[str] = None

event: StreamEvent = StreamEvent.LOOP_COMPLETED
workflow_run_id: str
data: Data


class TextChunkStreamResponse(StreamResponse):
"""
TextChunkStreamResponse entity

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

@@ -14,9 +14,13 @@ from core.app.entities.queue_entities import (
QueueIterationCompletedEvent,
QueueIterationNextEvent,
QueueIterationStartEvent,
QueueLoopCompletedEvent,
QueueLoopNextEvent,
QueueLoopStartEvent,
QueueNodeExceptionEvent,
QueueNodeFailedEvent,
QueueNodeInIterationFailedEvent,
QueueNodeInLoopFailedEvent,
QueueNodeRetryEvent,
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
@@ -29,6 +33,9 @@ from core.app.entities.task_entities import (
IterationNodeCompletedStreamResponse,
IterationNodeNextStreamResponse,
IterationNodeStartStreamResponse,
LoopNodeCompletedStreamResponse,
LoopNodeNextStreamResponse,
LoopNodeStartStreamResponse,
NodeFinishStreamResponse,
NodeRetryStreamResponse,
NodeStartStreamResponse,
@@ -304,6 +311,7 @@ class WorkflowCycleManage:
{
NodeRunMetadataKey.PARALLEL_MODE_RUN_ID: event.parallel_mode_run_id,
NodeRunMetadataKey.ITERATION_ID: event.in_iteration_id,
NodeRunMetadataKey.LOOP_ID: event.in_loop_id,
}
)
workflow_node_execution.created_at = datetime.now(UTC).replace(tzinfo=None)
@@ -344,7 +352,10 @@ class WorkflowCycleManage:
self,
*,
session: Session,
event: QueueNodeFailedEvent | QueueNodeInIterationFailedEvent | QueueNodeExceptionEvent,
event: QueueNodeFailedEvent
| QueueNodeInIterationFailedEvent
| QueueNodeInLoopFailedEvent
| QueueNodeExceptionEvent,
) -> WorkflowNodeExecution:
"""
Workflow node execution failed
@@ -396,6 +407,7 @@ class WorkflowCycleManage:
origin_metadata = {
NodeRunMetadataKey.ITERATION_ID: event.in_iteration_id,
NodeRunMetadataKey.PARALLEL_MODE_RUN_ID: event.parallel_mode_run_id,
NodeRunMetadataKey.LOOP_ID: event.in_loop_id,
}
merged_metadata = (
{**jsonable_encoder(event.execution_metadata), **origin_metadata}
@@ -540,6 +552,7 @@ class WorkflowCycleManage:
parent_parallel_id=event.parent_parallel_id,
parent_parallel_start_node_id=event.parent_parallel_start_node_id,
iteration_id=event.in_iteration_id,
loop_id=event.in_loop_id,
parallel_run_id=event.parallel_mode_run_id,
agent_strategy=event.agent_strategy,
),
@@ -563,6 +576,7 @@ class WorkflowCycleManage:
event: QueueNodeSucceededEvent
| QueueNodeFailedEvent
| QueueNodeInIterationFailedEvent
| QueueNodeInLoopFailedEvent
| QueueNodeExceptionEvent,
task_id: str,
workflow_node_execution: WorkflowNodeExecution,
@@ -601,6 +615,7 @@ class WorkflowCycleManage:
parent_parallel_id=event.parent_parallel_id,
parent_parallel_start_node_id=event.parent_parallel_start_node_id,
iteration_id=event.in_iteration_id,
loop_id=event.in_loop_id,
),
)

@@ -646,6 +661,7 @@ class WorkflowCycleManage:
parent_parallel_id=event.parent_parallel_id,
parent_parallel_start_node_id=event.parent_parallel_start_node_id,
iteration_id=event.in_iteration_id,
loop_id=event.in_loop_id,
retry_index=event.retry_index,
),
)
@@ -664,6 +680,7 @@ class WorkflowCycleManage:
parent_parallel_id=event.parent_parallel_id,
parent_parallel_start_node_id=event.parent_parallel_start_node_id,
iteration_id=event.in_iteration_id,
loop_id=event.in_loop_id,
created_at=int(time.time()),
),
)
@@ -687,6 +704,7 @@ class WorkflowCycleManage:
parent_parallel_id=event.parent_parallel_id,
parent_parallel_start_node_id=event.parent_parallel_start_node_id,
iteration_id=event.in_iteration_id,
loop_id=event.in_loop_id,
status="succeeded" if isinstance(event, QueueParallelBranchRunSucceededEvent) else "failed",
error=event.error if isinstance(event, QueueParallelBranchRunFailedEvent) else None,
created_at=int(time.time()),
@@ -770,6 +788,83 @@ class WorkflowCycleManage:
),
)

def _workflow_loop_start_to_stream_response(
self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueLoopStartEvent
) -> LoopNodeStartStreamResponse:
# receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
_ = session
return LoopNodeStartStreamResponse(
task_id=task_id,
workflow_run_id=workflow_run.id,
data=LoopNodeStartStreamResponse.Data(
id=event.node_id,
node_id=event.node_id,
node_type=event.node_type.value,
title=event.node_data.title,
created_at=int(time.time()),
extras={},
inputs=event.inputs or {},
metadata=event.metadata or {},
parallel_id=event.parallel_id,
parallel_start_node_id=event.parallel_start_node_id,
),
)

def _workflow_loop_next_to_stream_response(
self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueLoopNextEvent
) -> LoopNodeNextStreamResponse:
# receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
_ = session
return LoopNodeNextStreamResponse(
task_id=task_id,
workflow_run_id=workflow_run.id,
data=LoopNodeNextStreamResponse.Data(
id=event.node_id,
node_id=event.node_id,
node_type=event.node_type.value,
title=event.node_data.title,
index=event.index,
pre_loop_output=event.output,
created_at=int(time.time()),
extras={},
parallel_id=event.parallel_id,
parallel_start_node_id=event.parallel_start_node_id,
parallel_mode_run_id=event.parallel_mode_run_id,
duration=event.duration,
),
)

def _workflow_loop_completed_to_stream_response(
self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueLoopCompletedEvent
) -> LoopNodeCompletedStreamResponse:
# receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
_ = session
return LoopNodeCompletedStreamResponse(
task_id=task_id,
workflow_run_id=workflow_run.id,
data=LoopNodeCompletedStreamResponse.Data(
id=event.node_id,
node_id=event.node_id,
node_type=event.node_type.value,
title=event.node_data.title,
outputs=event.outputs,
created_at=int(time.time()),
extras={},
inputs=event.inputs or {},
status=WorkflowNodeExecutionStatus.SUCCEEDED
if event.error is None
else WorkflowNodeExecutionStatus.FAILED,
error=None,
elapsed_time=(datetime.now(UTC).replace(tzinfo=None) - event.start_at).total_seconds(),
total_tokens=event.metadata.get("total_tokens", 0) if event.metadata else 0,
execution_metadata=event.metadata,
finished_at=int(time.time()),
steps=event.steps,
parallel_id=event.parallel_id,
parallel_start_node_id=event.parallel_start_node_id,
),
)

def _fetch_files_from_node_outputs(self, outputs_dict: Mapping[str, Any]) -> Sequence[Mapping[str, Any]]:
"""
Fetch files from node outputs

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

@@ -11,6 +11,10 @@ from core.workflow.graph_engine.entities.event import (
IterationRunNextEvent,
IterationRunStartedEvent,
IterationRunSucceededEvent,
LoopRunFailedEvent,
LoopRunNextEvent,
LoopRunStartedEvent,
LoopRunSucceededEvent,
NodeRunFailedEvent,
NodeRunStartedEvent,
NodeRunStreamChunkEvent,
@@ -62,6 +66,12 @@ class WorkflowLoggingCallback(WorkflowCallback):
self.on_workflow_iteration_next(event=event)
elif isinstance(event, IterationRunSucceededEvent | IterationRunFailedEvent):
self.on_workflow_iteration_completed(event=event)
elif isinstance(event, LoopRunStartedEvent):
self.on_workflow_loop_started(event=event)
elif isinstance(event, LoopRunNextEvent):
self.on_workflow_loop_next(event=event)
elif isinstance(event, LoopRunSucceededEvent | LoopRunFailedEvent):
self.on_workflow_loop_completed(event=event)
else:
self.print_text(f"\n[{event.__class__.__name__}]", color="blue")

@@ -160,6 +170,8 @@ class WorkflowLoggingCallback(WorkflowCallback):
self.print_text(f"Branch ID: {event.parallel_start_node_id}", color="blue")
if event.in_iteration_id:
self.print_text(f"Iteration ID: {event.in_iteration_id}", color="blue")
if event.in_loop_id:
self.print_text(f"Loop ID: {event.in_loop_id}", color="blue")

def on_workflow_parallel_completed(
self, event: ParallelBranchRunSucceededEvent | ParallelBranchRunFailedEvent
@@ -182,6 +194,8 @@ class WorkflowLoggingCallback(WorkflowCallback):
self.print_text(f"Branch ID: {event.parallel_start_node_id}", color=color)
if event.in_iteration_id:
self.print_text(f"Iteration ID: {event.in_iteration_id}", color=color)
if event.in_loop_id:
self.print_text(f"Loop ID: {event.in_loop_id}", color=color)

if isinstance(event, ParallelBranchRunFailedEvent):
self.print_text(f"Error: {event.error}", color=color)
@@ -213,6 +227,31 @@ class WorkflowLoggingCallback(WorkflowCallback):
)
self.print_text(f"Node ID: {event.iteration_id}", color="blue")

def on_workflow_loop_started(self, event: LoopRunStartedEvent) -> None:
"""
Publish loop started
"""
self.print_text("\n[LoopRunStartedEvent]", color="blue")
self.print_text(f"Loop Node ID: {event.loop_id}", color="blue")

def on_workflow_loop_next(self, event: LoopRunNextEvent) -> None:
"""
Publish loop next
"""
self.print_text("\n[LoopRunNextEvent]", color="blue")
self.print_text(f"Loop Node ID: {event.loop_id}", color="blue")
self.print_text(f"Loop Index: {event.index}", color="blue")

def on_workflow_loop_completed(self, event: LoopRunSucceededEvent | LoopRunFailedEvent) -> None:
"""
Publish loop completed
"""
self.print_text(
"\n[LoopRunSucceededEvent]" if isinstance(event, LoopRunSucceededEvent) else "\n[LoopRunFailedEvent]",
color="blue",
)
self.print_text(f"Node ID: {event.loop_id}", color="blue")

def print_text(self, text: str, color: Optional[str] = None, end: str = "\n") -> None:
"""Print text with highlighting and no end characters."""
text_to_print = self._get_colored_text(text, color) if color else text

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

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



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

@@ -3,7 +3,7 @@ from typing import Optional
from pydantic import BaseModel

from core.app.entities.app_invoke_entities import InvokeFrom
from core.workflow.nodes.base import BaseIterationState, BaseNode
from core.workflow.nodes.base import BaseIterationState, BaseLoopState, BaseNode
from models.enums import UserFrom
from models.workflow import Workflow, WorkflowType

@@ -41,11 +41,13 @@ class WorkflowRunState:
class NodeRun(BaseModel):
node_id: str
iteration_node_id: str
loop_node_id: str

workflow_node_runs: list[NodeRun]
workflow_node_steps: int

current_iteration_state: Optional[BaseIterationState]
current_loop_state: Optional[BaseLoopState]

def __init__(
self,
@@ -74,3 +76,4 @@ class WorkflowRunState:
self.workflow_node_steps = 1
self.workflow_node_runs = []
self.current_iteration_state = None
self.current_loop_state = None

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

@@ -63,6 +63,8 @@ class BaseNodeEvent(GraphEngineEvent):
"""parent parallel start node id if node is in parallel"""
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""


class NodeRunStartedEvent(BaseNodeEvent):
@@ -100,6 +102,10 @@ class NodeInIterationFailedEvent(BaseNodeEvent):
error: str = Field(..., description="error")


class NodeInLoopFailedEvent(BaseNodeEvent):
error: str = Field(..., description="error")


class NodeRunRetryEvent(NodeRunStartedEvent):
error: str = Field(..., description="error")
retry_index: int = Field(..., description="which retry attempt is about to be performed")
@@ -122,6 +128,8 @@ class BaseParallelBranchEvent(GraphEngineEvent):
"""parent parallel start node id if node is in parallel"""
in_iteration_id: Optional[str] = None
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""


class ParallelBranchRunStartedEvent(BaseParallelBranchEvent):
@@ -189,6 +197,59 @@ class IterationRunFailedEvent(BaseIterationEvent):
error: str = Field(..., description="failed reason")


###########################################
# Loop Events
###########################################


class BaseLoopEvent(GraphEngineEvent):
loop_id: str = Field(..., description="loop node execution id")
loop_node_id: str = Field(..., description="loop node id")
loop_node_type: NodeType = Field(..., description="node type, loop or loop")
loop_node_data: BaseNodeData = Field(..., description="node data")
parallel_id: Optional[str] = None
"""parallel id if node is in parallel"""
parallel_start_node_id: Optional[str] = None
"""parallel start node id if node is in parallel"""
parent_parallel_id: Optional[str] = None
"""parent parallel id if node is in parallel"""
parent_parallel_start_node_id: Optional[str] = None
"""parent parallel start node id if node is in parallel"""
parallel_mode_run_id: Optional[str] = None
"""loop run in parallel mode run id"""


class LoopRunStartedEvent(BaseLoopEvent):
start_at: datetime = Field(..., description="start at")
inputs: Optional[Mapping[str, Any]] = None
metadata: Optional[Mapping[str, Any]] = None
predecessor_node_id: Optional[str] = None


class LoopRunNextEvent(BaseLoopEvent):
index: int = Field(..., description="index")
pre_loop_output: Optional[Any] = None
duration: Optional[float] = None


class LoopRunSucceededEvent(BaseLoopEvent):
start_at: datetime = Field(..., description="start at")
inputs: Optional[Mapping[str, Any]] = None
outputs: Optional[Mapping[str, Any]] = None
metadata: Optional[Mapping[str, Any]] = None
steps: int = 0
loop_duration_map: Optional[dict[str, float]] = None


class LoopRunFailedEvent(BaseLoopEvent):
start_at: datetime = Field(..., description="start at")
inputs: Optional[Mapping[str, Any]] = None
outputs: Optional[Mapping[str, Any]] = None
metadata: Optional[Mapping[str, Any]] = None
steps: int = 0
error: str = Field(..., description="failed reason")


###########################################
# Agent Events
###########################################
@@ -209,4 +270,4 @@ class AgentLogEvent(BaseAgentEvent):
metadata: Optional[Mapping[str, Any]] = Field(default=None, description="metadata")


InNodeEvent = BaseNodeEvent | BaseParallelBranchEvent | BaseIterationEvent | BaseAgentEvent
InNodeEvent = BaseNodeEvent | BaseParallelBranchEvent | BaseIterationEvent | BaseAgentEvent | BaseLoopEvent

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

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

yield item
else:

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

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

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

@@ -35,7 +35,7 @@ class AnswerStreamProcessor(StreamProcessor):

yield event
elif isinstance(event, NodeRunStreamChunkEvent):
if event.in_iteration_id:
if event.in_iteration_id or event.in_loop_id:
yield event
continue


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

@@ -1,4 +1,11 @@
from .entities import BaseIterationNodeData, BaseIterationState, BaseNodeData
from .entities import BaseIterationNodeData, BaseIterationState, BaseLoopNodeData, BaseLoopState, BaseNodeData
from .node import BaseNode

__all__ = ["BaseIterationNodeData", "BaseIterationState", "BaseNode", "BaseNodeData"]
__all__ = [
"BaseIterationNodeData",
"BaseIterationState",
"BaseLoopNodeData",
"BaseLoopState",
"BaseNode",
"BaseNodeData",
]

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

@@ -147,3 +147,18 @@ class BaseIterationState(BaseModel):
pass

metadata: MetaData


class BaseLoopNodeData(BaseNodeData):
start_node_id: Optional[str] = None


class BaseLoopState(BaseModel):
loop_node_id: str
index: int
inputs: dict

class MetaData(BaseModel):
pass

metadata: MetaData

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

@@ -33,7 +33,7 @@ class EndStreamProcessor(StreamProcessor):

yield event
elif isinstance(event, NodeRunStreamChunkEvent):
if event.in_iteration_id:
if event.in_iteration_id or event.in_loop_id:
if self.has_output and event.node_id not in self.output_node_ids:
event.chunk_content = "\n" + event.chunk_content


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

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

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

@@ -0,0 +1,5 @@
from .entities import LoopNodeData
from .loop_node import LoopNode
from .loop_start_node import LoopStartNode

__all__ = ["LoopNode", "LoopNodeData", "LoopStartNode"]

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

@@ -1,13 +1,54 @@
from core.workflow.nodes.base import BaseIterationNodeData, BaseIterationState
from typing import Any, Literal, Optional

from pydantic import Field

class LoopNodeData(BaseIterationNodeData):
from core.workflow.nodes.base import BaseLoopNodeData, BaseLoopState, BaseNodeData
from core.workflow.utils.condition.entities import Condition


class LoopNodeData(BaseLoopNodeData):
"""
Loop Node Data.
"""

loop_count: int # Maximum number of loops
break_conditions: list[Condition] # Conditions to break the loop
logical_operator: Literal["and", "or"]

class LoopState(BaseIterationState):

class LoopStartNodeData(BaseNodeData):
"""
Loop Start Node Data.
"""

pass


class LoopState(BaseLoopState):
"""
Loop State.
"""

outputs: list[Any] = Field(default_factory=list)
current_output: Optional[Any] = None

class MetaData(BaseLoopState.MetaData):
"""
Data.
"""

loop_length: int

def get_last_output(self) -> Optional[Any]:
"""
Get last output.
"""
if self.outputs:
return self.outputs[-1]
return None

def get_current_output(self) -> Optional[Any]:
"""
Get current output.
"""
return self.current_output

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

@@ -1,9 +1,35 @@
from typing import Any
import logging
from collections.abc import Generator, Mapping, Sequence
from datetime import datetime, timezone
from typing import Any, cast

from configs import dify_config
from core.variables import IntegerSegment
from core.workflow.entities.node_entities import NodeRunMetadataKey, NodeRunResult
from core.workflow.graph_engine.entities.event import (
BaseGraphEvent,
BaseNodeEvent,
BaseParallelBranchEvent,
GraphRunFailedEvent,
InNodeEvent,
LoopRunFailedEvent,
LoopRunNextEvent,
LoopRunStartedEvent,
LoopRunSucceededEvent,
NodeRunFailedEvent,
NodeRunStartedEvent,
NodeRunStreamChunkEvent,
NodeRunSucceededEvent,
)
from core.workflow.graph_engine.entities.graph import Graph
from core.workflow.nodes.base import BaseNode
from core.workflow.nodes.enums import NodeType
from core.workflow.nodes.loop.entities import LoopNodeData, LoopState
from core.workflow.utils.condition.entities import Condition
from core.workflow.nodes.event import NodeEvent, RunCompletedEvent
from core.workflow.nodes.loop.entities import LoopNodeData
from core.workflow.utils.condition.processor import ConditionProcessor
from models.workflow import WorkflowNodeExecutionStatus

logger = logging.getLogger(__name__)


class LoopNode(BaseNode[LoopNodeData]):
@@ -14,24 +40,323 @@ class LoopNode(BaseNode[LoopNodeData]):
_node_data_cls = LoopNodeData
_node_type = NodeType.LOOP

def _run(self) -> LoopState: # type: ignore
return super()._run() # type: ignore
def _run(self) -> Generator[NodeEvent | InNodeEvent, None, None]:
"""Run the node."""
# Get inputs
loop_count = self.node_data.loop_count
break_conditions = self.node_data.break_conditions
logical_operator = self.node_data.logical_operator

inputs = {"loop_count": loop_count}

if not self.node_data.start_node_id:
raise ValueError(f"field start_node_id in loop {self.node_id} not found")

# Initialize graph
loop_graph = Graph.init(graph_config=self.graph_config, root_node_id=self.node_data.start_node_id)
if not loop_graph:
raise ValueError("loop graph not found")

# Initialize variable pool
variable_pool = self.graph_runtime_state.variable_pool
variable_pool.add([self.node_id, "index"], 0)

from core.workflow.graph_engine.graph_engine import GraphEngine

graph_engine = GraphEngine(
tenant_id=self.tenant_id,
app_id=self.app_id,
workflow_type=self.workflow_type,
workflow_id=self.workflow_id,
user_id=self.user_id,
user_from=self.user_from,
invoke_from=self.invoke_from,
call_depth=self.workflow_call_depth,
graph=loop_graph,
graph_config=self.graph_config,
variable_pool=variable_pool,
max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS,
max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME,
thread_pool_id=self.thread_pool_id,
)

start_at = datetime.now(timezone.utc).replace(tzinfo=None)
condition_processor = ConditionProcessor()

# Start Loop event
yield LoopRunStartedEvent(
loop_id=self.id,
loop_node_id=self.node_id,
loop_node_type=self.node_type,
loop_node_data=self.node_data,
start_at=start_at,
inputs=inputs,
metadata={"loop_length": loop_count},
predecessor_node_id=self.previous_node_id,
)

yield LoopRunNextEvent(
loop_id=self.id,
loop_node_id=self.node_id,
loop_node_type=self.node_type,
loop_node_data=self.node_data,
index=0,
pre_loop_output=None,
)

try:
check_break_result = False
for i in range(loop_count):
# Run workflow
rst = graph_engine.run()
current_index_variable = variable_pool.get([self.node_id, "index"])
if not isinstance(current_index_variable, IntegerSegment):
raise ValueError(f"loop {self.node_id} current index not found")
current_index = current_index_variable.value

check_break_result = False

for event in rst:
if isinstance(event, (BaseNodeEvent | BaseParallelBranchEvent)) and not event.in_loop_id:
event.in_loop_id = self.node_id

if (
isinstance(event, BaseNodeEvent)
and event.node_type == NodeType.LOOP_START
and not isinstance(event, NodeRunStreamChunkEvent)
):
continue

if isinstance(event, NodeRunSucceededEvent):
yield self._handle_event_metadata(event=event, iter_run_index=current_index)

# Check if all variables in break conditions exist
exists_variable = False
for condition in break_conditions:
if not self.graph_runtime_state.variable_pool.get(condition.variable_selector):
exists_variable = False
break
else:
exists_variable = True
if exists_variable:
input_conditions, group_result, check_break_result = condition_processor.process_conditions(
variable_pool=self.graph_runtime_state.variable_pool,
conditions=break_conditions,
operator=logical_operator,
)
if check_break_result:
break

elif isinstance(event, BaseGraphEvent):
if isinstance(event, GraphRunFailedEvent):
# Loop run failed
yield LoopRunFailedEvent(
loop_id=self.id,
loop_node_id=self.node_id,
loop_node_type=self.node_type,
loop_node_data=self.node_data,
start_at=start_at,
inputs=inputs,
steps=i,
metadata={
NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens,
"completed_reason": "error",
},
error=event.error,
)
yield RunCompletedEvent(
run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
error=event.error,
metadata={
NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens
},
)
)
return
elif isinstance(event, NodeRunFailedEvent):
# Loop run failed
yield event
yield LoopRunFailedEvent(
loop_id=self.id,
loop_node_id=self.node_id,
loop_node_type=self.node_type,
loop_node_data=self.node_data,
start_at=start_at,
inputs=inputs,
steps=i,
metadata={
NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens,
"completed_reason": "error",
},
error=event.error,
)
yield RunCompletedEvent(
run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
error=event.error,
metadata={
NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens
},
)
)
return
else:
yield self._handle_event_metadata(event=cast(InNodeEvent, event), iter_run_index=current_index)

# Remove all nodes outputs from variable pool
for node_id in loop_graph.node_ids:
variable_pool.remove([node_id])

if check_break_result:
break

# Move to next loop
next_index = current_index + 1
variable_pool.add([self.node_id, "index"], next_index)

yield LoopRunNextEvent(
loop_id=self.id,
loop_node_id=self.node_id,
loop_node_type=self.node_type,
loop_node_data=self.node_data,
index=next_index,
pre_loop_output=None,
)

# Loop completed successfully
yield LoopRunSucceededEvent(
loop_id=self.id,
loop_node_id=self.node_id,
loop_node_type=self.node_type,
loop_node_data=self.node_data,
start_at=start_at,
inputs=inputs,
steps=loop_count,
metadata={
NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens,
"completed_reason": "loop_break" if check_break_result else "loop_completed",
},
)

yield RunCompletedEvent(
run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
metadata={NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens},
)
)

except Exception as e:
# Loop failed
logger.exception("Loop run failed")
yield LoopRunFailedEvent(
loop_id=self.id,
loop_node_id=self.node_id,
loop_node_type=self.node_type,
loop_node_data=self.node_data,
start_at=start_at,
inputs=inputs,
steps=loop_count,
metadata={
"total_tokens": graph_engine.graph_runtime_state.total_tokens,
"completed_reason": "error",
},
error=str(e),
)

yield RunCompletedEvent(
run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
error=str(e),
metadata={NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens},
)
)

finally:
# Clean up
variable_pool.remove([self.node_id, "index"])

def _handle_event_metadata(
self,
*,
event: BaseNodeEvent | InNodeEvent,
iter_run_index: int,
) -> NodeRunStartedEvent | BaseNodeEvent | InNodeEvent:
"""
add iteration metadata to event.
"""
if not isinstance(event, BaseNodeEvent):
return event
if event.route_node_state.node_run_result:
metadata = event.route_node_state.node_run_result.metadata
if not metadata:
metadata = {}
if NodeRunMetadataKey.LOOP_ID not in metadata:
metadata = {
**metadata,
NodeRunMetadataKey.LOOP_ID: self.node_id,
NodeRunMetadataKey.LOOP_INDEX: iter_run_index,
}
event.route_node_state.node_run_result.metadata = metadata
return event

@classmethod
def get_conditions(cls, node_config: dict[str, Any]) -> list[Condition]:
def _extract_variable_selector_to_variable_mapping(
cls,
*,
graph_config: Mapping[str, Any],
node_id: str,
node_data: LoopNodeData,
) -> Mapping[str, Sequence[str]]:
"""
Get conditions.
Extract variable selector to variable mapping
:param graph_config: graph config
:param node_id: node id
:param node_data: node data
:return:
"""
node_id = node_config.get("id")
if not node_id:
return []

# TODO waiting for implementation
return [
Condition( # type: ignore
variable_selector=[node_id, "index"],
comparison_operator="≤",
value_type="value_selector",
value_selector=[],
)
]
variable_mapping = {}

# init graph
loop_graph = Graph.init(graph_config=graph_config, root_node_id=node_data.start_node_id)

if not loop_graph:
raise ValueError("loop graph not found")

for sub_node_id, sub_node_config in loop_graph.node_id_config_mapping.items():
if sub_node_config.get("data", {}).get("loop_id") != node_id:
continue

# variable selector to variable mapping
try:
# Get node class
from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING

node_type = NodeType(sub_node_config.get("data", {}).get("type"))
if node_type not in NODE_TYPE_CLASSES_MAPPING:
continue
node_version = sub_node_config.get("data", {}).get("version", "1")
node_cls = NODE_TYPE_CLASSES_MAPPING[node_type][node_version]

sub_node_variable_mapping = node_cls.extract_variable_selector_to_variable_mapping(
graph_config=graph_config, config=sub_node_config
)
sub_node_variable_mapping = cast(dict[str, Sequence[str]], sub_node_variable_mapping)
except NotImplementedError:
sub_node_variable_mapping = {}

# remove loop variables
sub_node_variable_mapping = {
sub_node_id + "." + key: value
for key, value in sub_node_variable_mapping.items()
if value[0] != node_id
}

variable_mapping.update(sub_node_variable_mapping)

# remove variable out from loop
variable_mapping = {
key: value for key, value in variable_mapping.items() if value[0] not in loop_graph.node_ids
}

return variable_mapping

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

@@ -0,0 +1,20 @@
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.nodes.base import BaseNode
from core.workflow.nodes.enums import NodeType
from core.workflow.nodes.loop.entities import LoopStartNodeData
from models.workflow import WorkflowNodeExecutionStatus


class LoopStartNode(BaseNode):
"""
Loop Start Node.
"""

_node_data_cls = LoopStartNodeData
_node_type = NodeType.LOOP_START

def _run(self) -> NodeRunResult:
"""
Run the node.
"""
return NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED)

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

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

+ 19
- 0
api/services/app_generate_service.py Visa fil

@@ -137,6 +137,25 @@ class AppGenerateService:
else:
raise ValueError(f"Invalid app mode {app_model.mode}")

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

@classmethod
def generate_more_like_this(
cls,

+ 3
- 0
docker/.env.example Visa fil

@@ -720,6 +720,9 @@ SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128
# SSRF Proxy server HTTPS URL
SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128

# Maximum loop count in the workflow
LOOP_NODE_MAX_COUNT=100

# ------------------------------
# Environment Variables for web Service
# ------------------------------

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

@@ -67,6 +67,7 @@ services:
TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-}
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-}
PM2_INSTANCES: ${PM2_INSTANCES:-2}
LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}

# The postgres database.
db:

+ 2
- 0
docker/docker-compose.yaml Visa fil

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

# The postgres database.
db:

+ 3
- 0
web/.env.example Visa fil

@@ -37,3 +37,6 @@ NEXT_PUBLIC_TOP_K_MAX_VALUE=10

# The maximum number of tokens for segmentation
NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000

# Maximum loop count in the workflow
NEXT_PUBLIC_LOOP_NODE_MAX_COUNT=100

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

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

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

@@ -305,7 +305,7 @@ export const useChat = (
else
ttsUrl = `/apps/${params.appId}/text-to-audio`
}
const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => {})
const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => { })
ssePost(
url,
{
@@ -537,6 +537,9 @@ export const useChat = (
if (nodeStartedData.iteration_id)
return

if (data.loop_id)
return

responseItem.workflowProcess!.tracing!.push({
...nodeStartedData,
status: WorkflowRunningStatus.Running,
@@ -552,6 +555,9 @@ export const useChat = (
if (nodeFinishedData.iteration_id)
return

if (data.loop_id)
return

const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
if (!item.execution_metadata?.parallel_id)
return item.node_id === nodeFinishedData.node_id
@@ -576,6 +582,35 @@ export const useChat = (
onTTSEnd: (messageId: string, audio: string) => {
player.playAudioWithAudio(audio, false)
},
onLoopStart: ({ data: loopStartedData }) => {
responseItem.workflowProcess!.tracing!.push({
...loopStartedData,
status: WorkflowRunningStatus.Running,
} as any)
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onLoopFinish: ({ data: loopFinishedData }) => {
const tracing = responseItem.workflowProcess!.tracing!
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
tracing[loopIndex] = {
...tracing[loopIndex],
...loopFinishedData,
status: WorkflowRunningStatus.Succeeded,
} as any

updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
})
return true
}, [

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

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

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

@@ -0,0 +1,16 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY

import * as React from 'react'
import data from './Loop.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'

const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)

Icon.displayName = 'Loop'

export default Icon

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

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

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

@@ -0,0 +1,16 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY

import * as React from 'react'
import data from './LoopStart.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'

const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)

Icon.displayName = 'LoopStart'

export default Icon

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

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

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

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

if (data.loop_id)
return

setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
draft.tracing!.push({
@@ -257,6 +289,9 @@ const Result: FC<IResultProps> = ({
if (data.iteration_id)
return

if (data.loop_id)
return

setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
const currentIndex = draft.tracing!.findIndex(trace => trace.node_id === data.node_id
&& (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || trace.parallel_id === data.execution_metadata?.parallel_id))

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

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

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

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

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

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

if (candidateNode.data.type === BlockEnum.Loop)
draft.push(getLoopStartNode(candidateNode.id))
})
setNodes(newNodes)
if (candidateNode.type === CUSTOM_NOTE_NODE)

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

@@ -15,10 +15,12 @@ import VariableAssignerDefault from './nodes/variable-assigner/default'
import AssignerDefault from './nodes/assigner/default'
import EndNodeDefault from './nodes/end/default'
import IterationDefault from './nodes/iteration/default'
import LoopDefault from './nodes/loop/default'
import DocExtractorDefault from './nodes/document-extractor/default'
import ListFilterDefault from './nodes/list-operator/default'
import IterationStartDefault from './nodes/iteration-start/default'
import AgentDefault from './nodes/agent/default'
import LoopStartDefault from './nodes/loop-start/default'

type NodesExtraData = {
author: string
@@ -102,6 +104,24 @@ export const NODES_EXTRA_DATA: Record<BlockEnum, NodesExtraData> = {
getAvailableNextNodes: IterationStartDefault.getAvailableNextNodes,
checkValid: IterationStartDefault.checkValid,
},
[BlockEnum.Loop]: {
author: 'AICT-Team',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: LoopDefault.getAvailablePrevNodes,
getAvailableNextNodes: LoopDefault.getAvailableNextNodes,
checkValid: LoopDefault.checkValid,
},
[BlockEnum.LoopStart]: {
author: 'AICT-Team',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: LoopStartDefault.getAvailablePrevNodes,
getAvailableNextNodes: LoopStartDefault.getAvailableNextNodes,
checkValid: LoopStartDefault.checkValid,
},
[BlockEnum.Code]: {
author: 'Dify',
about: '',
@@ -265,6 +285,18 @@ export const NODES_INITIAL_DATA = {
desc: '',
...IterationStartDefault.defaultValue,
},
[BlockEnum.Loop]: {
type: BlockEnum.Loop,
title: '',
desc: '',
...LoopDefault.defaultValue,
},
[BlockEnum.LoopStart]: {
type: BlockEnum.LoopStart,
title: '',
desc: '',
...LoopStartDefault.defaultValue,
},
[BlockEnum.Code]: {
type: BlockEnum.Code,
title: '',
@@ -355,6 +387,7 @@ export const NODES_INITIAL_DATA = {
export const MAX_ITERATION_PARALLEL_NUM = 10
export const MIN_ITERATION_PARALLEL_NUM = 1
export const DEFAULT_ITER_TIMES = 1
export const DEFAULT_LOOP_TIMES = 1
export const NODE_WIDTH = 240
export const X_OFFSET = 60
export const NODE_WIDTH_X_OFFSET = NODE_WIDTH + X_OFFSET
@@ -373,6 +406,16 @@ export const ITERATION_PADDING = {
bottom: 20,
left: 16,
}

export const LOOP_NODE_Z_INDEX = 1
export const LOOP_CHILDREN_Z_INDEX = 1002
export const LOOP_PADDING = {
top: 65,
right: 16,
bottom: 20,
left: 16,
}

export const PARALLEL_LIMIT = 10
export const PARALLEL_DEPTH_LIMIT = 3

@@ -399,7 +442,7 @@ export const RETRIEVAL_OUTPUT_STRUCT = `{
export const SUPPORT_OUTPUT_VARS_NODE = [
BlockEnum.Start, BlockEnum.LLM, BlockEnum.KnowledgeRetrieval, BlockEnum.Code, BlockEnum.TemplateTransform,
BlockEnum.HttpRequest, BlockEnum.Tool, BlockEnum.VariableAssigner, BlockEnum.VariableAggregator, BlockEnum.QuestionClassifier,
BlockEnum.ParameterExtractor, BlockEnum.Iteration,
BlockEnum.ParameterExtractor, BlockEnum.Iteration, BlockEnum.Loop,
BlockEnum.DocExtractor, BlockEnum.ListFilter,
BlockEnum.Agent,
]

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

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

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

@@ -21,6 +21,14 @@ export const useHelpline = () => {
showVerticalHelpLineNodes: [],
}
}

if (node.data.isInLoop) {
return {
showHorizontalHelpLineNodes: [],
showVerticalHelpLineNodes: [],
}
}

const showHorizontalHelpLineNodes = nodes.filter((n) => {
if (n.id === node.id)
return false
@@ -28,6 +36,9 @@ export const useHelpline = () => {
if (n.data.isInIteration)
return false

if (n.data.isInLoop)
return false

const nY = Math.ceil(n.position.y)
const nodeY = Math.ceil(node.position.y)

@@ -67,6 +78,8 @@ export const useHelpline = () => {
return false
if (n.data.isInIteration)
return false
if (n.data.isInLoop)
return false

const nX = Math.ceil(n.position.x)
const nodeX = Math.ceil(node.position.x)

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

@@ -31,7 +31,7 @@ export const useNodesExtraData = () => {
}), [t, isChatMode])
}

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

if (isInLoop && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
return false

return true
}),
availableNextBlocks: availableNextBlocks.filter((nType) => {
if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.End))
if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
return false

if (isInLoop && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
return false

return true
}),
}
}, [isInIteration, availablePrevBlocks, availableNextBlocks])
}, [isInIteration, availablePrevBlocks, availableNextBlocks, isInLoop])
}

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

@@ -29,6 +29,8 @@ import {
CUSTOM_EDGE,
ITERATION_CHILDREN_Z_INDEX,
ITERATION_PADDING,
LOOP_CHILDREN_Z_INDEX,
LOOP_PADDING,
NODES_INITIAL_DATA,
NODE_WIDTH_X_OFFSET,
X_OFFSET,
@@ -42,9 +44,12 @@ import {
} from '../utils'
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
import type { IterationNodeType } from '../nodes/iteration/types'
import type { LoopNodeType } from '../nodes/loop/types'
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions'
import { useNodeLoopInteractions } from '../nodes/loop/use-interactions'
import { useWorkflowHistoryStore } from '../workflow-history-store'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useHelpline } from './use-helpline'
@@ -73,6 +78,10 @@ export const useNodesInteractions = () => {
handleNodeIterationChildDrag,
handleNodeIterationChildrenCopy,
} = useNodeIterationInteractions()
const {
handleNodeLoopChildDrag,
handleNodeLoopChildrenCopy,
} = useNodeLoopInteractions()
const dragNodeStartPosition = useRef({ x: 0, y: 0 } as { x: number; y: number })

const { saveStateToHistory, undo, redo } = useWorkflowHistory()
@@ -86,6 +95,9 @@ export const useNodesInteractions = () => {
if (node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_NOTE_NODE)
return

if (node.type === CUSTOM_LOOP_START_NODE || node.type === CUSTOM_NOTE_NODE)
return

dragNodeStartPosition.current = { x: node.position.x, y: node.position.y }
}, [workflowStore, getNodesReadOnly])

@@ -96,6 +108,9 @@ export const useNodesInteractions = () => {
if (node.type === CUSTOM_ITERATION_START_NODE)
return

if (node.type === CUSTOM_LOOP_START_NODE)
return

const {
getNodes,
setNodes,
@@ -105,6 +120,7 @@ export const useNodesInteractions = () => {
const nodes = getNodes()

const { restrictPosition } = handleNodeIterationChildDrag(node)
const { restrictPosition: restrictLoopPosition } = handleNodeLoopChildDrag(node)

const {
showHorizontalHelpLineNodes,
@@ -120,6 +136,8 @@ export const useNodesInteractions = () => {
currentNode.position.x = showVerticalHelpLineNodes[0].position.x
else if (restrictPosition.x !== undefined)
currentNode.position.x = restrictPosition.x
else if (restrictLoopPosition.x !== undefined)
currentNode.position.x = restrictLoopPosition.x
else
currentNode.position.x = node.position.x

@@ -127,12 +145,13 @@ export const useNodesInteractions = () => {
currentNode.position.y = showHorizontalHelpLineNodes[0].position.y
else if (restrictPosition.y !== undefined)
currentNode.position.y = restrictPosition.y
else if (restrictLoopPosition.y !== undefined)
currentNode.position.y = restrictLoopPosition.y
else
currentNode.position.y = node.position.y
})

setNodes(newNodes)
}, [store, getNodesReadOnly, handleSetHelpline, handleNodeIterationChildDrag])
}, [getNodesReadOnly, store, handleNodeIterationChildDrag, handleNodeLoopChildDrag, handleSetHelpline])

const handleNodeDragStop = useCallback<NodeDragHandler>((_, node) => {
const {
@@ -163,6 +182,9 @@ export const useNodesInteractions = () => {
if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE)
return

if (node.type === CUSTOM_LOOP_START_NODE || node.type === CUSTOM_NOTE_NODE)
return

const {
getNodes,
setNodes,
@@ -237,6 +259,9 @@ export const useNodesInteractions = () => {
if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE)
return

if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_LOOP_START_NODE)
return

const {
setEnteringNodePayload,
} = workflowStore.getState()
@@ -311,6 +336,8 @@ export const useNodesInteractions = () => {
const handleNodeClick = useCallback<NodeMouseHandler>((_, node) => {
if (node.type === CUSTOM_ITERATION_START_NODE)
return
if (node.type === CUSTOM_LOOP_START_NODE)
return
handleNodeSelect(node.id)
}, [handleNodeSelect])

@@ -344,6 +371,10 @@ export const useNodesInteractions = () => {
if (edges.find(edge => edge.source === source && edge.sourceHandle === sourceHandle && edge.target === target && edge.targetHandle === targetHandle))
return

const parendNode = nodes.find(node => node.id === targetNode?.parentId)
const isInIteration = parendNode && parendNode.data.type === BlockEnum.Iteration
const isInLoop = !!parendNode && parendNode.data.type === BlockEnum.Loop

const newEdge = {
id: `${source}-${sourceHandle}-${target}-${targetHandle}`,
type: CUSTOM_EDGE,
@@ -354,10 +385,12 @@ export const useNodesInteractions = () => {
data: {
sourceType: nodes.find(node => node.id === source)!.data.type,
targetType: nodes.find(node => node.id === target)!.data.type,
isInIteration: !!targetNode?.parentId,
iteration_id: targetNode?.parentId,
isInIteration,
iteration_id: isInIteration ? targetNode?.parentId : undefined,
isInLoop,
loop_id: isInLoop ? targetNode?.parentId : undefined,
},
zIndex: targetNode?.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
zIndex: targetNode?.parentId ? (isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
}
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
[
@@ -554,6 +587,45 @@ export const useNodesInteractions = () => {
}
}
}

if (currentNode.data.type === BlockEnum.Loop) {
const loopChildren = nodes.filter(node => node.parentId === currentNode.id)

if (loopChildren.length) {
if (currentNode.data._isBundled) {
loopChildren.forEach((child) => {
handleNodeDelete(child.id)
})
return handleNodeDelete(nodeId)
}
else {
if (loopChildren.length === 1) {
handleNodeDelete(loopChildren[0].id)
handleNodeDelete(nodeId)

return
}
const { setShowConfirm, showConfirm } = workflowStore.getState()

if (!showConfirm) {
setShowConfirm({
title: t('workflow.nodes.loop.deleteTitle'),
desc: t('workflow.nodes.loop.deleteDesc') || '',
onConfirm: () => {
loopChildren.forEach((child) => {
handleNodeDelete(child.id)
})
handleNodeDelete(nodeId)
handleSyncWorkflowDraft()
setShowConfirm(undefined)
},
})
return
}
}
}
}

const connectedEdges = getConnectedEdges([{ id: nodeId } as Node], edges)
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(connectedEdges.map(edge => ({ type: 'remove', edge })), nodes)
const newNodes = produce(nodes, (draft: Node[]) => {
@@ -612,6 +684,7 @@ export const useNodesInteractions = () => {
const {
newNode,
newIterationStartNode,
newLoopStartNode,
} = generateNewNode({
data: {
...NODES_INITIAL_DATA[nodeType],
@@ -640,13 +713,28 @@ export const useNodesInteractions = () => {
}
newNode.parentId = prevNode.parentId
newNode.extent = prevNode.extent

const parentNode = nodes.find(node => node.id === prevNode.parentId) || null
const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop

if (prevNode.parentId) {
newNode.data.isInIteration = true
newNode.data.iteration_id = prevNode.parentId
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
if (newNode.data.type === BlockEnum.Answer || newNode.data.type === BlockEnum.Tool || newNode.data.type === BlockEnum.Assigner) {
const parentIterNodeIndex = nodes.findIndex(node => node.id === prevNode.parentId)
const iterNodeData: IterationNodeType = nodes[parentIterNodeIndex].data
newNode.data.isInIteration = isInIteration
newNode.data.isInLoop = isInLoop
if (isInIteration) {
newNode.data.iteration_id = parentNode.id
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
}
if (isInLoop) {
newNode.data.loop_id = parentNode.id
newNode.zIndex = LOOP_CHILDREN_Z_INDEX
}
if (isInIteration && (newNode.data.type === BlockEnum.Answer || newNode.data.type === BlockEnum.Tool || newNode.data.type === BlockEnum.Assigner)) {
const iterNodeData: IterationNodeType = parentNode.data
iterNodeData._isShowTips = true
}
if (isInLoop && (newNode.data.type === BlockEnum.Answer || newNode.data.type === BlockEnum.Tool || newNode.data.type === BlockEnum.Assigner)) {
const iterNodeData: IterationNodeType = parentNode.data
iterNodeData._isShowTips = true
}
}
@@ -661,11 +749,13 @@ export const useNodesInteractions = () => {
data: {
sourceType: prevNode.data.type,
targetType: newNode.data.type,
isInIteration: !!prevNode.parentId,
iteration_id: prevNode.parentId,
isInIteration,
isInLoop,
iteration_id: isInIteration ? prevNode.parentId : undefined,
loop_id: isInLoop ? prevNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: prevNode.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
zIndex: prevNode.parentId ? (isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
}
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
[
@@ -686,10 +776,17 @@ export const useNodesInteractions = () => {

if (node.data.type === BlockEnum.Iteration && prevNode.parentId === node.id)
node.data._children?.push(newNode.id)

if (node.data.type === BlockEnum.Loop && prevNode.parentId === node.id)
node.data._children?.push(newNode.id)
})
draft.push(newNode)

if (newIterationStartNode)
draft.push(newIterationStartNode)

if (newLoopStartNode)
draft.push(newLoopStartNode)
})

if (newNode.data.type === BlockEnum.VariableAssigner || newNode.data.type === BlockEnum.VariableAggregator) {
@@ -736,10 +833,22 @@ export const useNodesInteractions = () => {
}
newNode.parentId = nextNode.parentId
newNode.extent = nextNode.extent
if (nextNode.parentId) {
newNode.data.isInIteration = true
newNode.data.iteration_id = nextNode.parentId
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX

const parentNode = nodes.find(node => node.id === nextNode.parentId) || null
const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop

if (parentNode && nextNode.parentId) {
newNode.data.isInIteration = isInIteration
newNode.data.isInLoop = isInLoop
if (isInIteration) {
newNode.data.iteration_id = parentNode.id
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
}
if (isInLoop) {
newNode.data.loop_id = parentNode.id
newNode.zIndex = LOOP_CHILDREN_Z_INDEX
}
}

let newEdge
@@ -755,11 +864,13 @@ export const useNodesInteractions = () => {
data: {
sourceType: newNode.data.type,
targetType: nextNode.data.type,
isInIteration: !!nextNode.parentId,
iteration_id: nextNode.parentId,
isInIteration,
isInLoop,
iteration_id: isInIteration ? nextNode.parentId : undefined,
loop_id: isInLoop ? nextNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: nextNode.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
zIndex: nextNode.parentId ? (isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
}
}

@@ -796,10 +907,20 @@ export const useNodesInteractions = () => {
node.data.start_node_id = newNode.id
node.data.startNodeType = newNode.data.type
}

if (node.data.type === BlockEnum.Loop && nextNode.parentId === node.id)
node.data._children?.push(newNode.id)

if (node.data.type === BlockEnum.Loop && node.data.start_node_id === nextNodeId) {
node.data.start_node_id = newNode.id
node.data.startNodeType = newNode.data.type
}
})
draft.push(newNode)
if (newIterationStartNode)
draft.push(newIterationStartNode)
if (newLoopStartNode)
draft.push(newLoopStartNode)
})
if (newEdge) {
const newEdges = produce(edges, (draft) => {
@@ -840,10 +961,22 @@ export const useNodesInteractions = () => {
}
newNode.parentId = prevNode.parentId
newNode.extent = prevNode.extent
if (prevNode.parentId) {
newNode.data.isInIteration = true
newNode.data.iteration_id = prevNode.parentId
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX

const parentNode = nodes.find(node => node.id === prevNode.parentId) || null
const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop

if (parentNode && prevNode.parentId) {
newNode.data.isInIteration = isInIteration
newNode.data.isInLoop = isInLoop
if (isInIteration) {
newNode.data.iteration_id = parentNode.id
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
}
if (isInLoop) {
newNode.data.loop_id = parentNode.id
newNode.zIndex = LOOP_CHILDREN_Z_INDEX
}
}

const currentEdgeIndex = edges.findIndex(edge => edge.source === prevNodeId && edge.target === nextNodeId)
@@ -857,13 +990,20 @@ export const useNodesInteractions = () => {
data: {
sourceType: prevNode.data.type,
targetType: newNode.data.type,
isInIteration: !!prevNode.parentId,
iteration_id: prevNode.parentId,
isInIteration,
isInLoop,
iteration_id: isInIteration ? prevNode.parentId : undefined,
loop_id: isInLoop ? prevNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: prevNode.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
zIndex: prevNode.parentId ? (isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
}
let newNextEdge: Edge | null = null

const nextNodeParentNode = nodes.find(node => node.id === nextNode.parentId) || null
const isNextNodeInIteration = !!nextNodeParentNode && nextNodeParentNode.data.type === BlockEnum.Iteration
const isNextNodeInLoop = !!nextNodeParentNode && nextNodeParentNode.data.type === BlockEnum.Loop

if (nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier) {
newNextEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
@@ -875,11 +1015,13 @@ export const useNodesInteractions = () => {
data: {
sourceType: newNode.data.type,
targetType: nextNode.data.type,
isInIteration: !!nextNode.parentId,
iteration_id: nextNode.parentId,
isInIteration: isNextNodeInIteration,
isInLoop: isNextNodeInLoop,
iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined,
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: nextNode.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
zIndex: nextNode.parentId ? (isNextNodeInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
}
}
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
@@ -908,10 +1050,14 @@ export const useNodesInteractions = () => {

if (node.data.type === BlockEnum.Iteration && prevNode.parentId === node.id)
node.data._children?.push(newNode.id)
if (node.data.type === BlockEnum.Loop && prevNode.parentId === node.id)
node.data._children?.push(newNode.id)
})
draft.push(newNode)
if (newIterationStartNode)
draft.push(newIterationStartNode)
if (newLoopStartNode)
draft.push(newLoopStartNode)
})
setNodes(newNodes)
if (newNode.data.type === BlockEnum.VariableAssigner || newNode.data.type === BlockEnum.VariableAggregator) {
@@ -969,6 +1115,7 @@ export const useNodesInteractions = () => {
const {
newNode: newCurrentNode,
newIterationStartNode,
newLoopStartNode,
} = generateNewNode({
data: {
...NODES_INITIAL_DATA[nodeType],
@@ -978,7 +1125,9 @@ export const useNodesInteractions = () => {
_connectedTargetHandleIds: [],
selected: currentNode.data.selected,
isInIteration: currentNode.data.isInIteration,
isInLoop: currentNode.data.isInLoop,
iteration_id: currentNode.data.iteration_id,
loop_id: currentNode.data.loop_id,
},
position: {
x: currentNode.position.x,
@@ -1010,6 +1159,8 @@ export const useNodesInteractions = () => {
draft.splice(index, 1, newCurrentNode)
if (newIterationStartNode)
draft.push(newIterationStartNode)
if (newLoopStartNode)
draft.push(newLoopStartNode)
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
@@ -1058,6 +1209,9 @@ export const useNodesInteractions = () => {
if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE)
return

if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_LOOP_START_NODE)
return

e.preventDefault()
const container = document.querySelector('#workflow-container')
const { x, y } = container!.getBoundingClientRect()
@@ -1085,13 +1239,15 @@ export const useNodesInteractions = () => {

if (nodeId) {
// If nodeId is provided, copy that specific node
const nodeToCopy = nodes.find(node => node.id === nodeId && node.data.type !== BlockEnum.Start && node.type !== CUSTOM_ITERATION_START_NODE)
const nodeToCopy = nodes.find(node => node.id === nodeId && node.data.type !== BlockEnum.Start
&& node.type !== CUSTOM_ITERATION_START_NODE && node.type !== CUSTOM_LOOP_START_NODE)
if (nodeToCopy)
setClipboardElements([nodeToCopy])
}
else {
// If no nodeId is provided, fall back to the current behavior
const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start && !node.data.isInIteration)
const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start
&& !node.data.isInIteration && !node.data.isInLoop)

if (bundledNodes.length) {
setClipboardElements(bundledNodes)
@@ -1138,6 +1294,7 @@ export const useNodesInteractions = () => {
const {
newNode,
newIterationStartNode,
newLoopStartNode,
} = generateNewNode({
type: nodeToPaste.type,
data: {
@@ -1176,6 +1333,17 @@ export const useNodesInteractions = () => {
newChildren.push(newIterationStartNode!)
}

if (nodeToPaste.data.type === BlockEnum.Loop) {
newLoopStartNode!.parentId = newNode.id;
(newNode.data as LoopNodeType).start_node_id = newLoopStartNode!.id

newChildren = handleNodeLoopChildrenCopy(nodeToPaste.id, newNode.id)
newChildren.forEach((child) => {
newNode.data._children?.push(child.id)
})
newChildren.push(newLoopStartNode!)
}

nodesToPaste.push(newNode)

if (newChildren.length)
@@ -1206,7 +1374,7 @@ export const useNodesInteractions = () => {
saveStateToHistory(WorkflowHistoryEvent.NodePaste)
handleSyncWorkflowDraft()
}
}, [getNodesReadOnly, workflowStore, store, reactflow, saveStateToHistory, handleSyncWorkflowDraft, handleNodeIterationChildrenCopy])
}, [getNodesReadOnly, workflowStore, store, reactflow, saveStateToHistory, handleSyncWorkflowDraft, handleNodeIterationChildrenCopy, handleNodeLoopChildrenCopy])

const handleNodesDuplicate = useCallback((nodeId?: string) => {
if (getNodesReadOnly())
@@ -1278,9 +1446,12 @@ export const useNodesInteractions = () => {
})

if (rightNode! && bottomNode!) {
if (width < rightNode!.position.x + rightNode.width! + ITERATION_PADDING.right)
const parentNode = nodes.find(n => n.id === rightNode.parentId)
const paddingMap = parentNode?.data.type === BlockEnum.Iteration ? ITERATION_PADDING : LOOP_PADDING

if (width < rightNode!.position.x + rightNode.width! + paddingMap.right)
return
if (height < bottomNode.position.y + bottomNode.height! + ITERATION_PADDING.bottom)
if (height < bottomNode.position.y + bottomNode.height! + paddingMap.bottom)
return
}
const newNodes = produce(nodes, (draft) => {

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

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

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

@@ -0,0 +1,46 @@
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import produce from 'immer'
import type { LoopFinishedResponse } from '@/types/workflow'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { DEFAULT_LOOP_TIMES } from '@/app/components/workflow/constants'

export const useWorkflowNodeLoopFinished = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()

const handleWorkflowNodeLoopFinished = useCallback((params: LoopFinishedResponse) => {
const { data } = params
const {
workflowRunningData,
setWorkflowRunningData,
setLoopTimes,
} = workflowStore.getState()
const {
getNodes,
setNodes,
} = store.getState()
const nodes = getNodes()
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
const currentIndex = draft.tracing!.findIndex(item => item.id === data.id)

if (currentIndex > -1) {
draft.tracing![currentIndex] = {
...draft.tracing![currentIndex],
...data,
}
}
}))
setLoopTimes(DEFAULT_LOOP_TIMES)
const newNodes = produce(nodes, (draft) => {
const currentNode = draft.find(node => node.id === data.node_id)!

currentNode.data._runningStatus = data.status
})
setNodes(newNodes)
}, [workflowStore, store])

return {
handleWorkflowNodeLoopFinished,
}
}

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

@@ -0,0 +1,35 @@
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import produce from 'immer'
import type { LoopNextResponse } from '@/types/workflow'
import { useWorkflowStore } from '@/app/components/workflow/store'

export const useWorkflowNodeLoopNext = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()

const handleWorkflowNodeLoopNext = useCallback((params: LoopNextResponse) => {
const {
loopTimes,
setLoopTimes,
} = workflowStore.getState()

const { data } = params
const {
getNodes,
setNodes,
} = store.getState()

const nodes = getNodes()
const newNodes = produce(nodes, (draft) => {
const currentNode = draft.find(node => node.id === data.node_id)!
currentNode.data._loopIndex = loopTimes
setLoopTimes(loopTimes + 1)
})
setNodes(newNodes)
}, [workflowStore, store])

return {
handleWorkflowNodeLoopNext,
}
}

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

@@ -0,0 +1,85 @@
import { useCallback } from 'react'
import {
useReactFlow,
useStoreApi,
} from 'reactflow'
import produce from 'immer'
import { useWorkflowStore } from '@/app/components/workflow/store'
import type { LoopStartedResponse } from '@/types/workflow'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import { DEFAULT_LOOP_TIMES } from '@/app/components/workflow/constants'

export const useWorkflowNodeLoopStarted = () => {
const store = useStoreApi()
const reactflow = useReactFlow()
const workflowStore = useWorkflowStore()

const handleWorkflowNodeLoopStarted = useCallback((
params: LoopStartedResponse,
containerParams: {
clientWidth: number,
clientHeight: number,
},
) => {
const { data } = params
const {
workflowRunningData,
setWorkflowRunningData,
setLoopTimes,
} = workflowStore.getState()
const {
getNodes,
setNodes,
edges,
setEdges,
transform,
} = store.getState()
const nodes = getNodes()
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
draft.tracing!.push({
...data,
status: NodeRunningStatus.Running,
})
}))
setLoopTimes(DEFAULT_LOOP_TIMES)

const {
setViewport,
} = reactflow
const currentNodeIndex = nodes.findIndex(node => node.id === data.node_id)
const currentNode = nodes[currentNodeIndex]
const position = currentNode.position
const zoom = transform[2]

if (!currentNode.parentId) {
setViewport({
x: (containerParams.clientWidth - 400 - currentNode.width! * zoom) / 2 - position.x * zoom,
y: (containerParams.clientHeight - currentNode.height! * zoom) / 2 - position.y * zoom,
zoom: transform[2],
})
}
const newNodes = produce(nodes, (draft) => {
draft[currentNodeIndex].data._runningStatus = NodeRunningStatus.Running
draft[currentNodeIndex].data._loopLength = data.metadata.loop_length
draft[currentNodeIndex].data._waitingRun = false
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
const incomeEdges = draft.filter(edge => edge.target === data.node_id)

incomeEdges.forEach((edge) => {
edge.data = {
...edge.data,
_sourceRunningStatus: nodes.find(node => node.id === edge.source)!.data._runningStatus,
_targetRunningStatus: NodeRunningStatus.Running,
_waitingRun: false,
}
})
})
setEdges(newEdges)
}, [workflowStore, store, reactflow])

return {
handleWorkflowNodeLoopStarted,
}
}

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

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

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

@@ -36,6 +36,9 @@ export const useWorkflowRun = () => {
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowAgentLog,
handleWorkflowTextChunk,
@@ -118,6 +121,9 @@ export const useWorkflowRun = () => {
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onAgentLog,
onError,
@@ -162,7 +168,7 @@ export const useWorkflowRun = () => {
else
ttsUrl = `/apps/${params.appId}/text-to-audio`
}
const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => {})
const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => { })

ssePost(
url,
@@ -230,6 +236,30 @@ export const useWorkflowRun = () => {
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(
params,
{
clientWidth,
clientHeight,
},
)

if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)

if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)

if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)

@@ -260,7 +290,27 @@ export const useWorkflowRun = () => {
...restCallback,
},
)
}, [store, workflowStore, doSyncWorkflowDraft, handleWorkflowStarted, handleWorkflowFinished, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeRetry, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowAgentLog, pathname])
}, [
store,
workflowStore,
doSyncWorkflowDraft,
handleWorkflowStarted,
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowAgentLog,
pathname],
)

const handleStopRun = useCallback((taskId: string) => {
const appId = useAppStore.getState().appDetail?.id

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

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

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

@@ -57,6 +57,7 @@ import {
import I18n from '@/context/i18n'
import { CollectionType } from '@/app/components/tools/types'
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
import { useWorkflowConfig } from '@/service/use-workflow'
import { canFindTool } from '@/utils'

@@ -89,7 +90,7 @@ export const useWorkflow = () => {
const currentNode = nodes.find(node => node.id === nodeId)

if (currentNode?.parentId)
startNode = nodes.find(node => node.parentId === currentNode.parentId && node.type === CUSTOM_ITERATION_START_NODE)
startNode = nodes.find(node => node.parentId === currentNode.parentId && (node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_LOOP_START_NODE))

if (!startNode)
return []
@@ -239,6 +240,15 @@ export const useWorkflow = () => {
return nodes.filter(node => node.parentId === nodeId)
}, [store])

const getLoopNodeChildren = useCallback((nodeId: string) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()

return nodes.filter(node => node.parentId === nodeId)
}, [store])

const isFromStartNode = useCallback((nodeId: string) => {
const { getNodes } = store.getState()
const nodes = getNodes()
@@ -280,7 +290,7 @@ export const useWorkflow = () => {
setNodes(newNodes)
}

// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store])

const isVarUsedInNodes = useCallback((varSelector: ValueSelector) => {
@@ -425,6 +435,7 @@ export const useWorkflow = () => {
getNode,
getBeforeNodeById,
getIterationNodeChildren,
getLoopNodeChildren,
}
}

@@ -520,7 +531,7 @@ export const useWorkflowInit = () => {

useEffect(() => {
handleGetInitialWorkflowData()
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

const handleFetchPreloadData = useCallback(async () => {
@@ -537,7 +548,7 @@ export const useWorkflowInit = () => {
workflowStore.getState().setPublishedAt(publishedWorkflow?.created_at)
}
catch (e) {
console.error(e)
}
}, [workflowStore, appDetail])

@@ -638,3 +649,26 @@ export const useIsNodeInIteration = (iterationId: string) => {
isNodeInIteration,
}
}

export const useIsNodeInLoop = (loopId: string) => {
const store = useStoreApi()

const isNodeInLoop = useCallback((nodeId: string) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()
const node = nodes.find(node => node.id === nodeId)

if (!node)
return false

if (node.parentId === loopId)
return true

return false
}, [loopId, store])
return {
isNodeInLoop,
}
}

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

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

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

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

const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {

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

@@ -36,7 +36,7 @@ const ChangeItem = ({
const {
availablePrevBlocks,
availableNextBlocks,
} = useAvailableBlocks(data.type, data.isInIteration)
} = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop)

const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue)

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

@@ -47,7 +47,7 @@ export const NodeTargetHandle = memo(({
const { handleNodeAdd } = useNodesInteractions()
const { getNodesReadOnly } = useNodesReadOnly()
const connected = data._connectedTargetHandleIds?.includes(handleId)
const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration)
const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop)
const isConnectable = !!availablePrevBlocks.length

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

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

@@ -30,7 +30,7 @@ const ChangeBlock = ({
const {
availablePrevBlocks,
availableNextBlocks,
} = useAvailableBlocks(nodeData.type, nodeData.isInIteration)
} = useAvailableBlocks(nodeData.type, nodeData.isInIteration, nodeData.isInLoop)

const availableNodes = useMemo(() => {
if (availablePrevBlocks.length && availableNextBlocks.length)

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

@@ -79,7 +79,7 @@ const PanelOperatorPopup = ({
return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
}, [data, nodesExtraData, language, buildInTools, customTools, workflowTools])

const showChangeBlock = data.type !== BlockEnum.Start && !nodesReadOnly && data.type !== BlockEnum.Iteration
const showChangeBlock = data.type !== BlockEnum.Start && !nodesReadOnly && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop

const link = useNodeHelpLink(data.type)


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

@@ -13,6 +13,7 @@ import { VarType as ToolVarType } from '../../../tool/types'
import type { ToolNodeType } from '../../../tool/types'
import type { ParameterExtractorNodeType } from '../../../parameter-extractor/types'
import type { IterationNodeType } from '../../../iteration/types'
import type { LoopNodeType } from '../../../loop/types'
import type { ListFilterNodeType } from '../../../list-operator/types'
import { OUTPUT_FILE_SUB_VARIABLES } from '../../../constants'
import type { DocExtractorNodeType } from '../../../document-extractor/types'
@@ -518,10 +519,61 @@ const getIterationItemType = ({
}
}

const getLoopItemType = ({
valueSelector,
beforeNodesOutputVars,
}: {
valueSelector: ValueSelector
beforeNodesOutputVars: NodeOutPutVar[]
}): VarType => {
const outputVarNodeId = valueSelector[0]
const isSystem = isSystemVar(valueSelector)

const targetVar = isSystem ? beforeNodesOutputVars.find(v => v.isStartNode) : beforeNodesOutputVars.find(v => v.nodeId === outputVarNodeId)
if (!targetVar)
return VarType.string

let arrayType: VarType = VarType.string

let curr: any = targetVar.vars
if (isSystem) {
arrayType = curr.find((v: any) => v.variable === (valueSelector).join('.'))?.type
}
else {
(valueSelector).slice(1).forEach((key, i) => {
const isLast = i === valueSelector.length - 2
curr = curr?.find((v: any) => v.variable === key)
if (isLast) {
arrayType = curr?.type
}
else {
if (curr?.type === VarType.object || curr?.type === VarType.file)
curr = curr.children
}
})
}

switch (arrayType as VarType) {
case VarType.arrayString:
return VarType.string
case VarType.arrayNumber:
return VarType.number
case VarType.arrayObject:
return VarType.object
case VarType.array:
return VarType.any
case VarType.arrayFile:
return VarType.file
default:
return VarType.string
}
}

export const getVarType = ({
parentNode,
valueSelector,
isIterationItem,
isLoopItem,
availableNodes,
isChatMode,
isConstant,
@@ -532,6 +584,7 @@ export const getVarType = ({
valueSelector: ValueSelector
parentNode?: Node | null
isIterationItem?: boolean
isLoopItem?: boolean
availableNodes: any[]
isChatMode: boolean
isConstant?: boolean
@@ -567,6 +620,26 @@ export const getVarType = ({
if (valueSelector[1] === 'index')
return VarType.number
}

const isLoopInnerVar = parentNode?.data.type === BlockEnum.Loop
if (isLoopItem) {
return getLoopItemType({
valueSelector,
beforeNodesOutputVars,
})
}
if (isLoopInnerVar) {
if (valueSelector[1] === 'item') {
const itemType = getLoopItemType({
valueSelector: (parentNode?.data as any).iterator_selector || [],
beforeNodesOutputVars,
})
return itemType
}
if (valueSelector[1] === 'index')
return VarType.number
}

const isSystem = isSystemVar(valueSelector)
const isEnv = isENV(valueSelector)
const isChatVar = isConversationVar(valueSelector)
@@ -802,6 +875,14 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
break
}

case BlockEnum.Loop: {
const payload = data as LoopNodeType
res = payload.break_conditions?.map((c) => {
return c.variable_selector || []
}) || []
break
}

case BlockEnum.ListFilter: {
res = [(data as ListFilterNodeType).variable]
break
@@ -1079,6 +1160,17 @@ export const updateNodeVars = (oldNode: Node, oldVarSelector: ValueSelector, new

break
}
case BlockEnum.Loop: {
const payload = data as LoopNodeType
if (payload.break_conditions) {
payload.break_conditions = payload.break_conditions.map((c) => {
if (c.variable_selector?.join('.') === oldVarSelector.join('.'))
c.variable_selector = newVarSelector
return c
})
}
break
}
case BlockEnum.ListFilter: {
const payload = data as ListFilterNodeType
if (payload.variable.join('.') === oldVarSelector.join('.'))
@@ -1200,6 +1292,11 @@ export const getNodeOutputVars = (node: Node, isChatMode: boolean): ValueSelecto
break
}

case BlockEnum.Loop: {
res.push([id, 'output'])
break
}

case BlockEnum.DocExtractor: {
res.push([id, 'text'])
break

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

@@ -114,6 +114,9 @@ const VarReferencePicker: FC<Props> = ({
const isInIteration = !!node?.data.isInIteration
const iterationNode = isInIteration ? getNodes().find(n => n.id === node.parentId) : null

const isInLoop = !!node?.data.isInLoop
const loopNode = isInLoop ? getNodes().find(n => n.id === node.parentId) : null

const triggerRef = useRef<HTMLDivElement>(null)
const [triggerWidth, setTriggerWidth] = useState(TRIGGER_DEFAULT_WIDTH)
useEffect(() => {
@@ -142,6 +145,14 @@ const VarReferencePicker: FC<Props> = ({
return false
}, [isInIteration, value, node])

const isLoopVar = useMemo(() => {
if (!isInLoop)
return false
if (value[0] === node?.parentId && ['item', 'index'].includes(value[1]))
return true
return false
}, [isInLoop, value, node])

const outputVarNodeId = hasValue ? value[0] : ''
const outputVarNode = useMemo(() => {
if (!hasValue || isConstant)
@@ -150,11 +161,14 @@ const VarReferencePicker: FC<Props> = ({
if (isIterationVar)
return iterationNode?.data

if (isLoopVar)
return loopNode?.data

if (isSystemVar(value as ValueSelector))
return startNode?.data

return getNodeInfoById(availableNodes, outputVarNodeId)?.data
}, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode])
}, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode, isLoopVar, loopNode])

const varName = useMemo(() => {
if (hasValue) {
@@ -220,7 +234,7 @@ const VarReferencePicker: FC<Props> = ({
}, [onChange, varKindType])

const type = getCurrentVariableType({
parentNode: iterationNode,
parentNode: isInIteration ? iterationNode : loopNode,
valueSelector: value as ValueSelector,
availableNodes,
isChatMode,

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

@@ -13,6 +13,7 @@ type Params = {
passedInAvailableNodes?: Node[]
}

// TODO: loop type?
const useAvailableVarList = (nodeId: string, {
onlyLeafNodeVar,
filterVar,

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

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


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

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

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

@@ -13,7 +13,7 @@ import type { CommonNodeType, InputVar, ValueSelector, Var, Variable } from '@/a
import { BlockEnum, InputVarType, NodeRunningStatus, VarType } from '@/app/components/workflow/types'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { getIterationSingleNodeRunUrl, singleNodeRun } from '@/service/workflow'
import { getIterationSingleNodeRunUrl, getLoopSingleNodeRunUrl, singleNodeRun } from '@/service/workflow'
import Toast from '@/app/components/base/toast'
import LLMDefault from '@/app/components/workflow/nodes/llm/default'
import KnowledgeRetrievalDefault from '@/app/components/workflow/nodes/knowledge-retrieval/default'
@@ -28,6 +28,7 @@ import Assigner from '@/app/components/workflow/nodes/assigner/default'
import ParameterExtractorDefault from '@/app/components/workflow/nodes/parameter-extractor/default'
import IterationDefault from '@/app/components/workflow/nodes/iteration/default'
import DocumentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default'
import LoopDefault from '@/app/components/workflow/nodes/loop/default'
import { ssePost } from '@/service/base'

import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants'
@@ -45,6 +46,7 @@ const { checkValid: checkAssignerValid } = Assigner
const { checkValid: checkParameterExtractorValid } = ParameterExtractorDefault
const { checkValid: checkIterationValid } = IterationDefault
const { checkValid: checkDocumentExtractorValid } = DocumentExtractorDefault
const { checkValid: checkLoopValid } = LoopDefault

// eslint-disable-next-line ts/no-unsafe-function-type
const checkValidFns: Record<BlockEnum, Function> = {
@@ -61,6 +63,7 @@ const checkValidFns: Record<BlockEnum, Function> = {
[BlockEnum.ParameterExtractor]: checkParameterExtractorValid,
[BlockEnum.Iteration]: checkIterationValid,
[BlockEnum.DocExtractor]: checkDocumentExtractorValid,
[BlockEnum.Loop]: checkLoopValid,
} as any

type Params<T> = {
@@ -69,6 +72,7 @@ type Params<T> = {
defaultRunInputData: Record<string, any>
moreDataForCheckValid?: any
iteratorInputKey?: string
loopInputKey?: string
}

const varTypeToInputVarType = (type: VarType, {
@@ -100,12 +104,14 @@ const useOneStepRun = <T>({
defaultRunInputData,
moreDataForCheckValid,
iteratorInputKey,
loopInputKey,
}: Params<T>) => {
const { t } = useTranslation()
const { getBeforeNodesInSameBranch, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() as any
const conversationVariables = useStore(s => s.conversationVariables)
const isChatMode = useIsChatMode()
const isIteration = data.type === BlockEnum.Iteration
const isLoop = data.type === BlockEnum.Loop

const availableNodes = getBeforeNodesInSameBranch(id)
const availableNodesIncludeParent = getBeforeNodesInSameBranchIncludeParent(id)
@@ -145,12 +151,14 @@ const useOneStepRun = <T>({
setRunInputData(data)
}, [])
const iterationTimes = iteratorInputKey ? runInputData[iteratorInputKey].length : 0
const loopTimes = loopInputKey ? runInputData[loopInputKey].length : 0
const [runResult, setRunResult] = useState<any>(null)

const { handleNodeDataUpdate }: { handleNodeDataUpdate: (data: any) => void } = useNodeDataUpdate()
const [canShowSingleRun, setCanShowSingleRun] = useState(false)
const isShowSingleRun = data._isSingleRun && canShowSingleRun
const [iterationRunResult, setIterationRunResult] = useState<NodeTracing[]>([])
const [loopRunResult, setLoopRunResult] = useState<NodeTracing[]>([])

useEffect(() => {
if (!checkValid) {
@@ -175,7 +183,7 @@ const useOneStepRun = <T>({
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data._isSingleRun])

const workflowStore = useWorkflowStore()
@@ -214,10 +222,10 @@ const useOneStepRun = <T>({
})
let res: any
try {
if (!isIteration) {
if (!isIteration && !isLoop) {
res = await singleNodeRun(appId!, id, { inputs: submitData }) as any
}
else {
else if (isIteration) {
setIterationRunResult([])
let _iterationResult: NodeTracing[] = []
let _runResult: any = null
@@ -315,11 +323,111 @@ const useOneStepRun = <T>({
},
)
}
if (res.error)
else if (isLoop) {
setLoopRunResult([])
let _loopResult: NodeTracing[] = []
let _runResult: any = null
ssePost(
getLoopSingleNodeRunUrl(isChatMode, appId!, id),
{ body: { inputs: submitData } },
{
onWorkflowStarted: () => {
},
onWorkflowFinished: (params) => {
handleNodeDataUpdate({
id,
data: {
...data,
_singleRunningStatus: NodeRunningStatus.Succeeded,
},
})
const { data: loopData } = params
_runResult.created_by = loopData.created_by.name
setRunResult(_runResult)
},
onLoopStart: (params) => {
const newLoopRunResult = produce(_loopResult, (draft) => {
draft.push({
...params.data,
status: NodeRunningStatus.Running,
})
})
_loopResult = newLoopRunResult
setLoopRunResult(newLoopRunResult)
},
onLoopNext: () => {
// loop next trigger time is triggered one more time than loopTimes
if (_loopResult.length >= loopTimes!)
return _loopResult.length >= loopTimes!
},
onLoopFinish: (params) => {
_runResult = params.data
setRunResult(_runResult)

const loopRunResult = _loopResult
const currentIndex = loopRunResult.findIndex(trace => trace.id === params.data.id)
const newLoopRunResult = produce(loopRunResult, (draft) => {
if (currentIndex > -1) {
draft[currentIndex] = {
...draft[currentIndex],
...data,
}
}
})
_loopResult = newLoopRunResult
setLoopRunResult(newLoopRunResult)
},
onNodeStarted: (params) => {
const newLoopRunResult = produce(_loopResult, (draft) => {
draft.push({
...params.data,
status: NodeRunningStatus.Running,
})
})
_loopResult = newLoopRunResult
setLoopRunResult(newLoopRunResult)
},
onNodeFinished: (params) => {
const loopRunResult = _loopResult

const { data } = params
const currentIndex = loopRunResult.findIndex(trace => trace.id === data.id)
const newLoopRunResult = produce(loopRunResult, (draft) => {
if (currentIndex > -1) {
draft[currentIndex] = {
...draft[currentIndex],
...data,
}
}
})
_loopResult = newLoopRunResult
setLoopRunResult(newLoopRunResult)
},
onNodeRetry: (params) => {
const newLoopRunResult = produce(_loopResult, (draft) => {
draft.push(params.data)
})
_loopResult = newLoopRunResult
setLoopRunResult(newLoopRunResult)
},
onError: () => {
handleNodeDataUpdate({
id,
data: {
...data,
_singleRunningStatus: NodeRunningStatus.Failed,
},
})
},
},
)
}
if (res && res.error)
throw new Error(res.error)
}
catch (e: any) {
if (!isIteration) {
console.error(e)
if (!isIteration && !isLoop) {
handleNodeDataUpdate({
id,
data: {
@@ -331,7 +439,7 @@ const useOneStepRun = <T>({
}
}
finally {
if (!isIteration) {
if (!isIteration && !isLoop) {
setRunResult({
...res,
total_tokens: res.execution_metadata?.total_tokens || 0,
@@ -339,7 +447,7 @@ const useOneStepRun = <T>({
})
}
}
if (!isIteration) {
if (!isIteration && !isLoop) {
handleNodeDataUpdate({
id,
data: {
@@ -430,6 +538,7 @@ const useOneStepRun = <T>({
setRunInputData: handleSetRunInputData,
runResult,
iterationRunResult,
loopRunResult,
}
}


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

@@ -30,6 +30,7 @@ import {
hasRetryNode,
} from '../../utils'
import { useNodeIterationInteractions } from '../iteration/use-interactions'
import { useNodeLoopInteractions } from '../loop/use-interactions'
import type { IterationNodeType } from '../iteration/types'
import {
NodeSourceHandle,
@@ -57,6 +58,7 @@ const BaseNode: FC<BaseNodeProps> = ({
const nodeRef = useRef<HTMLDivElement>(null)
const { nodesReadOnly } = useNodesReadOnly()
const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions()
const toolIcon = useToolIcon(data)

useEffect(() => {
@@ -73,6 +75,20 @@ const BaseNode: FC<BaseNodeProps> = ({
}
}, [data.isInIteration, data.selected, id, handleNodeIterationChildSizeChange])

useEffect(() => {
if (nodeRef.current && data.selected && data.isInLoop) {
const resizeObserver = new ResizeObserver(() => {
handleNodeLoopChildSizeChange(id)
})

resizeObserver.observe(nodeRef.current)

return () => {
resizeObserver.disconnect()
}
}
}, [data.isInLoop, data.selected, id, handleNodeLoopChildSizeChange])

const showSelectedBorder = data.selected || data._isBundled || data._isEntering
const {
showRunningBorder,
@@ -98,16 +114,16 @@ const BaseNode: FC<BaseNodeProps> = ({
)}
ref={nodeRef}
style={{
width: data.type === BlockEnum.Iteration ? data.width : 'auto',
height: data.type === BlockEnum.Iteration ? data.height : 'auto',
width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : 'auto',
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
}}
>
<div
className={cn(
'group relative pb-1 shadow-xs',
'border border-transparent rounded-[15px]',
data.type !== BlockEnum.Iteration && 'w-[240px] bg-workflow-block-bg',
data.type === BlockEnum.Iteration && 'flex flex-col w-full h-full bg-workflow-block-bg-transparent border-workflow-block-border',
(data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && 'w-[240px] bg-workflow-block-bg',
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'flex flex-col w-full h-full bg-workflow-block-bg-transparent border-workflow-block-border',
!data._runningStatus && 'hover:shadow-lg',
showRunningBorder && '!border-state-accent-solid',
showSuccessBorder && '!border-state-success-solid',
@@ -139,6 +155,14 @@ const BaseNode: FC<BaseNodeProps> = ({
/>
)
}
{
data.type === BlockEnum.Loop && (
<NodeResizer
nodeId={id}
nodeData={data}
/>
)
}
{
!data._isCandidate && (
<NodeTargetHandle
@@ -169,7 +193,7 @@ const BaseNode: FC<BaseNodeProps> = ({
}
<div className={cn(
'flex items-center px-3 pt-3 pb-2 rounded-t-2xl',
data.type === BlockEnum.Iteration && 'bg-transparent',
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'bg-transparent',
)}>
<BlockIcon
className='shrink-0 mr-2'
@@ -208,6 +232,13 @@ const BaseNode: FC<BaseNodeProps> = ({
</div>
)
}
{
data._loopLength && data._loopIndex && data._runningStatus === NodeRunningStatus.Running && (
<div className='mr-1.5 text-xs font-medium text-primary-600'>
{data._loopIndex > data._loopLength ? data._loopLength : data._loopIndex}/{data._loopLength}
</div>
)
}
{
(data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running) && (
<RiLoader2Line className='w-3.5 h-3.5 text-text-accent animate-spin' />
@@ -230,12 +261,12 @@ const BaseNode: FC<BaseNodeProps> = ({
}
</div>
{
data.type !== BlockEnum.Iteration && (
data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
cloneElement(children, { id, data })
)
}
{
data.type === BlockEnum.Iteration && (
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && (
<div className='grow pl-1 pr-1 pb-1'>
{cloneElement(children, { id, data })}
</div>
@@ -258,7 +289,7 @@ const BaseNode: FC<BaseNodeProps> = ({
)
}
{
data.desc && data.type !== BlockEnum.Iteration && (
data.desc && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
<div className='px-3 pt-1 pb-2 system-xs-regular text-text-tertiary whitespace-pre-line break-words'>
{data.desc}
</div>

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

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

const handleResize = useCallback((width: number) => {

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

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

const handleOperationListChanges = useCallback((items: AssignerNodeOperation[]) => {
const newInputs = produce(inputs, (draft) => {

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

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

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

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

const handleVarChanges = useCallback((variable: ValueSelector | string) => {
const newInputs = produce(inputs, (draft) => {

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

@@ -35,7 +35,7 @@ export enum ComparisonOperator {
notExists = 'not exists',
}

export interface Condition {
export type Condition = {
id: string
varType: VarType
variable_selector?: ValueSelector
@@ -46,7 +46,7 @@ export interface Condition {
sub_variable_condition?: CaseItem
}

export interface CaseItem {
export type CaseItem = {
case_id: string
logical_operator: LogicalOperator
conditions: Condition[]
@@ -57,6 +57,7 @@ export type IfElseNodeType = CommonNodeType & {
conditions?: Condition[]
cases: CaseItem[]
isInIteration: boolean
isInLoop: boolean
}

export type HandleAddCondition = (caseId: string, valueSelector: ValueSelector, varItem: Var) => void

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

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

const varsIsVarFileAttribute = useMemo(() => {

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

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

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

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

const { varType, itemVarType } = getType()


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

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

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

@@ -0,0 +1,21 @@
import type { NodeDefault } from '../../types'
import type { LoopStartNodeType } from './types'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'

const nodeDefault: NodeDefault<LoopStartNodeType> = {
defaultValue: {},
getAvailablePrevNodes() {
return []
},
getAvailableNextNodes(isChatMode: boolean) {
const nodes = isChatMode ? ALL_CHAT_AVAILABLE_BLOCKS : ALL_COMPLETION_AVAILABLE_BLOCKS
return nodes
},
checkValid() {
return {
isValid: true,
}
},
}

export default nodeDefault

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

@@ -0,0 +1,42 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import type { NodeProps } from 'reactflow'
import { RiHome5Fill } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import { NodeSourceHandle } from '@/app/components/workflow/nodes/_base/components/node-handle'

const LoopStartNode = ({ id, data }: NodeProps) => {
const { t } = useTranslation()

return (
<div className='group flex nodrag items-center justify-center w-11 h-11 mt-1 rounded-2xl border border-workflow-block-border bg-white'>
<Tooltip popupContent={t('workflow.blocks.loop-start')} asChild={false}>
<div className='flex items-center justify-center w-6 h-6 rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500'>
<RiHome5Fill className='w-3 h-3 text-text-primary-on-surface' />
</div>
</Tooltip>
<NodeSourceHandle
id={id}
data={data}
handleClassName='!top-1/2 !-right-[9px] !-translate-y-1/2'
handleId='source'
/>
</div>
)
}

export const LoopStartNodeDumb = () => {
const { t } = useTranslation()

return (
<div className='relative left-[17px] top-[21px] flex nodrag items-center justify-center w-11 h-11 rounded-2xl border border-workflow-block-border bg-white z-[11]'>
<Tooltip popupContent={t('workflow.blocks.loop-start')} asChild={false}>
<div className='flex items-center justify-center w-6 h-6 rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500'>
<RiHome5Fill className='w-3 h-3 text-text-primary-on-surface' />
</div>
</Tooltip>
</div>
)
}

export default memo(LoopStartNode)

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

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

export type LoopStartNodeType = CommonNodeType

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

@@ -0,0 +1,80 @@
import {
memo,
useCallback,
} from 'react'
import {
RiAddLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import {
useAvailableBlocks,
useNodesInteractions,
useNodesReadOnly,
} from '../../hooks'
import type { LoopNodeType } from './types'
import cn from '@/utils/classnames'
import BlockSelector from '@/app/components/workflow/block-selector'

import type {
OnSelectBlock,
} from '@/app/components/workflow/types'
import {
BlockEnum,
} from '@/app/components/workflow/types'

type AddBlockProps = {
loopNodeId: string
loopNodeData: LoopNodeType
}
const AddBlock = ({
loopNodeData,
}: AddBlockProps) => {
const { t } = useTranslation()
const { nodesReadOnly } = useNodesReadOnly()
const { handleNodeAdd } = useNodesInteractions()
const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false, true)

const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
handleNodeAdd(
{
nodeType: type,
toolDefaultValue,
},
{
prevNodeId: loopNodeData.start_node_id,
prevNodeSourceHandle: 'source',
},
)
}, [handleNodeAdd, loopNodeData.start_node_id])

const renderTriggerElement = useCallback((open: boolean) => {
return (
<div className={cn(
'relative inline-flex items-center px-3 h-8 rounded-lg border-[0.5px] border-gray-50 bg-white shadow-xs cursor-pointer hover:bg-gray-200 text-[13px] font-medium text-gray-700',
`${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
open && '!bg-gray-50',
)}>
<RiAddLine className='mr-1 w-4 h-4' />
{t('workflow.common.addBlock')}
</div>
)
}, [nodesReadOnly, t])

return (
<div className='absolute top-7 left-14 flex items-center h-8 z-10'>
<div className='group/insert relative w-16 h-0.5 bg-gray-300'>
<div className='absolute right-0 top-1/2 -translate-y-1/2 w-0.5 h-2 bg-primary-500'></div>
</div>
<BlockSelector
disabled={nodesReadOnly}
onSelect={handleSelect}
trigger={renderTriggerElement}
triggerInnerClassName='inline-flex'
popupClassName='!min-w-[256px]'
availableBlocksTypes={availableNextBlocks}
/>
</div>
)
}

export default memo(AddBlock)

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

@@ -0,0 +1,74 @@
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiAddLine } from '@remixicon/react'
import type { HandleAddCondition } from '../types'
import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import type {
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'

type ConditionAddProps = {
className?: string
variables: NodeOutPutVar[]
onSelectVariable: HandleAddCondition
disabled?: boolean
}
const ConditionAdd = ({
className,
variables,
onSelectVariable,
disabled,
}: ConditionAddProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)

const handleSelectVariable = useCallback((valueSelector: ValueSelector, varItem: Var) => {
onSelectVariable(valueSelector, varItem)
setOpen(false)
}, [onSelectVariable, setOpen])

return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
<Button
size='small'
className={className}
disabled={disabled}
>
<RiAddLine className='mr-1 w-3.5 h-3.5' />
{t('workflow.nodes.ifElse.addCondition')}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='w-[296px] bg-components-panel-bg-blur rounded-lg border-[0.5px] border-components-panel-border shadow-lg'>
<VarReferenceVars
vars={variables}
isSupportFileVar
onChange={handleSelectVariable}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

export default ConditionAdd

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

@@ -0,0 +1,115 @@
import {
memo,
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import { ComparisonOperator, type Condition } from '../types'
import {
comparisonOperatorNotRequireValue,
isComparisonOperatorNeedTranslate,
isEmptyRelatedOperator,
} from '../utils'
import type { ValueSelector } from '../../../types'
import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from './../default'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
import cn from '@/utils/classnames'
import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
const i18nPrefix = 'workflow.nodes.ifElse'

type ConditionValueProps = {
condition: Condition
}
const ConditionValue = ({
condition,
}: ConditionValueProps) => {
const { t } = useTranslation()
const {
variable_selector,
comparison_operator: operator,
sub_variable_condition,
} = condition

const variableSelector = variable_selector as ValueSelector

const variableName = (isSystemVar(variableSelector) ? variableSelector.slice(0).join('.') : variableSelector.slice(1).join('.'))
const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}`) : operator
const notHasValue = comparisonOperatorNotRequireValue(operator)
const isEnvVar = isENV(variableSelector)
const isChatVar = isConversationVar(variableSelector)
const formatValue = useCallback((c: Condition) => {
const notHasValue = comparisonOperatorNotRequireValue(c.comparison_operator)
if (notHasValue)
return ''

const value = c.value as string
return value.replace(/{{#([^#]*)#}}/g, (a, b) => {
const arr: string[] = b.split('.')
if (isSystemVar(arr))
return `{{${b}}}`

return `{{${arr.slice(1).join('.')}}}`
})
}, [])

const isSelect = useCallback((c: Condition) => {
return c.comparison_operator === ComparisonOperator.in || c.comparison_operator === ComparisonOperator.notIn
}, [])

const selectName = useCallback((c: Condition) => {
const isSelect = c.comparison_operator === ComparisonOperator.in || c.comparison_operator === ComparisonOperator.notIn
if (isSelect) {
const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(c.value) ? c.value[0] : c.value))[0]
return name
? t(`workflow.nodes.ifElse.optionName.${name.i18nKey}`).replace(/{{#([^#]*)#}}/g, (a, b) => {
const arr: string[] = b.split('.')
if (isSystemVar(arr))
return `{{${b}}}`

return `{{${arr.slice(1).join('.')}}}`
})
: ''
}
return ''
}, [t])

return (
<div className='rounded-md bg-workflow-block-parma-bg'>
<div className='flex items-center px-1 h-6 '>
{!isEnvVar && !isChatVar && <Variable02 className='shrink-0 mr-1 w-3.5 h-3.5 text-text-accent' />}
{isEnvVar && <Env className='shrink-0 mr-1 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}

<div
className={cn(
'shrink-0 truncate text-xs font-medium text-text-accent',
!notHasValue && 'max-w-[70px]',
)}
title={variableName}
>
{variableName}
</div>
<div
className='shrink-0 mx-1 text-xs font-medium text-text-primary'
title={operatorName}
>
{operatorName}
</div>
</div>
<div className='ml-[10px] pl-[10px] border-l border-divider-regular'>
{
sub_variable_condition?.conditions.map((c: Condition, index) => (
<div className='relative flex items-center h-6 space-x-1' key={c.id}>
<div className='text-text-accent system-xs-medium'>{c.key}</div>
<div className='text-text-primary system-xs-medium'>{isComparisonOperatorNeedTranslate(c.comparison_operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${c.comparison_operator}`) : c.comparison_operator}</div>
{c.comparison_operator && !isEmptyRelatedOperator(c.comparison_operator) && <div className='text-text-secondary system-xs-regular'>{isSelect(c) ? selectName(c) : formatValue(c)}</div>}
{index !== sub_variable_condition.conditions.length - 1 && (<div className='absolute z-10 right-1 bottom-[-10px] leading-4 text-[10px] font-medium text-text-accent uppercase'>{t(`${i18nPrefix}.${sub_variable_condition.logical_operator}`)}</div>)}
</div>
))
}
</div>
</div>
)
}

export default memo(ConditionValue)

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

@@ -0,0 +1,53 @@
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/workflow/store'
import PromptEditor from '@/app/components/base/prompt-editor'
import { BlockEnum } from '@/app/components/workflow/types'
import type {
Node,
} from '@/app/components/workflow/types'

type ConditionInputProps = {
disabled?: boolean
value: string
onChange: (value: string) => void
availableNodes: Node[]
}
const ConditionInput = ({
value,
onChange,
disabled,
availableNodes,
}: ConditionInputProps) => {
const { t } = useTranslation()
const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey)

return (
<PromptEditor
key={controlPromptEditorRerenderKey}
compact
value={value}
placeholder={t('workflow.nodes.ifElse.enterValue') || ''}
workflowVariableBlock={{
show: true,
variables: [],
workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
title: t('workflow.blocks.start'),
type: BlockEnum.Start,
}
}
return acc
}, {} as any),
}}
onChange={onChange}
editable={!disabled}
/>
)
}

export default ConditionInput

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

@@ -0,0 +1,330 @@
import {
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiDeleteBinLine } from '@remixicon/react'
import produce from 'immer'
import type { VarType as NumberVarType } from '../../../tool/types'
import type {
Condition,
HandleAddSubVariableCondition,
HandleRemoveCondition,
HandleToggleSubVariableConditionLogicalOperator,
HandleUpdateCondition,
HandleUpdateSubVariableCondition,
handleRemoveSubVariableCondition,
} from '../../types'
import {
ComparisonOperator,
} from '../../types'
import ConditionNumberInput from '../condition-number-input'
import ConditionWrap from '../condition-wrap'
import { comparisonOperatorNotRequireValue, getOperators } from './../../utils'
import ConditionOperator from './condition-operator'
import ConditionInput from './condition-input'
import { FILE_TYPE_OPTIONS, SUB_VARIABLES, TRANSFER_METHOD } from './../../default'
import type {
Node,
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
import { SimpleSelect as Select } from '@/app/components/base/select'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import ConditionVarSelector from './condition-var-selector'

const optionNameI18NPrefix = 'workflow.nodes.ifElse.optionName'

type ConditionItemProps = {
className?: string
disabled?: boolean
conditionId: string // in isSubVariableKey it's the value of the parent condition's id
condition: Condition // condition may the condition of case or condition of sub variable
file?: { key: string }
isSubVariableKey?: boolean
isValueFieldShort?: boolean
onRemoveCondition?: HandleRemoveCondition
onUpdateCondition?: HandleUpdateCondition
onAddSubVariableCondition?: HandleAddSubVariableCondition
onRemoveSubVariableCondition?: handleRemoveSubVariableCondition
onUpdateSubVariableCondition?: HandleUpdateSubVariableCondition
onToggleSubVariableConditionLogicalOperator?: HandleToggleSubVariableConditionLogicalOperator
nodeId: string
availableNodes: Node[]
numberVariables: NodeOutPutVar[]
availableVars: NodeOutPutVar[]
}
const ConditionItem = ({
className,
disabled,
conditionId,
condition,
file,
isSubVariableKey,
isValueFieldShort,
onRemoveCondition,
onUpdateCondition,
onAddSubVariableCondition,
onRemoveSubVariableCondition,
onUpdateSubVariableCondition,
onToggleSubVariableConditionLogicalOperator,
nodeId,
availableNodes,
numberVariables,
availableVars,
}: ConditionItemProps) => {
const { t } = useTranslation()

const [isHovered, setIsHovered] = useState(false)
const [open, setOpen] = useState(false)

const doUpdateCondition = useCallback((newCondition: Condition) => {
if (isSubVariableKey)
onUpdateSubVariableCondition?.(conditionId, condition.id, newCondition)
else
onUpdateCondition?.(condition.id, newCondition)
}, [condition, conditionId, isSubVariableKey, onUpdateCondition, onUpdateSubVariableCondition])

const canChooseOperator = useMemo(() => {
if (disabled)
return false

if (isSubVariableKey)
return !!condition.key

return true
}, [condition.key, disabled, isSubVariableKey])
const handleUpdateConditionOperator = useCallback((value: ComparisonOperator) => {
const newCondition = {
...condition,
comparison_operator: value,
}
doUpdateCondition(newCondition)
}, [condition, doUpdateCondition])

const handleUpdateConditionNumberVarType = useCallback((numberVarType: NumberVarType) => {
const newCondition = {
...condition,
numberVarType,
value: '',
}
doUpdateCondition(newCondition)
}, [condition, doUpdateCondition])

const isSubVariable = condition.varType === VarType.arrayFile && [ComparisonOperator.contains, ComparisonOperator.notContains, ComparisonOperator.allOf].includes(condition.comparison_operator!)
const fileAttr = useMemo(() => {
if (file)
return file
if (isSubVariableKey) {
return {
key: condition.key!,
}
}
return undefined
}, [condition.key, file, isSubVariableKey])

const isArrayValue = fileAttr?.key === 'transfer_method' || fileAttr?.key === 'type'

const handleUpdateConditionValue = useCallback((value: string) => {
if (value === condition.value || (isArrayValue && value === condition.value?.[0]))
return
const newCondition = {
...condition,
value: isArrayValue ? [value] : value,
}
doUpdateCondition(newCondition)
}, [condition, doUpdateCondition, isArrayValue])

const isSelect = condition.comparison_operator && [ComparisonOperator.in, ComparisonOperator.notIn].includes(condition.comparison_operator)
const selectOptions = useMemo(() => {
if (isSelect) {
if (fileAttr?.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
return FILE_TYPE_OPTIONS.map(item => ({
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`),
value: item.value,
}))
}
if (fileAttr?.key === 'transfer_method') {
return TRANSFER_METHOD.map(item => ({
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`),
value: item.value,
}))
}
return []
}
return []
}, [condition.comparison_operator, fileAttr?.key, isSelect, t])

const isNotInput = isSelect || isSubVariable

const isSubVarSelect = isSubVariableKey
const subVarOptions = SUB_VARIABLES.map(item => ({
name: item,
value: item,
}))

const handleSubVarKeyChange = useCallback((key: string) => {
const newCondition = produce(condition, (draft) => {
draft.key = key
if (key === 'size')
draft.varType = VarType.number
else
draft.varType = VarType.string

draft.value = ''
draft.comparison_operator = getOperators(undefined, { key })[0]
})

onUpdateSubVariableCondition?.(conditionId, condition.id, newCondition)
}, [condition, conditionId, onUpdateSubVariableCondition])

const doRemoveCondition = useCallback(() => {
if (isSubVariableKey)
onRemoveSubVariableCondition?.(conditionId, condition.id)
else
onRemoveCondition?.(condition.id)
}, [condition, conditionId, isSubVariableKey, onRemoveCondition, onRemoveSubVariableCondition])

const handleVarChange = useCallback((valueSelector: ValueSelector, varItem: Var) => {
const newCondition = produce(condition, (draft) => {
draft.variable_selector = valueSelector
draft.varType = varItem.type
draft.value = ''
draft.comparison_operator = getOperators(varItem.type)[0]
})
doUpdateCondition(newCondition)
setOpen(false)
}, [condition, doUpdateCondition])

return (
<div className={cn('flex mb-1 last-of-type:mb-0', className)}>
<div className={cn(
'grow bg-components-input-bg-normal rounded-lg',
isHovered && 'bg-state-destructive-hover',
)}>
<div className='flex items-center p-1'>
<div className='grow w-0'>
{isSubVarSelect
? (
<Select
wrapperClassName='h-6'
className='pl-0 text-xs'
optionWrapClassName='w-[165px] max-h-none'
defaultValue={condition.key}
items={subVarOptions}
onSelect={item => handleSubVarKeyChange(item.value as string)}
renderTrigger={item => (
item
? <div className='flex justify-start cursor-pointer'>
<div className='inline-flex max-w-full px-1.5 items-center h-6 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark shadow-xs text-text-accent'>
<Variable02 className='shrink-0 w-3.5 h-3.5 text-text-accent' />
<div className='ml-0.5 truncate system-xs-medium'>{item?.name}</div>
</div>
</div>
: <div className='text-left text-components-input-text-placeholder system-sm-regular'>{t('common.placeholder.select')}</div>
)}
hideChecked
/>
)
: (
<ConditionVarSelector
open={open}
onOpenChange={setOpen}
valueSelector={condition.variable_selector || []}
varType={condition.varType}
availableNodes={availableNodes}
nodesOutputVars={availableVars}
onChange={handleVarChange}
/>
)}

</div>
<div className='mx-1 w-[1px] h-3 bg-divider-regular'></div>
<ConditionOperator
disabled={!canChooseOperator}
varType={condition.varType}
value={condition.comparison_operator}
onSelect={handleUpdateConditionOperator}
file={fileAttr}
/>
</div>
{
!comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType !== VarType.number && (
<div className='px-2 py-1 max-h-[100px] border-t border-t-divider-subtle overflow-y-auto'>
<ConditionInput
disabled={disabled}
value={condition.value as string}
onChange={handleUpdateConditionValue}
availableNodes={availableNodes}
/>
</div>
)
}
{
!comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType === VarType.number && (
<div className='px-2 py-1 pt-[3px] border-t border-t-divider-subtle'>
<ConditionNumberInput
numberVarType={condition.numberVarType}
onNumberVarTypeChange={handleUpdateConditionNumberVarType}
value={condition.value as string}
onValueChange={handleUpdateConditionValue}
variables={numberVariables}
isShort={isValueFieldShort}
unit={fileAttr?.key === 'size' ? 'Byte' : undefined}
/>
</div>
)
}
{
!comparisonOperatorNotRequireValue(condition.comparison_operator) && isSelect && (
<div className='border-t border-t-divider-subtle'>
<Select
wrapperClassName='h-8'
className='px-2 text-xs rounded-t-none'
defaultValue={isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string)}
items={selectOptions}
onSelect={item => handleUpdateConditionValue(item.value as string)}
hideChecked
notClearable
/>
</div>
)
}
{
!comparisonOperatorNotRequireValue(condition.comparison_operator) && isSubVariable && (
<div className='p-1'>
<ConditionWrap
isSubVariable
conditions={condition.sub_variable_condition?.conditions || []}
logicalOperator={condition.sub_variable_condition?.logical_operator}
conditionId={conditionId}
readOnly={!!disabled}
handleAddSubVariableCondition={onAddSubVariableCondition}
handleRemoveSubVariableCondition={onRemoveSubVariableCondition}
handleUpdateSubVariableCondition={onUpdateSubVariableCondition}
handleToggleSubVariableConditionLogicalOperator={onToggleSubVariableConditionLogicalOperator}
nodeId={nodeId}
availableNodes={availableNodes}
availableVars={availableVars}
/>
</div>
)
}
</div>
<div
className='shrink-0 flex items-center justify-center ml-1 mt-1 w-6 h-6 rounded-lg cursor-pointer hover:bg-state-destructive-hover text-text-tertiary hover:text-text-destructive'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={doRemoveCondition}
>
<RiDeleteBinLine className='w-4 h-4' />
</div>
</div>
)
}

export default ConditionItem

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

@@ -0,0 +1,94 @@
import {
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import { getOperators, isComparisonOperatorNeedTranslate } from '../../utils'
import type { ComparisonOperator } from '../../types'
import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import type { VarType } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
const i18nPrefix = 'workflow.nodes.ifElse'

type ConditionOperatorProps = {
className?: string
disabled?: boolean
varType: VarType
file?: { key: string }
value?: string
onSelect: (value: ComparisonOperator) => void
}
const ConditionOperator = ({
className,
disabled,
varType,
file,
value,
onSelect,
}: ConditionOperatorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)

const options = useMemo(() => {
return getOperators(varType, file).map((o) => {
return {
label: isComparisonOperatorNeedTranslate(o) ? t(`${i18nPrefix}.comparisonOperator.${o}`) : o,
value: o,
}
})
}, [t, varType, file])
const selectedOption = options.find(o => Array.isArray(value) ? o.value === value[0] : o.value === value)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Button
className={cn('shrink-0', !selectedOption && 'opacity-50', className)}
size='small'
variant='ghost'
disabled={disabled}
>
{
selectedOption
? selectedOption.label
: t(`${i18nPrefix}.select`)
}
<RiArrowDownSLine className='ml-1 w-3.5 h-3.5' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='p-1 bg-components-panel-bg-blur rounded-xl border-[0.5px] border-components-panel-border shadow-lg'>
{
options.map(option => (
<div
key={option.value}
className='flex items-center px-3 py-1.5 h-7 text-[13px] font-medium text-text-secondary rounded-lg cursor-pointer hover:bg-state-base-hover'
onClick={() => {
onSelect(option.value)
setOpen(false)
}}
>
{option.label}
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

export default ConditionOperator

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

@@ -0,0 +1,58 @@
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import type { Node, NodeOutPutVar, ValueSelector, Var, VarType } from '@/app/components/workflow/types'

type ConditionVarSelectorProps = {
open: boolean
onOpenChange: (open: boolean) => void
valueSelector: ValueSelector
varType: VarType
availableNodes: Node[]
nodesOutputVars: NodeOutPutVar[]
onChange: (valueSelector: ValueSelector, varItem: Var) => void
}

const ConditionVarSelector = ({
open,
onOpenChange,
valueSelector,
varType,
availableNodes,
nodesOutputVars,
onChange,
}: ConditionVarSelectorProps) => {
return (
<PortalToFollowElem
open={open}
onOpenChange={onOpenChange}
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => onOpenChange(!open)}>
<div className="cursor-pointer">
<VariableTag
valueSelector={valueSelector}
varType={varType}
availableNodes={availableNodes}
isShort
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='w-[296px] bg-components-panel-bg-blur rounded-lg border-[0.5px] border-components-panel-border shadow-lg'>
<VarReferenceVars
vars={nodesOutputVars}
isSupportFileVar
onChange={onChange}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

export default ConditionVarSelector

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

@@ -0,0 +1,126 @@
import { RiLoopLeftLine } from '@remixicon/react'
import { useCallback, useMemo } from 'react'
import {
type Condition,
type HandleAddSubVariableCondition,
type HandleRemoveCondition,
type HandleToggleConditionLogicalOperator,
type HandleToggleSubVariableConditionLogicalOperator,
type HandleUpdateCondition,
type HandleUpdateSubVariableCondition,
LogicalOperator,
type handleRemoveSubVariableCondition,
} from '../../types'
import ConditionItem from './condition-item'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import cn from '@/utils/classnames'

type ConditionListProps = {
isSubVariable?: boolean
disabled?: boolean
conditionId?: string
conditions: Condition[]
logicalOperator?: LogicalOperator
onRemoveCondition?: HandleRemoveCondition
onUpdateCondition?: HandleUpdateCondition
onToggleConditionLogicalOperator?: HandleToggleConditionLogicalOperator
nodeId: string
availableNodes: Node[]
numberVariables: NodeOutPutVar[]
onAddSubVariableCondition?: HandleAddSubVariableCondition
onRemoveSubVariableCondition?: handleRemoveSubVariableCondition
onUpdateSubVariableCondition?: HandleUpdateSubVariableCondition
onToggleSubVariableConditionLogicalOperator?: HandleToggleSubVariableConditionLogicalOperator
availableVars: NodeOutPutVar[]
}
const ConditionList = ({
isSubVariable,
disabled,
conditionId,
conditions,
logicalOperator,
onUpdateCondition,
onRemoveCondition,
onToggleConditionLogicalOperator,
onAddSubVariableCondition,
onRemoveSubVariableCondition,
onUpdateSubVariableCondition,
onToggleSubVariableConditionLogicalOperator,
nodeId,
availableNodes,
numberVariables,
availableVars,
}: ConditionListProps) => {
const doToggleConditionLogicalOperator = useCallback((conditionId?: string) => {
if (isSubVariable && conditionId)
onToggleSubVariableConditionLogicalOperator?.(conditionId)
else
onToggleConditionLogicalOperator?.()
}, [isSubVariable, onToggleConditionLogicalOperator, onToggleSubVariableConditionLogicalOperator])

const isValueFieldShort = useMemo(() => {
if (isSubVariable && conditions.length > 1)
return true

return false
}, [conditions.length, isSubVariable])
const conditionItemClassName = useMemo(() => {
if (!isSubVariable)
return ''
if (conditions.length < 2)
return ''
return logicalOperator === LogicalOperator.and ? 'pl-[51px]' : 'pl-[42px]'
}, [conditions.length, isSubVariable, logicalOperator])

return (
<div className={cn('relative', conditions.length > 1 && !isSubVariable && 'pl-[60px]')}>
{
conditions.length > 1 && (
<div className={cn(
'absolute top-0 bottom-0 left-0 w-[60px]',
isSubVariable && logicalOperator === LogicalOperator.and && 'left-[-10px]',
isSubVariable && logicalOperator === LogicalOperator.or && 'left-[-18px]',
)}>
<div className='absolute top-4 bottom-4 left-[46px] w-2.5 border border-divider-deep rounded-l-[8px] border-r-0'></div>
<div className='absolute top-1/2 -translate-y-1/2 right-0 w-4 h-[29px] bg-components-panel-bg'></div>
<div
className='absolute top-1/2 right-1 -translate-y-1/2 flex items-center px-1 h-[21px] rounded-md border-[0.5px] border-components-button-secondary-border shadow-xs bg-components-button-secondary-bg text-text-accent-secondary text-[10px] font-semibold cursor-pointer select-none'
onClick={() => doToggleConditionLogicalOperator(conditionId)}
>
{logicalOperator && logicalOperator.toUpperCase()}
<RiLoopLeftLine className='ml-0.5 w-3 h-3' />
</div>
</div>
)
}
{
conditions.map(condition => (
<ConditionItem
key={condition.id}
className={conditionItemClassName}
disabled={disabled}
conditionId={isSubVariable ? conditionId! : condition.id}
condition={condition}
isValueFieldShort={isValueFieldShort}
onUpdateCondition={onUpdateCondition}
onRemoveCondition={onRemoveCondition}
onAddSubVariableCondition={onAddSubVariableCondition}
onRemoveSubVariableCondition={onRemoveSubVariableCondition}
onUpdateSubVariableCondition={onUpdateSubVariableCondition}
onToggleSubVariableConditionLogicalOperator={onToggleSubVariableConditionLogicalOperator}
nodeId={nodeId}
availableNodes={availableNodes}
numberVariables={numberVariables}
isSubVariableKey={isSubVariable}
availableVars={availableVars}
/>
))
}
</div>
)
}

export default ConditionList

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

@@ -0,0 +1,168 @@
import {
memo,
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import { capitalize } from 'lodash-es'
import { useBoolean } from 'ahooks'
import { VarType as NumberVarType } from '../../tool/types'
import VariableTag from '../../_base/components/variable-tag'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import type {
NodeOutPutVar,
ValueSelector,
} from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
import { variableTransformer } from '@/app/components/workflow/utils'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'

const options = [
NumberVarType.variable,
NumberVarType.constant,
]

type ConditionNumberInputProps = {
numberVarType?: NumberVarType
onNumberVarTypeChange: (v: NumberVarType) => void
value: string
onValueChange: (v: string) => void
variables: NodeOutPutVar[]
isShort?: boolean
unit?: string
}
const ConditionNumberInput = ({
numberVarType = NumberVarType.constant,
onNumberVarTypeChange,
value,
onValueChange,
variables,
isShort,
unit,
}: ConditionNumberInputProps) => {
const { t } = useTranslation()
const [numberVarTypeVisible, setNumberVarTypeVisible] = useState(false)
const [variableSelectorVisible, setVariableSelectorVisible] = useState(false)
const [isFocus, {
setTrue: setFocus,
setFalse: setBlur,
}] = useBoolean()

const handleSelectVariable = useCallback((valueSelector: ValueSelector) => {
onValueChange(variableTransformer(valueSelector) as string)
setVariableSelectorVisible(false)
}, [onValueChange])

return (
<div className='flex items-center cursor-pointer'>
<PortalToFollowElem
open={numberVarTypeVisible}
onOpenChange={setNumberVarTypeVisible}
placement='bottom-start'
offset={{ mainAxis: 2, crossAxis: 0 }}
>
<PortalToFollowElemTrigger onClick={() => setNumberVarTypeVisible(v => !v)}>
<Button
className='shrink-0'
variant='ghost'
size='small'
>
{capitalize(numberVarType)}
<RiArrowDownSLine className='ml-[1px] w-3.5 h-3.5' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='p-1 w-[112px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg'>
{
options.map(option => (
<div
key={option}
className={cn(
'flex items-center px-3 h-7 rounded-md hover:bg-state-base-hover cursor-pointer',
'text-[13px] font-medium text-text-secondary',
numberVarType === option && 'bg-state-base-hover',
)}
onClick={() => {
onNumberVarTypeChange(option)
setNumberVarTypeVisible(false)
}}
>
{capitalize(option)}
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<div className='mx-1 w-[1px] h-4 bg-divider-regular'></div>
<div className='grow w-0 ml-0.5'>
{
numberVarType === NumberVarType.variable && (
<PortalToFollowElem
open={variableSelectorVisible}
onOpenChange={setVariableSelectorVisible}
placement='bottom-start'
offset={{ mainAxis: 2, crossAxis: 0 }}
>
<PortalToFollowElemTrigger
className='w-full'
onClick={() => setVariableSelectorVisible(v => !v)}>
{
value && (
<VariableTag
valueSelector={variableTransformer(value) as string[]}
varType={VarType.number}
isShort={isShort}
/>
)
}
{
!value && (
<div className='flex items-center p-1 h-6 text-components-input-text-placeholder text-[13px]'>
<Variable02 className='shrink-0 mr-1 w-4 h-4' />
<div className='w-0 grow truncate'>{t('workflow.nodes.ifElse.selectVariable')}</div>
</div>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className={cn('w-[296px] pt-1 bg-components-panel-bg-blur rounded-lg border-[0.5px] border-components-panel-border shadow-lg', isShort && 'w-[200px]')}>
<VarReferenceVars
vars={variables}
onChange={handleSelectVariable}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
{
numberVarType === NumberVarType.constant && (
<div className=' relative'>
<input
className={cn('block w-full px-2 text-[13px] text-components-input-text-filled placeholder:text-components-input-text-placeholder outline-none appearance-none bg-transparent', unit && 'pr-6')}
type='number'
value={value}
onChange={e => onValueChange(e.target.value)}
placeholder={t('workflow.nodes.ifElse.enterValue') || ''}
onFocus={setFocus}
onBlur={setBlur}
/>
{!isFocus && unit && <div className='absolute right-2 top-[50%] translate-y-[-50%] text-text-tertiary system-sm-regular'>{unit}</div>}
</div>
)
}
</div>
</div>
)
}

export default memo(ConditionNumberInput)

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

@@ -0,0 +1,98 @@
import {
memo,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import { ComparisonOperator } from '../types'
import {
comparisonOperatorNotRequireValue,
isComparisonOperatorNeedTranslate,
} from '../utils'
import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from './../default'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
import cn from '@/utils/classnames'
import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'

type ConditionValueProps = {
variableSelector: string[]
labelName?: string
operator: ComparisonOperator
value: string | string[]
}
const ConditionValue = ({
variableSelector,
labelName,
operator,
value,
}: ConditionValueProps) => {
const { t } = useTranslation()
const variableName = labelName || (isSystemVar(variableSelector) ? variableSelector.slice(0).join('.') : variableSelector.slice(1).join('.'))
const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}`) : operator
const notHasValue = comparisonOperatorNotRequireValue(operator)
const isEnvVar = isENV(variableSelector)
const isChatVar = isConversationVar(variableSelector)
const formatValue = useMemo(() => {
if (notHasValue)
return ''

if (Array.isArray(value)) // transfer method
return value[0]

return value.replace(/{{#([^#]*)#}}/g, (a, b) => {
const arr: string[] = b.split('.')
if (isSystemVar(arr))
return `{{${b}}}`

return `{{${arr.slice(1).join('.')}}}`
})
}, [notHasValue, value])

const isSelect = operator === ComparisonOperator.in || operator === ComparisonOperator.notIn
const selectName = useMemo(() => {
if (isSelect) {
const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(value) ? value[0] : value))[0]
return name
? t(`workflow.nodes.ifElse.optionName.${name.i18nKey}`).replace(/{{#([^#]*)#}}/g, (a, b) => {
const arr: string[] = b.split('.')
if (isSystemVar(arr))
return `{{${b}}}`

return `{{${arr.slice(1).join('.')}}}`
})
: ''
}
return ''
}, [isSelect, t, value])

return (
<div className='flex items-center px-1 h-6 rounded-md bg-workflow-block-parma-bg'>
{!isEnvVar && !isChatVar && <Variable02 className='shrink-0 mr-1 w-3.5 h-3.5 text-text-accent' />}
{isEnvVar && <Env className='shrink-0 mr-1 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}

<div
className={cn(
'shrink-0 truncate text-xs font-medium text-text-accent',
!notHasValue && 'max-w-[70px]',
)}
title={variableName}
>
{variableName}
</div>
<div
className='shrink-0 mx-1 text-xs font-medium text-text-primary'
title={operatorName}
>
{operatorName}
</div>
{
!notHasValue && (
<div className='truncate text-xs text-text-secondary' title={formatValue}>{isSelect ? selectName : formatValue}</div>
)
}
</div>
)
}

export default memo(ConditionValue)

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

@@ -0,0 +1,149 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiAddLine,
} from '@remixicon/react'
import type { Condition, HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition, LogicalOperator, handleRemoveSubVariableCondition } from '../types'
import type { Node, NodeOutPutVar, Var } from '../../../types'
import { VarType } from '../../../types'
import { useGetAvailableVars } from '../../variable-assigner/hooks'
import ConditionList from './condition-list'
import ConditionAdd from './condition-add'
import { SUB_VARIABLES } from './../default'
import cn from '@/utils/classnames'
import Button from '@/app/components/base/button'
import { PortalSelect as Select } from '@/app/components/base/select'

type Props = {
isSubVariable?: boolean
conditionId?: string
conditions: Condition[]
logicalOperator: LogicalOperator | undefined
readOnly: boolean
handleAddCondition?: HandleAddCondition
handleRemoveCondition?: HandleRemoveCondition
handleUpdateCondition?: HandleUpdateCondition
handleToggleConditionLogicalOperator?: HandleToggleConditionLogicalOperator
handleAddSubVariableCondition?: HandleAddSubVariableCondition
handleRemoveSubVariableCondition?: handleRemoveSubVariableCondition
handleUpdateSubVariableCondition?: HandleUpdateSubVariableCondition
handleToggleSubVariableConditionLogicalOperator?: HandleToggleSubVariableConditionLogicalOperator
nodeId: string
availableNodes: Node[]
availableVars: NodeOutPutVar[]
}

const ConditionWrap: FC<Props> = ({
isSubVariable,
conditionId,
conditions,
logicalOperator,
nodeId: id = '',
readOnly,
handleUpdateCondition,
handleAddCondition,
handleRemoveCondition,
handleToggleConditionLogicalOperator,
handleAddSubVariableCondition,
handleRemoveSubVariableCondition,
handleUpdateSubVariableCondition,
handleToggleSubVariableConditionLogicalOperator,
availableNodes = [],
availableVars = [],
}) => {
const { t } = useTranslation()

const getAvailableVars = useGetAvailableVars()

const filterNumberVar = useCallback((varPayload: Var) => {
return varPayload.type === VarType.number
}, [])

const subVarOptions = SUB_VARIABLES.map(item => ({
name: item,
value: item,
}))

if (!conditions)
return <div />

return (
<>
<div>
<div
className={cn(
'group relative rounded-[10px] bg-components-panel-bg',
!isSubVariable && 'py-1 px-3 min-h-[40px] ',
isSubVariable && 'px-1 py-2',
)}
>
{
conditions && !!conditions.length && (
<div className='mb-2'>
<ConditionList
disabled={readOnly}
conditionId={conditionId}
conditions={conditions}
logicalOperator={logicalOperator}
onUpdateCondition={handleUpdateCondition}
onRemoveCondition={handleRemoveCondition}
onToggleConditionLogicalOperator={handleToggleConditionLogicalOperator}
nodeId={id}
availableNodes={availableNodes}
numberVariables={getAvailableVars(id, '', filterNumberVar)}
onAddSubVariableCondition={handleAddSubVariableCondition}
onRemoveSubVariableCondition={handleRemoveSubVariableCondition}
onUpdateSubVariableCondition={handleUpdateSubVariableCondition}
onToggleSubVariableConditionLogicalOperator={handleToggleSubVariableConditionLogicalOperator}
isSubVariable={isSubVariable}
availableVars={availableVars}
/>
</div>
)
}

<div className={cn(
'flex items-center justify-between pr-[30px]',
!conditions.length && !isSubVariable && 'mt-1',
!conditions.length && isSubVariable && 'mt-2',
conditions.length > 1 && !isSubVariable && 'ml-[60px]',
)}>
{isSubVariable
? (
<Select
popupInnerClassName='w-[165px] max-h-none'
onSelect={value => handleAddSubVariableCondition?.(conditionId!, value.value as string)}
items={subVarOptions}
value=''
renderTrigger={() => (
<Button
size='small'
disabled={readOnly}
>
<RiAddLine className='mr-1 w-3.5 h-3.5' />
{t('workflow.nodes.ifElse.addSubVariable')}
</Button>
)}
hideChecked
/>
)
: (
<ConditionAdd
disabled={readOnly}
variables={availableVars}
onSelectVariable={handleAddCondition!}
/>
)}
</div>
</div>
{!isSubVariable && (
<div className='my-2 mx-3 h-[1px] bg-divider-subtle'></div>
)}
</div>
</>
)
}

export default React.memo(ConditionWrap)

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

@@ -0,0 +1,92 @@
import { BlockEnum } from '../../types'
import type { NodeDefault } from '../../types'
import { ComparisonOperator, LogicalOperator, type LoopNodeType } from './types'
import { isEmptyRelatedOperator } from './utils'
import { TransferMethod } from '@/types/app'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
import { LOOP_NODE_MAX_COUNT } from '@/config'
const i18nPrefix = 'workflow.errorMsg'

const nodeDefault: NodeDefault<LoopNodeType> = {
defaultValue: {
start_node_id: '',
break_conditions: [],
loop_count: 10,
_children: [],
logical_operator: LogicalOperator.and,
},
getAvailablePrevNodes(isChatMode: boolean) {
const nodes = isChatMode
? ALL_CHAT_AVAILABLE_BLOCKS
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
return nodes
},
getAvailableNextNodes(isChatMode: boolean) {
const nodes = isChatMode ? ALL_CHAT_AVAILABLE_BLOCKS : ALL_COMPLETION_AVAILABLE_BLOCKS
return nodes
},
checkValid(payload: LoopNodeType, t: any) {
let errorMessages = ''

if (!errorMessages && (!payload.break_conditions || payload.break_conditions.length === 0))
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.loop.breakCondition') })

payload.break_conditions!.forEach((condition) => {
if (!errorMessages && (!condition.variable_selector || condition.variable_selector.length === 0))
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variable`) })
if (!errorMessages && !condition.comparison_operator)
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.ifElse.operator') })
if (!errorMessages) {
if (condition.sub_variable_condition
&& ![ComparisonOperator.empty, ComparisonOperator.notEmpty].includes(condition.comparison_operator!)) {
const isSet = condition.sub_variable_condition.conditions.every((c) => {
if (!c.comparison_operator)
return false

if (isEmptyRelatedOperator(c.comparison_operator!))
return true

return !!c.value
})

if (!isSet)
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variableValue`) })
}
else {
if (!isEmptyRelatedOperator(condition.comparison_operator!) && !condition.value)
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variableValue`) })
}
}
})

if (!errorMessages && (
Number.isNaN(Number(payload.loop_count))
|| !Number.isInteger(Number(payload.loop_count))
|| payload.loop_count < 1
|| payload.loop_count > LOOP_NODE_MAX_COUNT
))
errorMessages = t('workflow.nodes.loop.loopMaxCountError', { maxCount: LOOP_NODE_MAX_COUNT })

return {
isValid: !errorMessages,
errorMessage: errorMessages,
}
},
}

export const FILE_TYPE_OPTIONS = [
{ value: 'image', i18nKey: 'image' },
{ value: 'document', i18nKey: 'doc' },
{ value: 'audio', i18nKey: 'audio' },
{ value: 'video', i18nKey: 'video' },
]

export const TRANSFER_METHOD = [
{ value: TransferMethod.local_file, i18nKey: 'localUpload' },
{ value: TransferMethod.remote_url, i18nKey: 'url' },
]

export const SUB_VARIABLES = ['type', 'size', 'name', 'url', 'extension', 'mime_type', 'transfer_method']
export const OUTPUT_FILE_SUB_VARIABLES = SUB_VARIABLES.filter(key => key !== 'transfer_method')

export default nodeDefault

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

@@ -0,0 +1,61 @@
import {
memo,
useCallback,
useState,
} from 'react'
import cn from 'classnames'
import { useNodesInteractions } from '../../hooks'
import type {
BlockEnum,
OnSelectBlock,
} from '../../types'
import BlockSelector from '../../block-selector'

type InsertBlockProps = {
startNodeId: string
availableBlocksTypes: BlockEnum[]
}
const InsertBlock = ({
startNodeId,
availableBlocksTypes,
}: InsertBlockProps) => {
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()

const handleOpenChange = useCallback((v: boolean) => {
setOpen(v)
}, [])
const handleInsert = useCallback<OnSelectBlock>((nodeType, toolDefaultValue) => {
handleNodeAdd(
{
nodeType,
toolDefaultValue,
},
{
nextNodeId: startNodeId,
nextNodeTargetHandle: 'target',
},
)
}, [startNodeId, handleNodeAdd])

return (
<div
className={cn(
'nopan nodrag',
'hidden group-hover/insert:block absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2',
open && '!block',
)}
>
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
asChild
onSelect={handleInsert}
availableBlocksTypes={availableBlocksTypes}
triggerClassName={() => 'hover:scale-125 transition-all'}
/>
</div>
)
}

export default memo(InsertBlock)

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

@@ -0,0 +1,61 @@
import type { FC } from 'react'
import {
memo,
useEffect,
} from 'react'
import {
Background,
useNodesInitialized,
useViewport,
} from 'reactflow'
import { LoopStartNodeDumb } from '../loop-start'
import { useNodeLoopInteractions } from './use-interactions'
import type { LoopNodeType } from './types'
import AddBlock from './add-block'
import cn from '@/utils/classnames'

import type { NodeProps } from '@/app/components/workflow/types'

const Node: FC<NodeProps<LoopNodeType>> = ({
id,
data,
}) => {
const { zoom } = useViewport()
const nodesInitialized = useNodesInitialized()
const { handleNodeLoopRerender } = useNodeLoopInteractions()

useEffect(() => {
if (nodesInitialized)
handleNodeLoopRerender(id)
}, [nodesInitialized, id, handleNodeLoopRerender])

return (
<div className={cn(
'relative min-w-[240px] min-h-[90px] w-full h-full rounded-2xl bg-[#F0F2F7]/90',
)}>
<Background
id={`loop-background-${id}`}
className='rounded-2xl !z-0'
gap={[14 / zoom, 14 / zoom]}
size={2 / zoom}
color='#E4E5E7'
/>
{
data._isCandidate && (
<LoopStartNodeDumb />
)
}
{
data._children!.length === 1 && (
<AddBlock
loopNodeId={id}
loopNodeData={data}
/>
)
}

</div>
)
}

export default memo(Node)

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

@@ -0,0 +1,120 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'

import Split from '../_base/components/split'
import ResultPanel from '../../run/result-panel'
import InputNumberWithSlider from '../_base/components/input-number-with-slider'
import type { LoopNodeType } from './types'
import useConfig from './use-config'
import ConditionWrap from './components/condition-wrap'
import type { NodePanelProps } from '@/app/components/workflow/types'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/before-run-form'
import formatTracing from '@/app/components/workflow/run/utils/format-log'

import { useLogs } from '@/app/components/workflow/run/hooks'
import { LOOP_NODE_MAX_COUNT } from '@/config'

const i18nPrefix = 'workflow.nodes.loop'

const Panel: FC<NodePanelProps<LoopNodeType>> = ({
id,
data,
}) => {
const { t } = useTranslation()

const {
readOnly,
inputs,
childrenNodeVars,
loopChildrenNodes,
isShowSingleRun,
hideSingleRun,
runningStatus,
handleRun,
handleStop,
runResult,
loopRunResult,
handleAddCondition,
handleUpdateCondition,
handleRemoveCondition,
handleToggleConditionLogicalOperator,
handleAddSubVariableCondition,
handleRemoveSubVariableCondition,
handleUpdateSubVariableCondition,
handleToggleSubVariableConditionLogicalOperator,
handleUpdateLoopCount,
} = useConfig(id, data)

const nodeInfo = formatTracing(loopRunResult, t)[0]
const logsParams = useLogs()

return (
<div className='mt-2'>
<div>
<Field
title={<div className='pl-3'>{t(`${i18nPrefix}.breakCondition`)}</div>}
>
<ConditionWrap
nodeId={id}
readOnly={readOnly}
handleAddCondition={handleAddCondition}
handleRemoveCondition={handleRemoveCondition}
handleUpdateCondition={handleUpdateCondition}
handleToggleConditionLogicalOperator={handleToggleConditionLogicalOperator}
handleAddSubVariableCondition={handleAddSubVariableCondition}
handleRemoveSubVariableCondition={handleRemoveSubVariableCondition}
handleUpdateSubVariableCondition={handleUpdateSubVariableCondition}
handleToggleSubVariableConditionLogicalOperator={handleToggleSubVariableConditionLogicalOperator}
availableNodes={loopChildrenNodes}
availableVars={childrenNodeVars}
conditions={inputs.break_conditions || []}
logicalOperator={inputs.logical_operator!}
/>
</Field>
<Split />
<div className='mt-2'>
<Field
title={<div className='pl-3'>{t(`${i18nPrefix}.loopMaxCount`)}</div>}
>
<div className='px-3 py-2'>
<InputNumberWithSlider
min={1}
max={LOOP_NODE_MAX_COUNT}
value={inputs.loop_count}
onChange={(val) => {
const roundedVal = Math.round(val)
handleUpdateLoopCount(Number.isNaN(roundedVal) ? 1 : roundedVal)
}}
/>
</div>
</Field>
</div>
</div>
{/* Error handling for the Loop node is currently not considered. */}
{/* <div className='px-4 py-2'>
<Field title={t(`${i18nPrefix}.errorResponseMethod`)} >
<Select items={responseMethod} defaultValue={inputs.error_handle_mode} onSelect={changeErrorResponseMode} allowSearch={false}>
</Select>
</Field>
</div> */}
{isShowSingleRun && (
<BeforeRunForm
nodeName={inputs.title}
onHide={hideSingleRun}
forms={[]}
runningStatus={runningStatus}
onRun={handleRun}
onStop={handleStop}
{...logsParams}
result={
<ResultPanel {...runResult} showSteps={false} nodeInfo={nodeInfo} {...logsParams} />
}
/>
)}
</div>
)
}

export default React.memo(Panel)

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

@@ -0,0 +1,76 @@
import type { VarType as NumberVarType } from '../tool/types'
import type {
BlockEnum,
CommonNodeType,
ErrorHandleMode,
ValueSelector,
Var,
VarType,
} from '@/app/components/workflow/types'

export enum LogicalOperator {
and = 'and',
or = 'or',
}

export enum ComparisonOperator {
contains = 'contains',
notContains = 'not contains',
startWith = 'start with',
endWith = 'end with',
is = 'is',
isNot = 'is not',
empty = 'empty',
notEmpty = 'not empty',
equal = '=',
notEqual = '≠',
largerThan = '>',
lessThan = '<',
largerThanOrEqual = '≥',
lessThanOrEqual = '≤',
isNull = 'is null',
isNotNull = 'is not null',
in = 'in',
notIn = 'not in',
allOf = 'all of',
exists = 'exists',
notExists = 'not exists',
}

export type Condition = {
id: string
varType: VarType
variable_selector?: ValueSelector
key?: string // sub variable key
comparison_operator?: ComparisonOperator
value: string | string[]
numberVarType?: NumberVarType
sub_variable_condition?: CaseItem
}

export type CaseItem = {
logical_operator: LogicalOperator
conditions: Condition[]
}

export type HandleAddCondition = (valueSelector: ValueSelector, varItem: Var) => void
export type HandleRemoveCondition = (conditionId: string) => void
export type HandleUpdateCondition = (conditionId: string, newCondition: Condition) => void
export type HandleUpdateConditionLogicalOperator = (value: LogicalOperator) => void

export type HandleToggleConditionLogicalOperator = () => void

export type HandleAddSubVariableCondition = (conditionId: string, key?: string) => void
export type handleRemoveSubVariableCondition = (conditionId: string, subConditionId: string) => void
export type HandleUpdateSubVariableCondition = (conditionId: string, subConditionId: string, newSubCondition: Condition) => void
export type HandleToggleSubVariableConditionLogicalOperator = (conditionId: string) => void

export type LoopNodeType = CommonNodeType & {
startNodeType?: BlockEnum
start_node_id: string
loop_id?: string
logical_operator?: LogicalOperator
break_conditions?: Condition[]
loop_count: number
error_handle_mode: ErrorHandleMode // how to handle error in the iteration
}

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

@@ -0,0 +1,329 @@
import { useCallback } from 'react'
import produce from 'immer'
import { useBoolean } from 'ahooks'
import { uuid4 } from '@sentry/utils'
import {
useIsChatMode,
useIsNodeInLoop,
useNodesReadOnly,
useWorkflow,
} from '../../hooks'
import { VarType } from '../../types'
import type { ErrorHandleMode, ValueSelector, Var } from '../../types'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar, toNodeOutputVars } from '../_base/components/variable/utils'
import useOneStepRun from '../_base/hooks/use-one-step-run'
import { getOperators } from './utils'
import { LogicalOperator } from './types'
import type { HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition, LoopNodeType } from './types'
import useIsVarFileAttribute from './use-is-var-file-attribute'

const DELIMITER = '@@@@@'
const useConfig = (id: string, payload: LoopNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const { isNodeInLoop } = useIsNodeInLoop(id)
const isChatMode = useIsChatMode()

const { inputs, setInputs } = useNodeCrud<LoopNodeType>(id, payload)

const filterInputVar = useCallback((varPayload: Var) => {
return [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject, VarType.arrayFile].includes(varPayload.type)
}, [])

// output
const { getLoopNodeChildren, getBeforeNodesInSameBranch } = useWorkflow()
const beforeNodes = getBeforeNodesInSameBranch(id)
const loopChildrenNodes = getLoopNodeChildren(id)
const canChooseVarNodes = [...beforeNodes, ...loopChildrenNodes]
const childrenNodeVars = toNodeOutputVars(loopChildrenNodes, isChatMode)

// single run
const loopInputKey = `${id}.input_selector`
const {
isShowSingleRun,
showSingleRun,
hideSingleRun,
toVarInputs,
runningStatus,
handleRun: doHandleRun,
handleStop,
runInputData,
setRunInputData,
runResult,
loopRunResult,
} = useOneStepRun<LoopNodeType>({
id,
data: inputs,
loopInputKey,
defaultRunInputData: {
[loopInputKey]: [''],
},
})

const [isShowLoopDetail, {
setTrue: doShowLoopDetail,
setFalse: doHideLoopDetail,
}] = useBoolean(false)

const hideLoopDetail = useCallback(() => {
hideSingleRun()
doHideLoopDetail()
}, [doHideLoopDetail, hideSingleRun])

const showLoopDetail = useCallback(() => {
doShowLoopDetail()
}, [doShowLoopDetail])

const backToSingleRun = useCallback(() => {
hideLoopDetail()
showSingleRun()
}, [hideLoopDetail, showSingleRun])

const {
getIsVarFileAttribute,
} = useIsVarFileAttribute({
nodeId: id,
})

const { usedOutVars, allVarObject } = (() => {
const vars: ValueSelector[] = []
const varObjs: Record<string, boolean> = {}
const allVarObject: Record<string, {
inSingleRunPassedKey: string
}> = {}
loopChildrenNodes.forEach((node) => {
const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0)
nodeVars.forEach((varSelector) => {
if (varSelector[0] === id) { // skip Loop node itself variable: item, index
return
}
const isInLoop = isNodeInLoop(varSelector[0])
if (isInLoop) // not pass loop inner variable
return

const varSectorStr = varSelector.join('.')
if (!varObjs[varSectorStr]) {
varObjs[varSectorStr] = true
vars.push(varSelector)
}
let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector)
if (typeof passToServerKeys === 'string')
passToServerKeys = [passToServerKeys]

passToServerKeys.forEach((key: string, index: number) => {
allVarObject[[varSectorStr, node.id, index].join(DELIMITER)] = {
inSingleRunPassedKey: key,
}
})
})
})
const res = toVarInputs(vars.map((item) => {
const varInfo = getNodeInfoById(canChooseVarNodes, item[0])
return {
label: {
nodeType: varInfo?.data.type,
nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title, // default start node title
variable: isSystemVar(item) ? item.join('.') : item[item.length - 1],
},
variable: `${item.join('.')}`,
value_selector: item,
}
}))
return {
usedOutVars: res,
allVarObject,
}
})()

const handleRun = useCallback((data: Record<string, any>) => {
const formattedData: Record<string, any> = {}
Object.keys(allVarObject).forEach((key) => {
const [varSectorStr, nodeId] = key.split(DELIMITER)
formattedData[`${nodeId}.${allVarObject[key].inSingleRunPassedKey}`] = data[varSectorStr]
})
formattedData[loopInputKey] = data[loopInputKey]
doHandleRun(formattedData)
}, [allVarObject, doHandleRun, loopInputKey])

const inputVarValues = (() => {
const vars: Record<string, any> = {}
Object.keys(runInputData)
.filter(key => ![loopInputKey].includes(key))
.forEach((key) => {
vars[key] = runInputData[key]
})
return vars
})()

const setInputVarValues = useCallback((newPayload: Record<string, any>) => {
const newVars = {
...newPayload,
[loopInputKey]: runInputData[loopInputKey],
}
setRunInputData(newVars)
}, [loopInputKey, runInputData, setRunInputData])

const loop = runInputData[loopInputKey]
const setLoop = useCallback((newLoop: string[]) => {
setRunInputData({
...runInputData,
[loopInputKey]: newLoop,
})
}, [loopInputKey, runInputData, setRunInputData])

const changeErrorResponseMode = useCallback((item: { value: unknown }) => {
const newInputs = produce(inputs, (draft) => {
draft.error_handle_mode = item.value as ErrorHandleMode
})
setInputs(newInputs)
}, [inputs, setInputs])

const handleAddCondition = useCallback<HandleAddCondition>((valueSelector, varItem) => {
const newInputs = produce(inputs, (draft) => {
if (!draft.break_conditions)
draft.break_conditions = []

draft.break_conditions?.push({
id: uuid4(),
varType: varItem.type,
variable_selector: valueSelector,
comparison_operator: getOperators(varItem.type, getIsVarFileAttribute(valueSelector) ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
value: '',
})
})
setInputs(newInputs)
}, [getIsVarFileAttribute, inputs, setInputs])

const handleRemoveCondition = useCallback<HandleRemoveCondition>((conditionId) => {
const newInputs = produce(inputs, (draft) => {
draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId)
})
setInputs(newInputs)
}, [inputs, setInputs])

const handleUpdateCondition = useCallback<HandleUpdateCondition>((conditionId, newCondition) => {
const newInputs = produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition)
Object.assign(targetCondition, newCondition)
})
setInputs(newInputs)
}, [inputs, setInputs])

const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>(() => {
const newInputs = produce(inputs, (draft) => {
draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
setInputs(newInputs)
}, [inputs, setInputs])

const handleAddSubVariableCondition = useCallback<HandleAddSubVariableCondition>((conditionId: string, key?: string) => {
const newInputs = produce(inputs, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition) {
condition.sub_variable_condition = {
logical_operator: LogicalOperator.and,
conditions: [],
}
}
const subVarCondition = condition.sub_variable_condition
if (subVarCondition) {
if (!subVarCondition.conditions)
subVarCondition.conditions = []

const svcComparisonOperators = getOperators(VarType.string, { key: key || '' })

subVarCondition.conditions.push({
id: uuid4(),
key: key || '',
varType: VarType.string,
comparison_operator: (svcComparisonOperators && svcComparisonOperators.length) ? svcComparisonOperators[0] : undefined,
value: '',
})
}
})
setInputs(newInputs)
}, [inputs, setInputs])

const handleRemoveSubVariableCondition = useCallback((conditionId: string, subConditionId: string) => {
const newInputs = produce(inputs, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition)
return
const subVarCondition = condition.sub_variable_condition
if (subVarCondition)
subVarCondition.conditions = subVarCondition.conditions.filter(item => item.id !== subConditionId)
})
setInputs(newInputs)
}, [inputs, setInputs])

const handleUpdateSubVariableCondition = useCallback<HandleUpdateSubVariableCondition>((conditionId, subConditionId, newSubCondition) => {
const newInputs = produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition) {
const targetSubCondition = targetCondition.sub_variable_condition.conditions.find(item => item.id === subConditionId)
if (targetSubCondition)
Object.assign(targetSubCondition, newSubCondition)
}
})
setInputs(newInputs)
}, [inputs, setInputs])

const handleToggleSubVariableConditionLogicalOperator = useCallback<HandleToggleSubVariableConditionLogicalOperator>((conditionId) => {
const newInputs = produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition)
targetCondition.sub_variable_condition.logical_operator = targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
setInputs(newInputs)
}, [inputs, setInputs])

const handleUpdateLoopCount = useCallback((value: number) => {
const newInputs = produce(inputs, (draft) => {
draft.loop_count = value
})
setInputs(newInputs)
}, [inputs, setInputs])

return {
readOnly,
inputs,
filterInputVar,
childrenNodeVars,
loopChildrenNodes,
isShowSingleRun,
showSingleRun,
hideSingleRun,
isShowLoopDetail,
showLoopDetail,
hideLoopDetail,
backToSingleRun,
runningStatus,
handleRun,
handleStop,
runResult,
inputVarValues,
setInputVarValues,
usedOutVars,
loop,
setLoop,
loopInputKey,
loopRunResult,
handleAddCondition,
handleRemoveCondition,
handleUpdateCondition,
handleToggleConditionLogicalOperator,
handleAddSubVariableCondition,
handleUpdateSubVariableCondition,
handleRemoveSubVariableCondition,
handleToggleSubVariableConditionLogicalOperator,
handleUpdateLoopCount,
changeErrorResponseMode,
}
}

export default useConfig

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


Vissa filer visades inte eftersom för många filer har ändrats

Laddar…
Avbryt
Spara