From 6a7ab23f1ff74664a50b7d8f7acc41fc930b3083 Mon Sep 17 00:00:00 2001 From: Wood Date: Wed, 8 Jan 2025 18:34:30 +0800 Subject: [PATCH 01/15] feat: Loop and Sleep nodes. --- .../chat/answer/__mocks__/workflowProcess.ts | 2 + web/app/components/base/chat/chat/hooks.ts | 39 +- .../base/icons/src/vender/workflow/Loop.json | 36 + .../base/icons/src/vender/workflow/Loop.tsx | 16 + .../icons/src/vender/workflow/LoopStart.json | 36 + .../icons/src/vender/workflow/LoopStart.tsx | 16 + .../base/icons/src/vender/workflow/Sleep.json | 46 + .../base/icons/src/vender/workflow/Sleep.tsx | 13 + .../base/icons/src/vender/workflow/index.ts | 3 + .../share/text-generation/result/index.tsx | 35 + web/app/components/workflow/block-icon.tsx | 5 + .../workflow/block-selector/constants.tsx | 9 +- .../components/workflow/candidate-node.tsx | 5 +- web/app/components/workflow/constants.ts | 45 +- web/app/components/workflow/custom-edge.tsx | 7 +- .../components/workflow/hooks/use-helpline.ts | 13 + .../workflow/hooks/use-nodes-data.ts | 12 +- .../workflow/hooks/use-nodes-interactions.ts | 237 +- .../hooks/use-workflow-run-event/index.ts | 3 + .../use-workflow-node-loop-finished.ts | 46 + .../use-workflow-node-loop-next.ts | 35 + .../use-workflow-node-loop-started.ts | 85 + .../use-workflow-run-event.ts | 9 + .../workflow/hooks/use-workflow-run.ts | 55 +- .../workflow/hooks/use-workflow-variables.ts | 9 +- .../components/workflow/hooks/use-workflow.ts | 36 +- web/app/components/workflow/index.tsx | 4 + .../nodes/_base/components/next-step/add.tsx | 2 +- .../_base/components/next-step/operator.tsx | 2 +- .../nodes/_base/components/node-handle.tsx | 4 +- .../panel-operator/change-block.tsx | 2 +- .../panel-operator/panel-operator-popup.tsx | 2 +- .../nodes/_base/components/variable/utils.ts | 97 + .../variable/var-reference-picker.tsx | 18 +- .../nodes/_base/hooks/use-node-help-link.ts | 4 + .../nodes/_base/hooks/use-node-info.ts | 2 + .../nodes/_base/hooks/use-one-step-run.ts | 121 +- .../components/workflow/nodes/_base/node.tsx | 55 +- .../components/workflow/nodes/_base/panel.tsx | 2 +- .../workflow/nodes/assigner/use-config.ts | 6 +- .../components/workflow/nodes/constants.ts | 4 + .../nodes/document-extractor/use-config.ts | 6 +- .../workflow/nodes/if-else/types.ts | 1 + .../workflow/nodes/if-else/use-config.ts | 1 + .../if-else/use-is-var-file-attribute.ts | 5 +- .../nodes/list-operator/use-config.ts | 6 +- .../workflow/nodes/loop-start/constants.ts | 1 + .../workflow/nodes/loop-start/default.ts | 21 + .../workflow/nodes/loop-start/index.tsx | 42 + .../workflow/nodes/loop-start/types.ts | 3 + .../workflow/nodes/loop/add-block.tsx | 82 + .../nodes/loop/components/condition-add.tsx | 74 + .../components/condition-files-list-value.tsx | 115 + .../condition-list/condition-input.tsx | 56 + .../condition-list/condition-item.tsx | 318 ++ .../condition-list/condition-operator.tsx | 94 + .../loop/components/condition-list/index.tsx | 133 + .../components/condition-number-input.tsx | 168 ++ .../nodes/loop/components/condition-value.tsx | 98 + .../nodes/loop/components/condition-wrap.tsx | 156 + .../components/workflow/nodes/loop/default.ts | 91 + .../workflow/nodes/loop/insert-block.tsx | 61 + .../components/workflow/nodes/loop/node.tsx | 61 + .../components/workflow/nodes/loop/panel.tsx | 148 + .../components/workflow/nodes/loop/types.ts | 74 + .../workflow/nodes/loop/use-config.ts | 325 +++ .../workflow/nodes/loop/use-interactions.ts | 146 + .../nodes/loop/use-is-var-file-attribute.ts | 35 + .../components/workflow/nodes/loop/utils.ts | 179 ++ .../workflow/nodes/sleep/constants.ts | 1 + .../workflow/nodes/sleep/default.ts | 26 + .../components/workflow/nodes/sleep/index.tsx | 28 + .../components/workflow/nodes/sleep/node.tsx | 10 + .../components/workflow/nodes/sleep/panel.tsx | 50 + .../components/workflow/nodes/sleep/types.ts | 5 + .../workflow/nodes/sleep/use-config.ts | 22 + .../workflow/panel/debug-and-preview/hooks.ts | 46 +- web/app/components/workflow/run/hooks.ts | 24 +- .../workflow/run/loop-log/index.tsx | 2 + .../run/loop-log/loop-log-trigger.tsx | 57 + .../run/loop-log/loop-result-panel.tsx | 128 + .../workflow/run/loop-result-panel.tsx | 122 + web/app/components/workflow/run/node.tsx | 15 +- .../components/workflow/run/tracing-panel.tsx | 6 + .../utils/format-log/graph-to-log-struct.ts | 73 +- .../run/utils/format-log/loop/index.spec.ts | 22 + .../run/utils/format-log/loop/index.ts | 55 + web/app/components/workflow/store.ts | 4 + web/app/components/workflow/types.ts | 10 + web/app/components/workflow/utils.ts | 115 +- web/i18n/zh-Hans/workflow.ts | 17 + web/service/base.ts | 50 +- web/service/share.ts | 46 +- web/service/workflow.ts | 4 + web/types/workflow.ts | 31 +- web/yarn.lock | 2549 +++++++++-------- 96 files changed, 5861 insertions(+), 1335 deletions(-) create mode 100644 web/app/components/base/icons/src/vender/workflow/Loop.json create mode 100644 web/app/components/base/icons/src/vender/workflow/Loop.tsx create mode 100644 web/app/components/base/icons/src/vender/workflow/LoopStart.json create mode 100644 web/app/components/base/icons/src/vender/workflow/LoopStart.tsx create mode 100644 web/app/components/base/icons/src/vender/workflow/Sleep.json create mode 100644 web/app/components/base/icons/src/vender/workflow/Sleep.tsx create mode 100644 web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts create mode 100644 web/app/components/workflow/nodes/loop-start/constants.ts create mode 100644 web/app/components/workflow/nodes/loop-start/default.ts create mode 100644 web/app/components/workflow/nodes/loop-start/index.tsx create mode 100644 web/app/components/workflow/nodes/loop-start/types.ts create mode 100644 web/app/components/workflow/nodes/loop/add-block.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-add.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-list/index.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-number-input.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-value.tsx create mode 100644 web/app/components/workflow/nodes/loop/components/condition-wrap.tsx create mode 100644 web/app/components/workflow/nodes/loop/default.ts create mode 100644 web/app/components/workflow/nodes/loop/insert-block.tsx create mode 100644 web/app/components/workflow/nodes/loop/node.tsx create mode 100644 web/app/components/workflow/nodes/loop/panel.tsx create mode 100644 web/app/components/workflow/nodes/loop/types.ts create mode 100644 web/app/components/workflow/nodes/loop/use-config.ts create mode 100644 web/app/components/workflow/nodes/loop/use-interactions.ts create mode 100644 web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts create mode 100644 web/app/components/workflow/nodes/loop/utils.ts create mode 100644 web/app/components/workflow/nodes/sleep/constants.ts create mode 100644 web/app/components/workflow/nodes/sleep/default.ts create mode 100644 web/app/components/workflow/nodes/sleep/index.tsx create mode 100644 web/app/components/workflow/nodes/sleep/node.tsx create mode 100644 web/app/components/workflow/nodes/sleep/panel.tsx create mode 100644 web/app/components/workflow/nodes/sleep/types.ts create mode 100644 web/app/components/workflow/nodes/sleep/use-config.ts create mode 100644 web/app/components/workflow/run/loop-log/index.tsx create mode 100644 web/app/components/workflow/run/loop-log/loop-log-trigger.tsx create mode 100644 web/app/components/workflow/run/loop-log/loop-result-panel.tsx create mode 100644 web/app/components/workflow/run/loop-result-panel.tsx create mode 100644 web/app/components/workflow/run/utils/format-log/loop/index.spec.ts create mode 100644 web/app/components/workflow/run/utils/format-log/loop/index.ts diff --git a/web/app/components/base/chat/chat/answer/__mocks__/workflowProcess.ts b/web/app/components/base/chat/chat/answer/__mocks__/workflowProcess.ts index 0c5fd3946f5d37..b6bd9a6be2e3bd 100644 --- a/web/app/components/base/chat/chat/answer/__mocks__/workflowProcess.ts +++ b/web/app/components/base/chat/chat/answer/__mocks__/workflowProcess.ts @@ -46,6 +46,7 @@ export const mockedWorkflowProcess = { parent_parallel_id: null, parent_parallel_start_node_id: null, iteration_id: null, + loop_id: null, }, { extras: {}, @@ -107,6 +108,7 @@ export const mockedWorkflowProcess = { parent_parallel_id: null, parent_parallel_start_node_id: null, iteration_id: null, + loop_id: null, }, { extras: {}, diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index 869a51396cf09b..c723f4476ab7bc 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -249,7 +249,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, { @@ -495,10 +495,44 @@ export const useChat = ( } })) }, + onLoopStart: ({ data }) => { + responseItem.workflowProcess!.tracing!.push({ + ...data, + status: WorkflowRunningStatus.Running, + } as any) + handleUpdateChatList(produce(chatListRef.current, (draft) => { + const currentIndex = draft.findIndex(item => item.id === responseItem.id) + draft[currentIndex] = { + ...draft[currentIndex], + ...responseItem, + } + })) + }, + onLoopFinish: ({ data }) => { + const tracing = responseItem.workflowProcess!.tracing! + const loopIndex = 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))! + tracing[loopIndex] = { + ...tracing[loopIndex], + ...data, + status: WorkflowRunningStatus.Succeeded, + } as any + + handleUpdateChatList(produce(chatListRef.current, (draft) => { + const currentIndex = draft.findIndex(item => item.id === responseItem.id) + draft[currentIndex] = { + ...draft[currentIndex], + ...responseItem, + } + })) + }, onNodeStarted: ({ data }) => { if (data.iteration_id) return + if (data.loop_id) + return + responseItem.workflowProcess!.tracing!.push({ ...data, status: WorkflowRunningStatus.Running, @@ -515,6 +549,9 @@ export const useChat = ( if (data.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 === data.node_id diff --git a/web/app/components/base/icons/src/vender/workflow/Loop.json b/web/app/components/base/icons/src/vender/workflow/Loop.json new file mode 100644 index 00000000000000..47d45e890d7eea --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Loop.json @@ -0,0 +1,36 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "14", + "height": "14", + "viewBox": "0 0 1024 1024", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "id": "icons/loop" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "id": "Vector", + "d": "M879.304348 359.513043V801.391304c0 24.486957-20.034783 44.521739-44.521739 44.521739H189.217391c-24.486957 0-44.521739-20.034783-44.521739-44.521739V359.513043c0-24.486957 20.034783-47.86087 44.521739-47.860869h247.095652l-60.104347-58.991304c-17.808696-17.808696-16.695652-44.521739 0-61.217392 17.808696-17.808696 45.634783-16.695652 63.443478 0l135.791304 136.904348c16.695652 17.808696 16.695652 45.634783 0 62.330435L439.652174 527.582609c-8.904348 8.904348-20.034783 13.356522-31.165217 13.356521-11.130435 0-22.26087-4.452174-31.165218-13.356521-17.808696-17.808696-17.808696-46.747826 0-63.443479l60.104348-62.330434H233.73913v356.173913h556.52174V400.695652h-93.495653c-24.486957 0-44.521739-20.034783-44.521739-44.521739s20.034783-44.521739 44.521739-44.521739H834.782609c24.486957 0 44.521739 23.373913 44.521739 47.860869z", + "fill": "currentColor" + }, + "children": [] + } + ] + } + ] + }, + "name": "Loop" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/workflow/Loop.tsx b/web/app/components/base/icons/src/vender/workflow/Loop.tsx new file mode 100644 index 00000000000000..d25a163779b769 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Loop.tsx @@ -0,0 +1,16 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Loop.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase' + +const Icon = React.forwardRef, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'Loop' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/LoopStart.json b/web/app/components/base/icons/src/vender/workflow/LoopStart.json new file mode 100644 index 00000000000000..362d54b3db5e36 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/LoopStart.json @@ -0,0 +1,36 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "12", + "height": "12", + "viewBox": "0 0 12 12", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "id": "icons/block-start" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "id": "Vector", + "d": "M6.8498 1.72732C6.3379 1.3754 5.6621 1.3754 5.1502 1.72732L2.1502 3.78982C1.74317 4.06965 1.5 4.53193 1.5 5.02588V8.99983C1.5 9.82828 2.17158 10.4998 3 10.4998H4.25C4.52614 10.4998 4.75 10.276 4.75 9.99983V8.24983C4.75 7.55948 5.30965 6.99983 6 6.99983C6.69035 6.99983 7.25 7.55948 7.25 8.24983V9.99983C7.25 10.276 7.47385 10.4998 7.75 10.4998H9C9.82845 10.4998 10.5 9.82828 10.5 8.99983V5.02588C10.5 4.53193 10.2568 4.06965 9.8498 3.78982L6.8498 1.72732Z", + "fill": "red" + }, + "children": [] + } + ] + } + ] + }, + "name": "LoopStart" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/workflow/LoopStart.tsx b/web/app/components/base/icons/src/vender/workflow/LoopStart.tsx new file mode 100644 index 00000000000000..0c93cfe8b0518f --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/LoopStart.tsx @@ -0,0 +1,16 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './LoopStart.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase' + +const Icon = React.forwardRef, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'LoopStart' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/Sleep.json b/web/app/components/base/icons/src/vender/workflow/Sleep.json new file mode 100644 index 00000000000000..14f908809de50b --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Sleep.json @@ -0,0 +1,46 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "14", + "height": "14", + "viewBox": "0 0 1024 1024", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "id": "icons/sleep" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "id": "Vector (Stroke)", + "d": "M221.78789744 920.38454701c-15.71774359 0-28.4991453-12.78249572-28.49914531-28.4991453s12.78140171-28.50023931 28.49914531-28.50023932h28.99145299V732.11186325c0-88.83309402 74.38659829-147.01948718 128.70345299-189.51111112 14.95521367-11.70160684 33.45723077-26.17545299 42.02119658-35.78201708l4.02160684-4.51500856-4.20102564-4.34652991c-8.86700854-9.1580171-27.59111111-22.61770941-42.6425983-33.43425641-54.56300854-39.20957265-129.26905983-92.90393162-129.26905982-178.6945641V160.61483761h-27.63049573a28.53305983 28.53305983 0 0 1-28.50023932-28.4991453 28.53305983 28.53305983 0 0 1 28.4991453-28.50023932h580.44170941a28.53305983 28.53305983 0 0 1 28.50023931 28.4991453 28.52758974 28.52758974 0 0 1-28.4991453 28.50023932h-27.63158974v125.22447863c0 85.80157265-74.71699146 139.49046154-129.26358975 178.6945641-15.07336752 10.83295726-33.79309402 24.28717949-42.6371282 33.43425641l-4.20649573 4.34762394 4.02707693 4.51391452c8.55302564 9.60109402 27.03316239 24.0574359 41.88663248 35.67589744 54.44594872 42.58680342 128.8259829 100.76225641 128.8259829 189.61723077v131.27329914h29.0045812a28.53305983 28.53305983 0 0 1 28.50461539 28.49476923 28.33176069 28.33176069 0 0 1-8.33969231 20.15835898 28.31535043 28.31535043 0 0 1-20.15398291 8.34735043h-580.45264957z m84.62550427-634.54523077c0 56.56287179 58.51131624 98.6114188 105.52888888 132.40451282 39.70844444 28.53962393 71.06516239 51.07418803 71.0651624 83.89251282 0 31.85449572-30.18064957 55.45900854-68.38700855 85.35302564-47.61162393 37.23815385-106.8351453 83.56758974-106.83514529 144.62249573v131.26782906h408.44034188V732.11186325c0-61.0494359-59.22352136-107.38434188-106.81217095-144.60499146-38.22386325-29.91152136-68.40451282-53.52150428-68.40451282-85.37709402 0-32.81176069 31.35671795-55.34632479 71.05422223-83.87938461 47.02851282-33.79418803 105.53982906-75.84820513 105.53982905-132.41107692V160.61483761H306.41449572v125.22447863z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "id": "Vector (Stroke)", + "d": "M412.28820513 765.81538462c-13.6 0-24.66461538-11.06461538-24.66461539-24.66564103s11.06461538-24.66461538 24.66461539-24.66461538h199.42871795c13.6 0 24.66461538 11.06461538 24.66461538 24.66461538s-11.06461538 24.66666667-24.66461538 24.66666667H412.28717949z", + "fill": "currentColor" + }, + "children": [] + } + ] + } + ] + }, + "name": "Sleep" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/workflow/Sleep.tsx b/web/app/components/base/icons/src/vender/workflow/Sleep.tsx new file mode 100644 index 00000000000000..ace4801f806ba4 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Sleep.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' +import data from './Sleep.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase' + +const Icon = React.forwardRef, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'Sleep' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/index.ts b/web/app/components/base/icons/src/vender/workflow/index.ts index 11ce55b130d9fc..3f3a59c676db5b 100644 --- a/web/app/components/base/icons/src/vender/workflow/index.ts +++ b/web/app/components/base/icons/src/vender/workflow/index.ts @@ -9,6 +9,9 @@ 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 Sleep } from './Sleep' export { default as Jinja } from './Jinja' export { default as KnowledgeRetrieval } from './KnowledgeRetrieval' export { default as ListFilter } from './ListFilter' diff --git a/web/app/components/share/text-generation/result/index.tsx b/web/app/components/share/text-generation/result/index.tsx index 6d5c63273a1cde..f778de27e665ca 100644 --- a/web/app/components/share/text-generation/result/index.tsx +++ b/web/app/components/share/text-generation/result/index.tsx @@ -240,10 +240,42 @@ const Result: FC = ({ } as any })) }, + onLoopStart: ({ data }) => { + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.expand = true + draft.tracing!.push({ + ...data, + status: NodeRunningStatus.Running, + expand: true, + } as any) + })) + }, + onLoopNext: () => { + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.expand = true + const loops = draft.tracing.find(item => item.node_id === data.node_id + && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))! + loops?.details!.push([]) + })) + }, + onLoopFinish: ({ data }) => { + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.expand = true + const loopsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id + && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))! + draft.tracing[loopsIndex] = { + ...data, + expand: !!data.error, + } as any + })) + }, onNodeStarted: ({ data }) => { if (data.iteration_id) return + if (data.loop_id) + return + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { draft.expand = true draft.tracing!.push({ @@ -257,6 +289,9 @@ const Result: FC = ({ if (data.iteration_id) return + if (data.loop_id) + return + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { const currentIndex = draft.tracing!.findIndex(trace => trace.node_id === data.node_id && (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || trace.parallel_id === data.execution_metadata?.parallel_id)) diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index 7f7aeca0920963..9bfb2dde47b28a 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -15,8 +15,10 @@ import { KnowledgeRetrieval, ListFilter, Llm, + Loop, ParameterExtractor, QuestionClassifier, + Sleep, TemplatingTransform, VariableX, } from '@/app/components/base/icons/src/vender/workflow' @@ -51,6 +53,8 @@ const getIcon = (type: BlockEnum, className: string) => { [BlockEnum.Tool]: , [BlockEnum.IterationStart]: , [BlockEnum.Iteration]: , + [BlockEnum.LoopStart]: , + [BlockEnum.Loop]: , [BlockEnum.ParameterExtractor]: , [BlockEnum.DocExtractor]: , [BlockEnum.ListFilter]: , @@ -64,6 +68,7 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record = { [BlockEnum.End]: 'bg-util-colors-warning-warning-500', [BlockEnum.IfElse]: 'bg-util-colors-cyan-cyan-500', [BlockEnum.Iteration]: 'bg-util-colors-cyan-cyan-500', + [BlockEnum.Loop]: 'bg-util-colors-cyan-cyan-500', [BlockEnum.HttpRequest]: 'bg-util-colors-violet-violet-500', [BlockEnum.Answer]: 'bg-util-colors-warning-warning-500', [BlockEnum.KnowledgeRetrieval]: 'bg-util-colors-green-green-500', diff --git a/web/app/components/workflow/block-selector/constants.tsx b/web/app/components/workflow/block-selector/constants.tsx index 798e7ae3c50464..9e27126eacbc1f 100644 --- a/web/app/components/workflow/block-selector/constants.tsx +++ b/web/app/components/workflow/block-selector/constants.tsx @@ -44,6 +44,11 @@ export const BLOCKS: Block[] = [ type: BlockEnum.Iteration, title: 'Iteration', }, + { + classification: BlockClassificationEnum.Logic, + type: BlockEnum.Loop, + title: 'Loop', + }, { classification: BlockClassificationEnum.Transform, type: BlockEnum.Code, @@ -87,8 +92,8 @@ export const BLOCKS: Block[] = [ { classification: BlockClassificationEnum.Default, type: BlockEnum.Agent, - title: 'Agent', - }, + title: 'Agent' + } ] export const BLOCK_CLASSIFICATIONS: string[] = [ diff --git a/web/app/components/workflow/candidate-node.tsx b/web/app/components/workflow/candidate-node.tsx index 16d6f852b2fe26..eb59a4618c9d38 100644 --- a/web/app/components/workflow/candidate-node.tsx +++ b/web/app/components/workflow/candidate-node.tsx @@ -14,7 +14,7 @@ import { } from './store' import { WorkflowHistoryEvent, useNodesInteractions, useWorkflowHistory } from './hooks' import { CUSTOM_NODE } from './constants' -import { getIterationStartNode } from './utils' +import { getIterationStartNode, getLoopStartNode } from './utils' import CustomNode from './nodes' import CustomNoteNode from './note-node' import { CUSTOM_NOTE_NODE } from './note-node/constants' @@ -56,6 +56,9 @@ const CandidateNode = () => { }) if (candidateNode.data.type === BlockEnum.Iteration) draft.push(getIterationStartNode(candidateNode.id)) + + if (candidateNode.data.type === BlockEnum.Loop) + draft.push(getLoopStartNode(candidateNode.id)) }) setNodes(newNodes) if (candidateNode.type === CUSTOM_NOTE_NODE) diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 87f1e01f56a4f8..69129e33f2fe37 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -15,10 +15,12 @@ import VariableAssignerDefault from './nodes/variable-assigner/default' import AssignerDefault from './nodes/assigner/default' import EndNodeDefault from './nodes/end/default' import IterationDefault from './nodes/iteration/default' +import LoopDefault from './nodes/loop/default' import DocExtractorDefault from './nodes/document-extractor/default' import ListFilterDefault from './nodes/list-operator/default' import IterationStartDefault from './nodes/iteration-start/default' import AgentDefault from './nodes/agent/default' +import LoopStartDefault from './nodes/loop-start/default' type NodesExtraData = { author: string @@ -102,6 +104,24 @@ export const NODES_EXTRA_DATA: Record = { getAvailableNextNodes: IterationStartDefault.getAvailableNextNodes, checkValid: IterationStartDefault.checkValid, }, + [BlockEnum.Loop]: { + author: 'AICT-Team', + about: '', + availablePrevNodes: [], + availableNextNodes: [], + getAvailablePrevNodes: LoopDefault.getAvailablePrevNodes, + getAvailableNextNodes: LoopDefault.getAvailableNextNodes, + checkValid: LoopDefault.checkValid, + }, + [BlockEnum.LoopStart]: { + author: 'AICT-Team', + about: '', + availablePrevNodes: [], + availableNextNodes: [], + getAvailablePrevNodes: LoopStartDefault.getAvailablePrevNodes, + getAvailableNextNodes: LoopStartDefault.getAvailableNextNodes, + checkValid: LoopStartDefault.checkValid, + }, [BlockEnum.Code]: { author: 'Dify', about: '', @@ -268,6 +288,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: '', @@ -358,6 +390,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 @@ -376,6 +409,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 @@ -402,7 +445,7 @@ export const RETRIEVAL_OUTPUT_STRUCT = `{ export const SUPPORT_OUTPUT_VARS_NODE = [ BlockEnum.Start, BlockEnum.LLM, BlockEnum.KnowledgeRetrieval, BlockEnum.Code, BlockEnum.TemplateTransform, BlockEnum.HttpRequest, BlockEnum.Tool, BlockEnum.VariableAssigner, BlockEnum.VariableAggregator, BlockEnum.QuestionClassifier, - BlockEnum.ParameterExtractor, BlockEnum.Iteration, + BlockEnum.ParameterExtractor, BlockEnum.Iteration, BlockEnum.Loop, BlockEnum.DocExtractor, BlockEnum.ListFilter, BlockEnum.Agent, ] diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index ce95549055540d..4467b0adb58a15 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/web/app/components/workflow/custom-edge.tsx @@ -23,7 +23,7 @@ import type { } from './types' import { NodeRunningStatus } from './types' import { getEdgeColor } from './utils' -import { ITERATION_CHILDREN_Z_INDEX } from './constants' +import { ITERATION_CHILDREN_Z_INDEX, LOOP_CHILDREN_Z_INDEX } from './constants' import CustomEdgeLinearGradientRender from './custom-edge-linear-gradient-render' import cn from '@/utils/classnames' import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' @@ -56,8 +56,8 @@ const CustomEdge = ({ }) const [open, setOpen] = useState(false) const { handleNodeAdd } = useNodesInteractions() - const { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration) - const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration) + const { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration, (data as Edge['data'])?.isInLoop) + const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration, (data as Edge['data'])?.isInLoop) const { _sourceRunningStatus, _targetRunningStatus, @@ -144,6 +144,7 @@ const CustomEdge = ({ data?._hovering ? 'block' : 'hidden', open && '!block', data.isInIteration && `z-[${ITERATION_CHILDREN_Z_INDEX}]`, + data.isInLoop && `z-[${LOOP_CHILDREN_Z_INDEX}]`, )} style={{ position: 'absolute', diff --git a/web/app/components/workflow/hooks/use-helpline.ts b/web/app/components/workflow/hooks/use-helpline.ts index e9dc08c706785a..2eed71a80746c3 100644 --- a/web/app/components/workflow/hooks/use-helpline.ts +++ b/web/app/components/workflow/hooks/use-helpline.ts @@ -21,6 +21,14 @@ export const useHelpline = () => { showVerticalHelpLineNodes: [], } } + + if (node.data.isInLoop) { + return { + showHorizontalHelpLineNodes: [], + showVerticalHelpLineNodes: [], + } + } + const showHorizontalHelpLineNodes = nodes.filter((n) => { if (n.id === node.id) return false @@ -28,6 +36,9 @@ export const useHelpline = () => { if (n.data.isInIteration) return false + if (n.data.isInLoop) + return false + const nY = Math.ceil(n.position.y) const nodeY = Math.ceil(node.position.y) @@ -67,6 +78,8 @@ export const useHelpline = () => { return false if (n.data.isInIteration) return false + if (n.data.isInLoop) + return false const nX = Math.ceil(n.position.x) const nodeX = Math.ceil(node.position.x) diff --git a/web/app/components/workflow/hooks/use-nodes-data.ts b/web/app/components/workflow/hooks/use-nodes-data.ts index 3017f5005abf79..62bd5d9089f072 100644 --- a/web/app/components/workflow/hooks/use-nodes-data.ts +++ b/web/app/components/workflow/hooks/use-nodes-data.ts @@ -31,7 +31,7 @@ export const useNodesExtraData = () => { }), [t, isChatMode]) } -export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean) => { +export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean, isInLoop?: boolean) => { const nodesExtraData = useNodesExtraData() const availablePrevBlocks = useMemo(() => { if (!nodeType) @@ -48,15 +48,19 @@ export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean return useMemo(() => { return { availablePrevBlocks: availablePrevBlocks.filter((nType) => { - if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.End)) + if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End)) + return false + if (isInLoop && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End)) return false return true }), availableNextBlocks: availableNextBlocks.filter((nType) => { - if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.End)) + if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End)) + return false + if (isInLoop && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End)) return false return true }), } - }, [isInIteration, availablePrevBlocks, availableNextBlocks]) + }, [isInIteration, availablePrevBlocks, availableNextBlocks, isInLoop]) } diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 8962333311d151..6bba4486d7cbf5 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -29,6 +29,8 @@ import { CUSTOM_EDGE, ITERATION_CHILDREN_Z_INDEX, ITERATION_PADDING, + LOOP_CHILDREN_Z_INDEX, + LOOP_PADDING, NODES_INITIAL_DATA, NODE_WIDTH_X_OFFSET, X_OFFSET, @@ -42,9 +44,12 @@ import { } from '../utils' import { CUSTOM_NOTE_NODE } from '../note-node/constants' import type { IterationNodeType } from '../nodes/iteration/types' +import type { LoopNodeType } from '../nodes/loop/types' import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants' +import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants' import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types' import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions' +import { useNodeLoopInteractions } from '../nodes/loop/use-interactions' import { useWorkflowHistoryStore } from '../workflow-history-store' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useHelpline } from './use-helpline' @@ -73,6 +78,10 @@ export const useNodesInteractions = () => { handleNodeIterationChildDrag, handleNodeIterationChildrenCopy, } = useNodeIterationInteractions() + const { + handleNodeLoopChildDrag, + handleNodeLoopChildrenCopy, + } = useNodeLoopInteractions() const dragNodeStartPosition = useRef({ x: 0, y: 0 } as { x: number; y: number }) const { saveStateToHistory, undo, redo } = useWorkflowHistory() @@ -86,6 +95,9 @@ export const useNodesInteractions = () => { if (node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_NOTE_NODE) return + if (node.type === CUSTOM_LOOP_START_NODE || node.type === CUSTOM_NOTE_NODE) + return + dragNodeStartPosition.current = { x: node.position.x, y: node.position.y } }, [workflowStore, getNodesReadOnly]) @@ -96,6 +108,9 @@ export const useNodesInteractions = () => { if (node.type === CUSTOM_ITERATION_START_NODE) return + if (node.type === CUSTOM_LOOP_START_NODE) + return + const { getNodes, setNodes, @@ -105,6 +120,7 @@ export const useNodesInteractions = () => { const nodes = getNodes() const { restrictPosition } = handleNodeIterationChildDrag(node) + const { restrictPosition: restrictLoopPosition } = handleNodeLoopChildDrag(node) const { showHorizontalHelpLineNodes, @@ -118,6 +134,8 @@ export const useNodesInteractions = () => { if (showVerticalHelpLineNodesLength > 0) currentNode.position.x = showVerticalHelpLineNodes[0].position.x + else if (restrictLoopPosition.x !== undefined) + currentNode.position.x = restrictLoopPosition.x else if (restrictPosition.x !== undefined) currentNode.position.x = restrictPosition.x else @@ -125,6 +143,8 @@ export const useNodesInteractions = () => { if (showHorizontalHelpLineNodesLength > 0) currentNode.position.y = showHorizontalHelpLineNodes[0].position.y + else if (restrictLoopPosition.y !== undefined) + currentNode.position.y = restrictLoopPosition.y else if (restrictPosition.y !== undefined) currentNode.position.y = restrictPosition.y else @@ -132,7 +152,7 @@ export const useNodesInteractions = () => { }) setNodes(newNodes) - }, [store, getNodesReadOnly, handleSetHelpline, handleNodeIterationChildDrag]) + }, [store, getNodesReadOnly, handleSetHelpline, handleNodeIterationChildDrag, handleNodeLoopChildDrag]) const handleNodeDragStop = useCallback((_, node) => { const { @@ -163,6 +183,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 +260,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 +337,8 @@ export const useNodesInteractions = () => { const handleNodeClick = useCallback((_, node) => { if (node.type === CUSTOM_ITERATION_START_NODE) return + if (node.type === CUSTOM_LOOP_START_NODE) + return handleNodeSelect(node.id) }, [handleNodeSelect]) @@ -344,6 +372,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 +386,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 +588,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 +685,7 @@ export const useNodesInteractions = () => { const { newNode, newIterationStartNode, + newLoopStartNode, } = generateNewNode({ data: { ...NODES_INITIAL_DATA[nodeType], @@ -640,13 +714,25 @@ 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.isInIteration = isInIteration + newNode.data.isInLoop = isInLoop 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 + 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 } } @@ -661,11 +747,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 +774,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 +831,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 +862,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 +905,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 +959,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 +988,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 +1013,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 +1048,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 +1113,7 @@ export const useNodesInteractions = () => { const { newNode: newCurrentNode, newIterationStartNode, + newLoopStartNode, } = generateNewNode({ data: { ...NODES_INITIAL_DATA[nodeType], @@ -978,7 +1123,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 +1157,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 +1207,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 +1237,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) @@ -1134,6 +1288,7 @@ export const useNodesInteractions = () => { const { newNode, newIterationStartNode, + newLoopStartNode, } = generateNewNode({ type: nodeToPaste.type, data: { @@ -1166,6 +1321,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) @@ -1176,7 +1342,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()) @@ -1248,9 +1414,12 @@ export const useNodesInteractions = () => { }) if (rightNode! && bottomNode!) { - if (width < rightNode!.position.x + rightNode.width! + ITERATION_PADDING.right) + const parentNode = nodes.find(n => n.id === rightNode.parentId) + const paddingMap = parentNode?.data.type === BlockEnum.Iteration ? ITERATION_PADDING : LOOP_PADDING + + if (width < rightNode!.position.x + rightNode.width! + paddingMap.right) return - if (height < bottomNode.position.y + bottomNode.height! + ITERATION_PADDING.bottom) + if (height < bottomNode.position.y + bottomNode.height! + paddingMap.bottom) return } const newNodes = produce(nodes, (draft) => { diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/index.ts b/web/app/components/workflow/hooks/use-workflow-run-event/index.ts index 70528f7e79b34d..67bc6c15ef37c0 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/index.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/index.ts @@ -6,6 +6,9 @@ export * from './use-workflow-node-finished' export * from './use-workflow-node-iteration-started' export * from './use-workflow-node-iteration-next' export * from './use-workflow-node-iteration-finished' +export * from './use-workflow-node-loop-started' +export * from './use-workflow-node-loop-next' +export * from './use-workflow-node-loop-finished' export * from './use-workflow-node-retry' export * from './use-workflow-text-chunk' export * from './use-workflow-text-replace' diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts new file mode 100644 index 00000000000000..38064e36585fe3 --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts @@ -0,0 +1,46 @@ +import { useCallback } from 'react' +import { useStoreApi } from 'reactflow' +import produce from 'immer' +import type { LoopFinishedResponse } from '@/types/workflow' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { DEFAULT_LOOP_TIMES } from '@/app/components/workflow/constants' + +export const useWorkflowNodeLoopFinished = () => { + const store = useStoreApi() + const workflowStore = useWorkflowStore() + + const handleWorkflowNodeLoopFinished = useCallback((params: LoopFinishedResponse) => { + const { data } = params + const { + workflowRunningData, + setWorkflowRunningData, + setLoopTimes, + } = workflowStore.getState() + const { + getNodes, + setNodes, + } = store.getState() + const nodes = getNodes() + setWorkflowRunningData(produce(workflowRunningData!, (draft) => { + const currentIndex = draft.tracing!.findIndex(item => item.id === data.id) + + if (currentIndex > -1) { + draft.tracing![currentIndex] = { + ...draft.tracing![currentIndex], + ...data, + } + } + })) + setLoopTimes(DEFAULT_LOOP_TIMES) + const newNodes = produce(nodes, (draft) => { + const currentNode = draft.find(node => node.id === data.node_id)! + + currentNode.data._runningStatus = data.status + }) + setNodes(newNodes) + }, [workflowStore, store]) + + return { + handleWorkflowNodeLoopFinished, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts new file mode 100644 index 00000000000000..d3c5164dcb40c3 --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react' +import { useStoreApi } from 'reactflow' +import produce from 'immer' +import type { LoopNextResponse } from '@/types/workflow' +import { useWorkflowStore } from '@/app/components/workflow/store' + +export const useWorkflowNodeLoopNext = () => { + const store = useStoreApi() + const workflowStore = useWorkflowStore() + + const handleWorkflowNodeLoopNext = useCallback((params: LoopNextResponse) => { + const { + loopTimes, + setLoopTimes, + } = workflowStore.getState() + + const { data } = params + const { + getNodes, + setNodes, + } = store.getState() + + const nodes = getNodes() + const newNodes = produce(nodes, (draft) => { + const currentNode = draft.find(node => node.id === data.node_id)! + currentNode.data._loopIndex = loopTimes + setLoopTimes(loopTimes + 1) + }) + setNodes(newNodes) + }, [workflowStore, store]) + + return { + handleWorkflowNodeLoopNext, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts new file mode 100644 index 00000000000000..533154bc339c74 --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts @@ -0,0 +1,85 @@ +import { useCallback } from 'react' +import { + useReactFlow, + useStoreApi, +} from 'reactflow' +import produce from 'immer' +import { useWorkflowStore } from '@/app/components/workflow/store' +import type { LoopStartedResponse } from '@/types/workflow' +import { NodeRunningStatus } from '@/app/components/workflow/types' +import { DEFAULT_LOOP_TIMES } from '@/app/components/workflow/constants' + +export const useWorkflowNodeLoopStarted = () => { + const store = useStoreApi() + const reactflow = useReactFlow() + const workflowStore = useWorkflowStore() + + const handleWorkflowNodeLoopStarted = useCallback(( + params: LoopStartedResponse, + containerParams: { + clientWidth: number, + clientHeight: number, + }, + ) => { + const { data } = params + const { + workflowRunningData, + setWorkflowRunningData, + setLoopTimes, + } = workflowStore.getState() + const { + getNodes, + setNodes, + edges, + setEdges, + transform, + } = store.getState() + const nodes = getNodes() + setWorkflowRunningData(produce(workflowRunningData!, (draft) => { + draft.tracing!.push({ + ...data, + status: NodeRunningStatus.Running, + }) + })) + setLoopTimes(DEFAULT_LOOP_TIMES) + + const { + setViewport, + } = reactflow + const currentNodeIndex = nodes.findIndex(node => node.id === data.node_id) + const currentNode = nodes[currentNodeIndex] + const position = currentNode.position + const zoom = transform[2] + + if (!currentNode.parentId) { + setViewport({ + x: (containerParams.clientWidth - 400 - currentNode.width! * zoom) / 2 - position.x * zoom, + y: (containerParams.clientHeight - currentNode.height! * zoom) / 2 - position.y * zoom, + zoom: transform[2], + }) + } + const newNodes = produce(nodes, (draft) => { + draft[currentNodeIndex].data._runningStatus = NodeRunningStatus.Running + draft[currentNodeIndex].data._loopLength = data.metadata.loop_length + draft[currentNodeIndex].data._waitingRun = false + }) + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + const incomeEdges = draft.filter(edge => edge.target === data.node_id) + + incomeEdges.forEach((edge) => { + edge.data = { + ...edge.data, + _sourceRunningStatus: nodes.find(node => node.id === edge.source)!.data._runningStatus, + _targetRunningStatus: NodeRunningStatus.Running, + _waitingRun: false, + } + }) + }) + setEdges(newEdges) + }, [workflowStore, store, reactflow]) + + return { + handleWorkflowNodeLoopStarted, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts index 8ba622081845cc..64883076cdcfe4 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts @@ -6,6 +6,9 @@ import { useWorkflowNodeIterationFinished, useWorkflowNodeIterationNext, useWorkflowNodeIterationStarted, + useWorkflowNodeLoopFinished, + useWorkflowNodeLoopNext, + useWorkflowNodeLoopStarted, useWorkflowNodeRetry, useWorkflowNodeStarted, useWorkflowStarted, @@ -22,6 +25,9 @@ export const useWorkflowRunEvent = () => { const { handleWorkflowNodeIterationStarted } = useWorkflowNodeIterationStarted() const { handleWorkflowNodeIterationNext } = useWorkflowNodeIterationNext() const { handleWorkflowNodeIterationFinished } = useWorkflowNodeIterationFinished() + const { handleWorkflowNodeLoopStarted } = useWorkflowNodeLoopStarted() + const { handleWorkflowNodeLoopNext } = useWorkflowNodeLoopNext() + const { handleWorkflowNodeLoopFinished } = useWorkflowNodeLoopFinished() const { handleWorkflowNodeRetry } = useWorkflowNodeRetry() const { handleWorkflowTextChunk } = useWorkflowTextChunk() const { handleWorkflowTextReplace } = useWorkflowTextReplace() @@ -36,6 +42,9 @@ export const useWorkflowRunEvent = () => { handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, + handleWorkflowNodeLoopStarted, + handleWorkflowNodeLoopNext, + handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowTextChunk, handleWorkflowTextReplace, diff --git a/web/app/components/workflow/hooks/use-workflow-run.ts b/web/app/components/workflow/hooks/use-workflow-run.ts index 00e7faeeed7bc0..3be67c726dc4a2 100644 --- a/web/app/components/workflow/hooks/use-workflow-run.ts +++ b/web/app/components/workflow/hooks/use-workflow-run.ts @@ -36,6 +36,9 @@ export const useWorkflowRun = () => { handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, + handleWorkflowNodeLoopStarted, + handleWorkflowNodeLoopNext, + handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, @@ -118,6 +121,9 @@ export const useWorkflowRun = () => { onIterationStart, onIterationNext, onIterationFinish, + onLoopStart, + onLoopNext, + onLoopFinish, onNodeRetry, onAgentLog, onError, @@ -162,7 +168,7 @@ export const useWorkflowRun = () => { else ttsUrl = `/apps/${params.appId}/text-to-audio` } - const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => {}) + const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => { }) ssePost( url, @@ -230,6 +236,30 @@ export const useWorkflowRun = () => { if (onIterationFinish) onIterationFinish(params) }, + onLoopStart: (params) => { + handleWorkflowNodeLoopStarted( + params, + { + clientWidth, + clientHeight, + }, + ) + + if (onLoopStart) + onLoopStart(params) + }, + onLoopNext: (params) => { + handleWorkflowNodeLoopNext(params) + + if (onLoopNext) + onLoopNext(params) + }, + onLoopFinish: (params) => { + handleWorkflowNodeLoopFinished(params) + + if (onLoopFinish) + onLoopFinish(params) + }, onNodeRetry: (params) => { handleWorkflowNodeRetry(params) @@ -260,7 +290,26 @@ 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 @@ -289,4 +338,4 @@ export const useWorkflowRun = () => { handleStopRun, handleRestoreFromPublishedWorkflow, } -} +} \ No newline at end of file diff --git a/web/app/components/workflow/hooks/use-workflow-variables.ts b/web/app/components/workflow/hooks/use-workflow-variables.ts index feadaf86594a26..86c0a54900735f 100644 --- a/web/app/components/workflow/hooks/use-workflow-variables.ts +++ b/web/app/components/workflow/hooks/use-workflow-variables.ts @@ -43,14 +43,16 @@ export const useWorkflowVariables = () => { const getCurrentVariableType = useCallback(({ parentNode, valueSelector, - isIterationItem, + isItem, + isLoopItem, availableNodes, isChatMode, isConstant, }: { valueSelector: ValueSelector parentNode?: Node | null - isIterationItem?: boolean + isItem?: boolean + isLoopItem?: boolean availableNodes: any[] isChatMode: boolean isConstant?: boolean @@ -58,7 +60,8 @@ export const useWorkflowVariables = () => { return getVarType({ parentNode, valueSelector, - isIterationItem, + isItem, + isLoopItem, availableNodes, isChatMode, isConstant, diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 0f6ae59b6e33a1..02bfa89d8413bd 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -57,6 +57,7 @@ import { import I18n from '@/context/i18n' import { CollectionType } from '@/app/components/tools/types' import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants' +import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants' import { useWorkflowConfig } from '@/service/use-workflow' export const useIsChatMode = () => { @@ -88,7 +89,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 [] @@ -238,6 +239,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() @@ -424,6 +434,7 @@ export const useWorkflow = () => { getNode, getBeforeNodeById, getIterationNodeChildren, + getLoopNodeChildren, } } @@ -637,3 +648,26 @@ export const useIsNodeInIteration = (iterationId: string) => { isNodeInIteration, } } + +export const useIsNodeInLoop = (loopId: string) => { + const store = useStoreApi() + + const isNodeInLoop = useCallback((nodeId: string) => { + const { + getNodes, + } = store.getState() + const nodes = getNodes() + const node = nodes.find(node => node.id === nodeId) + + if (!node) + return false + + if (node.parentId === loopId) + return true + + return false + }, [loopId, store]) + return { + isNodeInLoop, + } +} diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 0510109b397b67..6e5bac3605fdf8 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -59,6 +59,8 @@ import CustomNoteNode from './note-node' import { CUSTOM_NOTE_NODE } from './note-node/constants' import CustomIterationStartNode from './nodes/iteration-start' import { CUSTOM_ITERATION_START_NODE } from './nodes/iteration-start/constants' +import CustomLoopStartNode from './nodes/loop-start' +import { CUSTOM_LOOP_START_NODE } from './nodes/loop-start/constants' import Operator from './operator' import CustomEdge from './custom-edge' import CustomConnectionLine from './custom-connection-line' @@ -101,6 +103,7 @@ const nodeTypes = { [CUSTOM_NODE]: CustomNode, [CUSTOM_NOTE_NODE]: CustomNoteNode, [CUSTOM_ITERATION_START_NODE]: CustomIterationStartNode, + [CUSTOM_LOOP_START_NODE]: CustomLoopStartNode, } const edgeTypes = { [CUSTOM_NODE]: CustomEdge, @@ -352,6 +355,7 @@ const Workflow: FC = memo(({ onSelectionDrag={handleSelectionDrag} onPaneContextMenu={handlePaneContextMenu} connectionLineComponent={CustomConnectionLine} + // TODO: For LOOP node, how to distinguish between ITERATION and LOOP here? Maybe both are the same? connectionLineContainerStyle={{ zIndex: ITERATION_CHILDREN_Z_INDEX }} defaultViewport={viewport} multiSelectionKeyCode={null} diff --git a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx index 54ab4b327f0626..a5adff39825110 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx @@ -38,7 +38,7 @@ const Add = ({ const [open, setOpen] = useState(false) const { handleNodeAdd } = useNodesInteractions() const { nodesReadOnly } = useNodesReadOnly() - const { availableNextBlocks } = useAvailableBlocks(nodeData.type, nodeData.isInIteration) + const { availableNextBlocks } = useAvailableBlocks(nodeData.type, nodeData.isInIteration, nodeData.isInLoop) const { checkParallelLimit } = useWorkflow() const handleSelect = useCallback((type, toolDefaultValue) => { diff --git a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx index ad6c7abd0ce796..9e3873c71ddfd0 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx @@ -36,7 +36,7 @@ const ChangeItem = ({ const { availablePrevBlocks, availableNextBlocks, - } = useAvailableBlocks(data.type, data.isInIteration) + } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop) const handleSelect = useCallback((type, toolDefaultValue) => { handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue) diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx index 65798e46107856..3cf7cc7e03f66d 100644 --- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx @@ -47,7 +47,7 @@ export const NodeTargetHandle = memo(({ const { handleNodeAdd } = useNodesInteractions() const { getNodesReadOnly } = useNodesReadOnly() const connected = data._connectedTargetHandleIds?.includes(handleId) - const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration) + const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop) const isConnectable = !!availablePrevBlocks.length const handleOpenChange = useCallback((v: boolean) => { @@ -129,7 +129,7 @@ export const NodeSourceHandle = memo(({ const [open, setOpen] = useState(false) const { handleNodeAdd } = useNodesInteractions() const { getNodesReadOnly } = useNodesReadOnly() - const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration) + const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop) const isConnectable = !!availableNextBlocks.length const isChatMode = useIsChatMode() const { checkParallelLimit } = useWorkflow() diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx index debeb9a7ce8a0f..173f1ada6f24c0 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx @@ -30,7 +30,7 @@ const ChangeBlock = ({ const { availablePrevBlocks, availableNextBlocks, - } = useAvailableBlocks(nodeData.type, nodeData.isInIteration) + } = useAvailableBlocks(nodeData.type, nodeData.isInIteration, nodeData.isInLoop) const availableNodes = useMemo(() => { if (availablePrevBlocks.length && availableNextBlocks.length) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx index cd44d15606e633..a12879311b91d4 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx @@ -78,7 +78,7 @@ const PanelOperatorPopup = ({ return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language] }, [data, nodesExtraData, language, buildInTools, customTools, workflowTools]) - const showChangeBlock = data.type !== BlockEnum.Start && !nodesReadOnly && data.type !== BlockEnum.Iteration + const showChangeBlock = data.type !== BlockEnum.Start && !nodesReadOnly && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop const link = useNodeHelpLink(data.type) diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 482bb7acf7d9ba..c4af0ab984f4a9 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -13,6 +13,7 @@ import { VarType as ToolVarType } from '../../../tool/types' import type { ToolNodeType } from '../../../tool/types' import type { ParameterExtractorNodeType } from '../../../parameter-extractor/types' import type { IterationNodeType } from '../../../iteration/types' +import type { LoopNodeType } from '../../../loop/types' import type { ListFilterNodeType } from '../../../list-operator/types' import { OUTPUT_FILE_SUB_VARIABLES } from '../../../if-else/default' 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) @@ -800,6 +873,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 @@ -1077,6 +1158,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('.')) @@ -1198,6 +1290,11 @@ export const getNodeOutputVars = (node: Node, isChatMode: boolean): ValueSelecto break } + case BlockEnum.Loop: { + res.push([id, 'output']) + break + } + case BlockEnum.DocExtractor: { res.push([id, 'text']) break diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 3a4cece35c9863..24f9438c30b963 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -112,6 +112,9 @@ const VarReferencePicker: FC = ({ const isInIteration = !!node?.data.isInIteration const iterationNode = isInIteration ? getNodes().find(n => n.id === node.parentId) : null + const isInLoop = !!node?.data.isInLoop + const loopNode = isInLoop ? getNodes().find(n => n.id === node.parentId) : null + const triggerRef = useRef(null) const [triggerWidth, setTriggerWidth] = useState(TRIGGER_DEFAULT_WIDTH) useEffect(() => { @@ -140,6 +143,14 @@ const VarReferencePicker: FC = ({ return false }, [isInIteration, value, node]) + const isLoopVar = useMemo(() => { + if (!isInLoop) + return false + if (value[0] === node?.parentId && ['item', 'index'].includes(value[1])) + return true + return false + }, [isInLoop, value, node]) + const outputVarNodeId = hasValue ? value[0] : '' const outputVarNode = useMemo(() => { if (!hasValue || isConstant) @@ -148,11 +159,14 @@ const VarReferencePicker: FC = ({ if (isIterationVar) return iterationNode?.data + if (isLoopVar) + return loopNode?.data + if (isSystemVar(value as ValueSelector)) return startNode?.data return getNodeInfoById(availableNodes, outputVarNodeId)?.data - }, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode]) + }, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode, isLoopVar, loopNode]) const varName = useMemo(() => { if (hasValue) { @@ -218,7 +232,7 @@ const VarReferencePicker: FC = ({ }, [onChange, varKindType]) const type = getCurrentVariableType({ - parentNode: iterationNode, + parentNode: isInIteration ? iterationNode : loopNode, valueSelector: value as ValueSelector, availableNodes, isChatMode, diff --git a/web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts b/web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts index 2ecdf101d27067..d009521110e7e6 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts @@ -27,6 +27,8 @@ export const useNodeHelpLink = (nodeType: BlockEnum) => { [BlockEnum.Assigner]: 'variable-assigner', [BlockEnum.Iteration]: 'iteration', [BlockEnum.IterationStart]: 'iteration', + [BlockEnum.Loop]: 'loop', + [BlockEnum.LoopStart]: 'loop', [BlockEnum.ParameterExtractor]: 'parameter-extractor', [BlockEnum.HttpRequest]: 'http-request', [BlockEnum.Tool]: 'tools', @@ -50,6 +52,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', diff --git a/web/app/components/workflow/nodes/_base/hooks/use-node-info.ts b/web/app/components/workflow/nodes/_base/hooks/use-node-info.ts index f8f076dfdf0574..a66e0f19b59fa6 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-node-info.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-node-info.ts @@ -8,11 +8,13 @@ const useNodeInfo = (nodeId: string) => { const allNodes = getNodes() const node = allNodes.find(n => n.id === nodeId) const isInIteration = !!node?.data.isInIteration + const isInLoop = !!node?.data.isInLoop const parentNodeId = node?.parentId const parentNode = allNodes.find(n => n.id === parentNodeId) return { node, isInIteration, + isInLoop, parentNode, } } diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index 443ba1bbd27cb5..82f801e40743c1 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts @@ -13,7 +13,7 @@ import type { CommonNodeType, InputVar, ValueSelector, Var, Variable } from '@/a import { BlockEnum, InputVarType, NodeRunningStatus, VarType } from '@/app/components/workflow/types' import { useStore as useAppStore } from '@/app/components/app/store' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' -import { getIterationSingleNodeRunUrl, singleNodeRun } from '@/service/workflow' +import { getIterationSingleNodeRunUrl, getLoopSingleNodeRunUrl, singleNodeRun } from '@/service/workflow' import Toast from '@/app/components/base/toast' import LLMDefault from '@/app/components/workflow/nodes/llm/default' import KnowledgeRetrievalDefault from '@/app/components/workflow/nodes/knowledge-retrieval/default' @@ -28,6 +28,7 @@ import Assigner from '@/app/components/workflow/nodes/assigner/default' import ParameterExtractorDefault from '@/app/components/workflow/nodes/parameter-extractor/default' import IterationDefault from '@/app/components/workflow/nodes/iteration/default' import DocumentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default' +import LoopDefault from '@/app/components/workflow/nodes/loop/default' import { ssePost } from '@/service/base' import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants' @@ -45,6 +46,7 @@ const { checkValid: checkAssignerValid } = Assigner const { checkValid: checkParameterExtractorValid } = ParameterExtractorDefault const { checkValid: checkIterationValid } = IterationDefault const { checkValid: checkDocumentExtractorValid } = DocumentExtractorDefault +const { checkValid: checkLoopValid } = LoopDefault // eslint-disable-next-line ts/no-unsafe-function-type const checkValidFns: Record = { @@ -61,6 +63,7 @@ const checkValidFns: Record = { [BlockEnum.ParameterExtractor]: checkParameterExtractorValid, [BlockEnum.Iteration]: checkIterationValid, [BlockEnum.DocExtractor]: checkDocumentExtractorValid, + [BlockEnum.Loop]: checkLoopValid, } as any type Params = { @@ -69,6 +72,7 @@ type Params = { defaultRunInputData: Record moreDataForCheckValid?: any iteratorInputKey?: string + loopInputKey?: string } const varTypeToInputVarType = (type: VarType, { @@ -106,6 +110,7 @@ const useOneStepRun = ({ 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) @@ -146,6 +151,7 @@ const useOneStepRun = ({ const [canShowSingleRun, setCanShowSingleRun] = useState(false) const isShowSingleRun = data._isSingleRun && canShowSingleRun const [iterationRunResult, setIterationRunResult] = useState([]) + const [loopRunResult, setLoopRunResult] = useState([]) useEffect(() => { if (!checkValid) { @@ -170,7 +176,7 @@ const useOneStepRun = ({ }) } } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [data._isSingleRun]) const workflowStore = useWorkflowStore() @@ -209,10 +215,10 @@ const useOneStepRun = ({ }) let res: any try { - if (!isIteration) { + if (!isIteration && !isLoop) { res = await singleNodeRun(appId!, id, { inputs: submitData }) as any } - else { + else if (isIteration) { setIterationRunResult([]) let _iterationResult: NodeTracing[] = [] let _runResult: any = null @@ -310,11 +316,111 @@ const useOneStepRun = ({ }, ) } + 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: (params) => { + // TODO: ?? + // loop next trigger time is triggered one more time than iterationTimes + // if (_loopResult.length >= iterationTimes!) + // return _loopResult.length >= iterationTimes! + }, + 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.error) throw new Error(res.error) } catch (e: any) { - if (!isIteration) { + if (!isIteration && !isLoop) { handleNodeDataUpdate({ id, data: { @@ -326,7 +432,7 @@ const useOneStepRun = ({ } } finally { - if (!isIteration) { + if (!isIteration && !isLoop) { setRunResult({ ...res, total_tokens: res.execution_metadata?.total_tokens || 0, @@ -334,7 +440,7 @@ const useOneStepRun = ({ }) } } - if (!isIteration) { + if (!isIteration && !isLoop) { handleNodeDataUpdate({ id, data: { @@ -424,6 +530,7 @@ const useOneStepRun = ({ setRunInputData, runResult, iterationRunResult, + loopRunResult, } } diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 4807fa3b2b7457..754fe56e6e3a2a 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -30,7 +30,9 @@ import { hasRetryNode, } from '../../utils' import { useNodeIterationInteractions } from '../iteration/use-interactions' +import { useNodeLoopInteractions } from '../loop/use-interactions' import type { IterationNodeType } from '../iteration/types' +import type { SleepNodeType } from '../sleep/types' import { NodeSourceHandle, NodeTargetHandle, @@ -57,6 +59,7 @@ const BaseNode: FC = ({ const nodeRef = useRef(null) const { nodesReadOnly } = useNodesReadOnly() const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions() + const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions() const toolIcon = useToolIcon(data) useEffect(() => { @@ -73,6 +76,20 @@ const BaseNode: FC = ({ } }, [data.isInIteration, data.selected, id, handleNodeIterationChildSizeChange]) + useEffect(() => { + if (nodeRef.current && data.selected && data.isInLoop) { + const resizeObserver = new ResizeObserver(() => { + handleNodeLoopChildSizeChange(id) + }) + + resizeObserver.observe(nodeRef.current) + + return () => { + resizeObserver.disconnect() + } + } + }, [data.isInLoop, data.selected, id, handleNodeLoopChildSizeChange]) + const showSelectedBorder = data.selected || data._isBundled || data._isEntering const { showRunningBorder, @@ -98,16 +115,16 @@ const BaseNode: FC = ({ )} ref={nodeRef} style={{ - width: data.type === BlockEnum.Iteration ? data.width : 'auto', - height: data.type === BlockEnum.Iteration ? data.height : 'auto', + width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : 'auto', + height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto', }} >
= ({ /> ) } + { + data.type === BlockEnum.Loop && ( + + ) + } { !data._isCandidate && ( = ({ }
= ({ >
{data.title} + { + data.type === BlockEnum.Sleep + && +   + {(data as SleepNodeType).sleep_time_ms} + {t('workflow.nodes.sleep.unit')} +   + + }
{ data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && ( @@ -208,6 +242,13 @@ const BaseNode: FC = ({
) } + { + data._loopLength && data._loopIndex && data._runningStatus === NodeRunningStatus.Running && ( +
+ {data._loopIndex > data._loopLength ? data._loopLength : data._loopIndex}/{data._loopLength} +
+ ) + } { (data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running) && ( @@ -230,12 +271,12 @@ const BaseNode: FC = ({ }
{ - data.type !== BlockEnum.Iteration && ( + data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && ( cloneElement(children, { id, data }) ) } { - data.type === BlockEnum.Iteration && ( + (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && (
{cloneElement(children, { id, data })}
diff --git a/web/app/components/workflow/nodes/_base/panel.tsx b/web/app/components/workflow/nodes/_base/panel.tsx index b8282032856526..ea920cdb4b1ffe 100644 --- a/web/app/components/workflow/nodes/_base/panel.tsx +++ b/web/app/components/workflow/nodes/_base/panel.tsx @@ -68,7 +68,7 @@ const BasePanel: FC = ({ const { handleNodeSelect } = useNodesInteractions() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { nodesReadOnly } = useNodesReadOnly() - const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration) + const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop) const toolIcon = useToolIcon(data) const handleResize = useCallback((width: number) => { diff --git a/web/app/components/workflow/nodes/assigner/use-config.ts b/web/app/components/workflow/nodes/assigner/use-config.ts index fc41ac16ceb7dd..ad7d066ef1f471 100644 --- a/web/app/components/workflow/nodes/assigner/use-config.ts +++ b/web/app/components/workflow/nodes/assigner/use-config.ts @@ -39,6 +39,8 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => { const currentNode = getNodes().find(n => n.id === id) const isInIteration = payload.isInIteration const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null + const isInLoop = payload.isInLoop + const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null const availableNodes = useMemo(() => { return getBeforeNodesInSameBranch(id) }, [getBeforeNodesInSameBranch, id]) @@ -54,13 +56,13 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => { const { getCurrentVariableType } = useWorkflowVariables() const getAssignedVarType = useCallback((valueSelector: ValueSelector) => { return getCurrentVariableType({ - parentNode: iterationNode, + parentNode: isInIteration ? iterationNode : loopNode, valueSelector: valueSelector || [], availableNodes, isChatMode, isConstant: false, }) - }, [getCurrentVariableType, iterationNode, availableNodes, isChatMode]) + }, [getCurrentVariableType, isInIteration, iterationNode, loopNode, availableNodes, isChatMode]) const handleOperationListChanges = useCallback((items: AssignerNodeOperation[]) => { const newInputs = produce(inputs, (draft) => { diff --git a/web/app/components/workflow/nodes/constants.ts b/web/app/components/workflow/nodes/constants.ts index dc202acc284bfa..6081150b069aac 100644 --- a/web/app/components/workflow/nodes/constants.ts +++ b/web/app/components/workflow/nodes/constants.ts @@ -30,6 +30,8 @@ import ParameterExtractorNode from './parameter-extractor/node' import ParameterExtractorPanel from './parameter-extractor/panel' import IterationNode from './iteration/node' import IterationPanel from './iteration/panel' +import LoopNode from './loop/node' +import LoopPanel from './loop/panel' import DocExtractorNode from './document-extractor/node' import DocExtractorPanel from './document-extractor/panel' import ListFilterNode from './list-operator/node' @@ -54,6 +56,7 @@ export const NodeComponentMap: Record> = { [BlockEnum.VariableAggregator]: VariableAssignerNode, [BlockEnum.ParameterExtractor]: ParameterExtractorNode, [BlockEnum.Iteration]: IterationNode, + [BlockEnum.Loop]: LoopNode, [BlockEnum.DocExtractor]: DocExtractorNode, [BlockEnum.ListFilter]: ListFilterNode, [BlockEnum.Agent]: AgentNode, @@ -76,6 +79,7 @@ export const PanelComponentMap: Record> = { [BlockEnum.Assigner]: AssignerPanel, [BlockEnum.ParameterExtractor]: ParameterExtractorPanel, [BlockEnum.Iteration]: IterationPanel, + [BlockEnum.Loop]: LoopPanel, [BlockEnum.DocExtractor]: DocExtractorPanel, [BlockEnum.ListFilter]: ListFilterPanel, [BlockEnum.Agent]: AgentPanel, diff --git a/web/app/components/workflow/nodes/document-extractor/use-config.ts b/web/app/components/workflow/nodes/document-extractor/use-config.ts index 95ac9fe7a71a41..8ceb1538743824 100644 --- a/web/app/components/workflow/nodes/document-extractor/use-config.ts +++ b/web/app/components/workflow/nodes/document-extractor/use-config.ts @@ -32,6 +32,8 @@ const useConfig = (id: string, payload: DocExtractorNodeType) => { const currentNode = getNodes().find(n => n.id === id) const isInIteration = payload.isInIteration const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null + const isInLoop = payload.isInLoop + const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null const availableNodes = useMemo(() => { return getBeforeNodesInSameBranch(id) }, [getBeforeNodesInSameBranch, id]) @@ -39,14 +41,14 @@ const useConfig = (id: string, payload: DocExtractorNodeType) => { const { getCurrentVariableType } = useWorkflowVariables() const getType = useCallback((variable?: ValueSelector) => { const varType = getCurrentVariableType({ - parentNode: iterationNode, + parentNode: isInIteration ? iterationNode : loopNode, valueSelector: variable || [], availableNodes, isChatMode, isConstant: false, }) return varType - }, [getCurrentVariableType, availableNodes, isChatMode, iterationNode]) + }, [getCurrentVariableType, isInIteration, availableNodes, isChatMode, iterationNode, loopNode]) const handleVarChanges = useCallback((variable: ValueSelector | string) => { const newInputs = produce(inputs, (draft) => { diff --git a/web/app/components/workflow/nodes/if-else/types.ts b/web/app/components/workflow/nodes/if-else/types.ts index 22238b33892fbd..f078fab4c512f9 100644 --- a/web/app/components/workflow/nodes/if-else/types.ts +++ b/web/app/components/workflow/nodes/if-else/types.ts @@ -57,6 +57,7 @@ export type IfElseNodeType = CommonNodeType & { conditions?: Condition[] cases: CaseItem[] isInIteration: boolean + isInLoop: boolean } export type HandleAddCondition = (caseId: string, valueSelector: ValueSelector, varItem: Var) => void diff --git a/web/app/components/workflow/nodes/if-else/use-config.ts b/web/app/components/workflow/nodes/if-else/use-config.ts index 41e41f6b8bbc10..827eb499f91c22 100644 --- a/web/app/components/workflow/nodes/if-else/use-config.ts +++ b/web/app/components/workflow/nodes/if-else/use-config.ts @@ -57,6 +57,7 @@ const useConfig = (id: string, payload: IfElseNodeType) => { } = useIsVarFileAttribute({ nodeId: id, isInIteration: payload.isInIteration, + isInLoop: payload.isInLoop, }) const varsIsVarFileAttribute = useMemo(() => { diff --git a/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts b/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts index 81552dbef6c774..c0cf8cfefe89cf 100644 --- a/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts +++ b/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts @@ -7,10 +7,12 @@ import { VarType } from '../../types' type Params = { nodeId: string isInIteration: boolean + isInLoop: boolean } const useIsVarFileAttribute = ({ nodeId, isInIteration, + isInLoop, }: Params) => { const isChatMode = useIsChatMode() const store = useStoreApi() @@ -20,6 +22,7 @@ const useIsVarFileAttribute = ({ } = store.getState() const currentNode = getNodes().find(n => n.id === nodeId) const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null + const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null const availableNodes = useMemo(() => { return getBeforeNodesInSameBranch(nodeId) }, [getBeforeNodesInSameBranch, nodeId]) @@ -29,7 +32,7 @@ const useIsVarFileAttribute = ({ return false const parentVariable = variable.slice(0, 2) const varType = getCurrentVariableType({ - parentNode: iterationNode, + parentNode: isInIteration ? iterationNode : loopNode, valueSelector: parentVariable, availableNodes, isChatMode, diff --git a/web/app/components/workflow/nodes/list-operator/use-config.ts b/web/app/components/workflow/nodes/list-operator/use-config.ts index 00defe7a844ab4..efbf32b8c7a641 100644 --- a/web/app/components/workflow/nodes/list-operator/use-config.ts +++ b/web/app/components/workflow/nodes/list-operator/use-config.ts @@ -27,6 +27,8 @@ const useConfig = (id: string, payload: ListFilterNodeType) => { const currentNode = getNodes().find(n => n.id === id) const isInIteration = payload.isInIteration const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null + const isInLoop = payload.isInLoop + const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null const availableNodes = useMemo(() => { return getBeforeNodesInSameBranch(id) }, [getBeforeNodesInSameBranch, id]) @@ -36,7 +38,7 @@ const useConfig = (id: string, payload: ListFilterNodeType) => { const { getCurrentVariableType } = useWorkflowVariables() const getType = useCallback((variable?: ValueSelector) => { const varType = getCurrentVariableType({ - parentNode: iterationNode, + parentNode: isInIteration ? iterationNode : loopNode, valueSelector: variable || inputs.variable || [], availableNodes, isChatMode, @@ -60,7 +62,7 @@ const useConfig = (id: string, payload: ListFilterNodeType) => { itemVarType = varType } return { varType, itemVarType } - }, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, iterationNode]) + }, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, isInIteration, iterationNode, loopNode]) const { varType, itemVarType } = getType() diff --git a/web/app/components/workflow/nodes/loop-start/constants.ts b/web/app/components/workflow/nodes/loop-start/constants.ts new file mode 100644 index 00000000000000..3185b8421a4527 --- /dev/null +++ b/web/app/components/workflow/nodes/loop-start/constants.ts @@ -0,0 +1 @@ +export const CUSTOM_LOOP_START_NODE = 'custom-loop-start' diff --git a/web/app/components/workflow/nodes/loop-start/default.ts b/web/app/components/workflow/nodes/loop-start/default.ts new file mode 100644 index 00000000000000..8eb1c43d5de2d5 --- /dev/null +++ b/web/app/components/workflow/nodes/loop-start/default.ts @@ -0,0 +1,21 @@ +import type { NodeDefault } from '../../types' +import type { LoopStartNodeType } from './types' +import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/constants' + +const nodeDefault: NodeDefault = { + defaultValue: {}, + getAvailablePrevNodes() { + return [] + }, + getAvailableNextNodes(isChatMode: boolean) { + const nodes = isChatMode ? ALL_CHAT_AVAILABLE_BLOCKS : ALL_COMPLETION_AVAILABLE_BLOCKS + return nodes + }, + checkValid() { + return { + isValid: true, + } + }, +} + +export default nodeDefault diff --git a/web/app/components/workflow/nodes/loop-start/index.tsx b/web/app/components/workflow/nodes/loop-start/index.tsx new file mode 100644 index 00000000000000..a43ec36ff7e31f --- /dev/null +++ b/web/app/components/workflow/nodes/loop-start/index.tsx @@ -0,0 +1,42 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import type { NodeProps } from 'reactflow' +import { RiHome5Fill } from '@remixicon/react' +import Tooltip from '@/app/components/base/tooltip' +import { NodeSourceHandle } from '@/app/components/workflow/nodes/_base/components/node-handle' + +const LoopStartNode = ({ id, data }: NodeProps) => { + const { t } = useTranslation() + + return ( +
+ +
+ +
+
+ +
+ ) +} + +export const LoopStartNodeDumb = () => { + const { t } = useTranslation() + + return ( +
+ +
+ +
+
+
+ ) +} + +export default memo(LoopStartNode) diff --git a/web/app/components/workflow/nodes/loop-start/types.ts b/web/app/components/workflow/nodes/loop-start/types.ts new file mode 100644 index 00000000000000..1ba7136926df80 --- /dev/null +++ b/web/app/components/workflow/nodes/loop-start/types.ts @@ -0,0 +1,3 @@ +import type { CommonNodeType } from '@/app/components/workflow/types' + +export type LoopStartNodeType = CommonNodeType diff --git a/web/app/components/workflow/nodes/loop/add-block.tsx b/web/app/components/workflow/nodes/loop/add-block.tsx new file mode 100644 index 00000000000000..0c7b5c5bb022b1 --- /dev/null +++ b/web/app/components/workflow/nodes/loop/add-block.tsx @@ -0,0 +1,82 @@ +import { + memo, + useCallback, +} from 'react' +import { + RiAddLine, +} from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { + useAvailableBlocks, + useNodesInteractions, + useNodesReadOnly, +} from '../../hooks' +import type { LoopNodeType } from './types' +import cn from '@/utils/classnames' +import BlockSelector from '@/app/components/workflow/block-selector' + +import type { + OnSelectBlock, +} from '@/app/components/workflow/types' +import { + BlockEnum, +} from '@/app/components/workflow/types' + +type AddBlockProps = { + loopNodeId: string + loopNodeData: LoopNodeType +} +const AddBlock = ({ + loopNodeData, +}: AddBlockProps) => { + const { t } = useTranslation() + const { nodesReadOnly } = useNodesReadOnly() + const { handleNodeAdd } = useNodesInteractions() + const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false, true) + + const handleSelect = useCallback((type, toolDefaultValue) => { + handleNodeAdd( + { + nodeType: type, + toolDefaultValue, + }, + { + prevNodeId: loopNodeData.start_node_id, + prevNodeSourceHandle: 'source', + }, + ) + }, [handleNodeAdd, loopNodeData.start_node_id]) + + const renderTriggerElement = useCallback((open: boolean) => { + return ( +
+ + + {t('workflow.common.addBlock')} +
+ ) + }, [nodesReadOnly, t]) + + return ( +
+
+
+
+ + +
+ ) +} + +export default memo(AddBlock) diff --git a/web/app/components/workflow/nodes/loop/components/condition-add.tsx b/web/app/components/workflow/nodes/loop/components/condition-add.tsx new file mode 100644 index 00000000000000..dcba4c974af06b --- /dev/null +++ b/web/app/components/workflow/nodes/loop/components/condition-add.tsx @@ -0,0 +1,74 @@ +import { + useCallback, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { RiAddLine } from '@remixicon/react' +import type { HandleAddCondition } from '../types' +import Button from '@/app/components/base/button' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' +import type { + NodeOutPutVar, + ValueSelector, + Var, +} from '@/app/components/workflow/types' + +type ConditionAddProps = { + className?: string + variables: NodeOutPutVar[] + onSelectVariable: HandleAddCondition + disabled?: boolean +} +const ConditionAdd = ({ + className, + variables, + onSelectVariable, + disabled, +}: ConditionAddProps) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + const handleSelectVariable = useCallback((valueSelector: ValueSelector, varItem: Var) => { + onSelectVariable(valueSelector, varItem) + setOpen(false) + }, [onSelectVariable, setOpen]) + + return ( + + setOpen(!open)}> + + + +
+ +
+
+
+ ) +} + +export default ConditionAdd diff --git a/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx b/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx new file mode 100644 index 00000000000000..d4203d1bc2ed2b --- /dev/null +++ b/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx @@ -0,0 +1,115 @@ +import { + memo, + useCallback, +} from 'react' +import { useTranslation } from 'react-i18next' +import { ComparisonOperator, type Condition } from '../types' +import { + comparisonOperatorNotRequireValue, + isComparisonOperatorNeedTranslate, + isEmptyRelatedOperator, +} from '../utils' +import type { ValueSelector } from '../../../types' +import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from './../default' +import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' +import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others' +import cn from '@/utils/classnames' +import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' +const i18nPrefix = 'workflow.nodes.ifElse' + +type ConditionValueProps = { + condition: Condition +} +const ConditionValue = ({ + condition, +}: ConditionValueProps) => { + const { t } = useTranslation() + const { + variable_selector, + comparison_operator: operator, + sub_variable_condition, + } = condition + + const variableSelector = variable_selector as ValueSelector + + const variableName = (isSystemVar(variableSelector) ? variableSelector.slice(0).join('.') : variableSelector.slice(1).join('.')) + const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}`) : operator + const notHasValue = comparisonOperatorNotRequireValue(operator) + const isEnvVar = isENV(variableSelector) + const isChatVar = isConversationVar(variableSelector) + const formatValue = useCallback((c: Condition) => { + const notHasValue = comparisonOperatorNotRequireValue(c.comparison_operator) + if (notHasValue) + return '' + + const value = c.value as string + return value.replace(/{{#([^#]*)#}}/g, (a, b) => { + const arr: string[] = b.split('.') + if (isSystemVar(arr)) + return `{{${b}}}` + + return `{{${arr.slice(1).join('.')}}}` + }) + }, []) + + const isSelect = useCallback((c: Condition) => { + return c.comparison_operator === ComparisonOperator.in || c.comparison_operator === ComparisonOperator.notIn + }, []) + + const selectName = useCallback((c: Condition) => { + const isSelect = c.comparison_operator === ComparisonOperator.in || c.comparison_operator === ComparisonOperator.notIn + if (isSelect) { + const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(c.value) ? c.value[0] : c.value))[0] + return name + ? t(`workflow.nodes.ifElse.optionName.${name.i18nKey}`).replace(/{{#([^#]*)#}}/g, (a, b) => { + const arr: string[] = b.split('.') + if (isSystemVar(arr)) + return `{{${b}}}` + + return `{{${arr.slice(1).join('.')}}}` + }) + : '' + } + return '' + }, []) + + return ( +
+
+ {!isEnvVar && !isChatVar && } + {isEnvVar && } + {isChatVar && } + +
+ {variableName} +
+
+ {operatorName} +
+
+
+ { + sub_variable_condition?.conditions.map((c: Condition, index) => ( +
+
{c.key}
+
{isComparisonOperatorNeedTranslate(c.comparison_operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${c.comparison_operator}`) : c.comparison_operator}
+ {c.comparison_operator && !isEmptyRelatedOperator(c.comparison_operator) &&
{isSelect(c) ? selectName(c) : formatValue(c)}
} + {index !== sub_variable_condition.conditions.length - 1 && (
{t(`${i18nPrefix}.${sub_variable_condition.logical_operator}`)}
)} +
+ )) + } +
+
+ ) +} + +export default memo(ConditionValue) diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx new file mode 100644 index 00000000000000..c393aaaa58ac0b --- /dev/null +++ b/web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx @@ -0,0 +1,56 @@ +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, + NodeOutPutVar, +} from '@/app/components/workflow/types' + +type ConditionInputProps = { + disabled?: boolean + value: string + onChange: (value: string) => void + nodesOutputVars: NodeOutPutVar[] + availableNodes: Node[] +} +const ConditionInput = ({ + value, + onChange, + disabled, + nodesOutputVars, + availableNodes, +}: ConditionInputProps) => { + const { t } = useTranslation() + const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) + + return ( + { + acc[node.id] = { + title: node.data.title, + type: node.data.type, + } + if (node.data.type === BlockEnum.Start) { + acc.sys = { + title: t('workflow.blocks.start'), + type: BlockEnum.Start, + } + } + return acc + }, {} as any), + }} + onChange={onChange} + editable={!disabled} + /> + ) +} + +export default ConditionInput diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx new file mode 100644 index 00000000000000..b55f820ace1f0c --- /dev/null +++ b/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx @@ -0,0 +1,318 @@ +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 VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag' +import type { + Node, + NodeOutPutVar, + 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' +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 + nodesOutputVars: NodeOutPutVar[] + availableNodes: Node[] + numberVariables: NodeOutPutVar[] + filterVar: (varPayload: Var) => boolean +} +const ConditionItem = ({ + className, + disabled, + conditionId, + condition, + file, + isSubVariableKey, + isValueFieldShort, + onRemoveCondition, + onUpdateCondition, + onAddSubVariableCondition, + onRemoveSubVariableCondition, + onUpdateSubVariableCondition, + onToggleSubVariableConditionLogicalOperator, + nodeId, + nodesOutputVars, + availableNodes, + numberVariables, + filterVar, +}: ConditionItemProps) => { + const { t } = useTranslation() + + const [isHovered, setIsHovered] = 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, fileAttr]) + + 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]) + + return ( +
+
+
+
+ {isSubVarSelect + ? ( + handleUpdateConditionValue(item.value as string)} + hideChecked + notClearable + /> +
+ ) + } + { + !comparisonOperatorNotRequireValue(condition.comparison_operator) && isSubVariable && ( +
+ +
+ ) + } +
+
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={doRemoveCondition} + > + +
+
+ ) +} + +export default ConditionItem diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx new file mode 100644 index 00000000000000..ecbe53f689cd14 --- /dev/null +++ b/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx @@ -0,0 +1,94 @@ +import { + useMemo, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { RiArrowDownSLine } from '@remixicon/react' +import { getOperators, isComparisonOperatorNeedTranslate } from '../../utils' +import type { ComparisonOperator } from '../../types' +import Button from '@/app/components/base/button' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import type { VarType } from '@/app/components/workflow/types' +import cn from '@/utils/classnames' +const i18nPrefix = 'workflow.nodes.ifElse' + +type ConditionOperatorProps = { + className?: string + disabled?: boolean + varType: VarType + file?: { key: string } + value?: string + onSelect: (value: ComparisonOperator) => void +} +const ConditionOperator = ({ + className, + disabled, + varType, + file, + value, + onSelect, +}: ConditionOperatorProps) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + const options = useMemo(() => { + return getOperators(varType, file).map((o) => { + return { + label: isComparisonOperatorNeedTranslate(o) ? t(`${i18nPrefix}.comparisonOperator.${o}`) : o, + value: o, + } + }) + }, [t, varType, file]) + const selectedOption = options.find(o => Array.isArray(value) ? o.value === value[0] : o.value === value) + return ( + + setOpen(v => !v)}> + + + +
+ { + options.map(option => ( +
{ + onSelect(option.value) + setOpen(false) + }} + > + {option.label} +
+ )) + } +
+
+
+ ) +} + +export default ConditionOperator diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/index.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/index.tsx new file mode 100644 index 00000000000000..5d24731182ac2d --- /dev/null +++ b/web/app/components/workflow/nodes/loop/components/condition-list/index.tsx @@ -0,0 +1,133 @@ +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, + Var, +} 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 + nodesOutputVars: NodeOutPutVar[] + availableNodes: Node[] + numberVariables: NodeOutPutVar[] + filterVar: (varPayload: Var) => boolean + varsIsVarFileAttribute: Record + onAddSubVariableCondition?: HandleAddSubVariableCondition + onRemoveSubVariableCondition?: handleRemoveSubVariableCondition + onUpdateSubVariableCondition?: HandleUpdateSubVariableCondition + onToggleSubVariableConditionLogicalOperator?: HandleToggleSubVariableConditionLogicalOperator +} +const ConditionList = ({ + isSubVariable, + disabled, + conditionId, + conditions, + logicalOperator, + onUpdateCondition, + onRemoveCondition, + onToggleConditionLogicalOperator, + onAddSubVariableCondition, + onRemoveSubVariableCondition, + onUpdateSubVariableCondition, + onToggleSubVariableConditionLogicalOperator, + nodeId, + nodesOutputVars, + availableNodes, + numberVariables, + varsIsVarFileAttribute, + filterVar, +}: ConditionListProps) => { + const doToggleConditionLogicalOperator = useCallback((conditionId?: string) => { + if (isSubVariable && conditionId) + onToggleSubVariableConditionLogicalOperator?.(conditionId) + else + onToggleConditionLogicalOperator?.() + }, [isSubVariable, onToggleConditionLogicalOperator, onToggleSubVariableConditionLogicalOperator]) + + const isValueFieldShort = useMemo(() => { + if (isSubVariable && conditions.length > 1) + return true + + return false + }, [conditions.length, isSubVariable]) + const conditionItemClassName = useMemo(() => { + if (!isSubVariable) + return '' + if (conditions.length < 2) + return '' + return logicalOperator === LogicalOperator.and ? 'pl-[51px]' : 'pl-[42px]' + }, [conditions.length, isSubVariable, logicalOperator]) + + return ( +
1 && !isSubVariable && 'pl-[60px]')}> + { + conditions.length > 1 && ( +
+
+
+
doToggleConditionLogicalOperator(conditionId)} + > + {logicalOperator && logicalOperator.toUpperCase()} + +
+
+ ) + } + { + conditions.map(condition => ( + + )) + } +
+ ) +} + +export default ConditionList diff --git a/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx b/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx new file mode 100644 index 00000000000000..5dabd967cd113b --- /dev/null +++ b/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx @@ -0,0 +1,168 @@ +import { + memo, + useCallback, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { RiArrowDownSLine } from '@remixicon/react' +import { capitalize } from 'lodash-es' +import { useBoolean } from 'ahooks' +import { VarType as NumberVarType } from '../../tool/types' +import VariableTag from '../../_base/components/variable-tag' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import Button from '@/app/components/base/button' +import cn from '@/utils/classnames' +import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' +import type { + NodeOutPutVar, + ValueSelector, +} from '@/app/components/workflow/types' +import { VarType } from '@/app/components/workflow/types' +import { variableTransformer } from '@/app/components/workflow/utils' +import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' + +const options = [ + NumberVarType.variable, + NumberVarType.constant, +] + +type ConditionNumberInputProps = { + numberVarType?: NumberVarType + onNumberVarTypeChange: (v: NumberVarType) => void + value: string + onValueChange: (v: string) => void + variables: NodeOutPutVar[] + isShort?: boolean + unit?: string +} +const ConditionNumberInput = ({ + numberVarType = NumberVarType.constant, + onNumberVarTypeChange, + value, + onValueChange, + variables, + isShort, + unit, +}: ConditionNumberInputProps) => { + const { t } = useTranslation() + const [numberVarTypeVisible, setNumberVarTypeVisible] = useState(false) + const [variableSelectorVisible, setVariableSelectorVisible] = useState(false) + const [isFocus, { + setTrue: setFocus, + setFalse: setBlur, + }] = useBoolean() + + const handleSelectVariable = useCallback((valueSelector: ValueSelector) => { + onValueChange(variableTransformer(valueSelector) as string) + setVariableSelectorVisible(false) + }, [onValueChange]) + + return ( +
+ + setNumberVarTypeVisible(v => !v)}> + + + +
+ { + options.map(option => ( +
{ + onNumberVarTypeChange(option) + setNumberVarTypeVisible(false) + }} + > + {capitalize(option)} +
+ )) + } +
+
+
+
+
+ { + numberVarType === NumberVarType.variable && ( + + setVariableSelectorVisible(v => !v)}> + { + value && ( + + ) + } + { + !value && ( +
+ +
{t('workflow.nodes.ifElse.selectVariable')}
+
+ ) + } +
+ +
+ +
+
+
+ ) + } + { + numberVarType === NumberVarType.constant && ( +
+ onValueChange(e.target.value)} + placeholder={t('workflow.nodes.ifElse.enterValue') || ''} + onFocus={setFocus} + onBlur={setBlur} + /> + {!isFocus && unit &&
{unit}
} +
+ ) + } +
+
+ ) +} + +export default memo(ConditionNumberInput) diff --git a/web/app/components/workflow/nodes/loop/components/condition-value.tsx b/web/app/components/workflow/nodes/loop/components/condition-value.tsx new file mode 100644 index 00000000000000..c30b7c7cc9adba --- /dev/null +++ b/web/app/components/workflow/nodes/loop/components/condition-value.tsx @@ -0,0 +1,98 @@ +import { + memo, + useMemo, +} from 'react' +import { useTranslation } from 'react-i18next' +import { ComparisonOperator } from '../types' +import { + comparisonOperatorNotRequireValue, + isComparisonOperatorNeedTranslate, +} from '../utils' +import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from './../default' +import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' +import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others' +import cn from '@/utils/classnames' +import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' + +type ConditionValueProps = { + variableSelector: string[] + labelName?: string + operator: ComparisonOperator + value: string | string[] +} +const ConditionValue = ({ + variableSelector, + labelName, + operator, + value, +}: ConditionValueProps) => { + const { t } = useTranslation() + const variableName = labelName || (isSystemVar(variableSelector) ? variableSelector.slice(0).join('.') : variableSelector.slice(1).join('.')) + const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}`) : operator + const notHasValue = comparisonOperatorNotRequireValue(operator) + const isEnvVar = isENV(variableSelector) + const isChatVar = isConversationVar(variableSelector) + const formatValue = useMemo(() => { + if (notHasValue) + return '' + + if (Array.isArray(value)) // transfer method + return value[0] + + return value.replace(/{{#([^#]*)#}}/g, (a, b) => { + const arr: string[] = b.split('.') + if (isSystemVar(arr)) + return `{{${b}}}` + + return `{{${arr.slice(1).join('.')}}}` + }) + }, [notHasValue, value]) + + const isSelect = operator === ComparisonOperator.in || operator === ComparisonOperator.notIn + const selectName = useMemo(() => { + if (isSelect) { + const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(value) ? value[0] : value))[0] + return name + ? t(`workflow.nodes.ifElse.optionName.${name.i18nKey}`).replace(/{{#([^#]*)#}}/g, (a, b) => { + const arr: string[] = b.split('.') + if (isSystemVar(arr)) + return `{{${b}}}` + + return `{{${arr.slice(1).join('.')}}}` + }) + : '' + } + return '' + }, [isSelect, t, value]) + + return ( +
+ {!isEnvVar && !isChatVar && } + {isEnvVar && } + {isChatVar && } + +
+ {variableName} +
+
+ {operatorName} +
+ { + !notHasValue && ( +
{isSelect ? selectName : formatValue}
+ ) + } +
+ ) +} + +export default memo(ConditionValue) diff --git a/web/app/components/workflow/nodes/loop/components/condition-wrap.tsx b/web/app/components/workflow/nodes/loop/components/condition-wrap.tsx new file mode 100644 index 00000000000000..3ab144524a53d6 --- /dev/null +++ b/web/app/components/workflow/nodes/loop/components/condition-wrap.tsx @@ -0,0 +1,156 @@ +'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 + readOnly: boolean + handleAddCondition?: HandleAddCondition + handleRemoveCondition?: HandleRemoveCondition + handleUpdateCondition?: HandleUpdateCondition + handleToggleConditionLogicalOperator?: HandleToggleConditionLogicalOperator + handleAddSubVariableCondition?: HandleAddSubVariableCondition + handleRemoveSubVariableCondition?: handleRemoveSubVariableCondition + handleUpdateSubVariableCondition?: HandleUpdateSubVariableCondition + handleToggleSubVariableConditionLogicalOperator?: HandleToggleSubVariableConditionLogicalOperator + nodeId: string + nodesOutputVars: NodeOutPutVar[] + availableNodes: Node[] + varsIsVarFileAttribute?: Record + filterVar: (varPayload: Var) => boolean + availableVars: NodeOutPutVar[] +} + +const ConditionWrap: FC = ({ + isSubVariable, + conditionId, + conditions, + logicalOperator, + nodeId: id = '', + readOnly, + handleUpdateCondition, + handleAddCondition, + handleRemoveCondition, + handleToggleConditionLogicalOperator, + handleAddSubVariableCondition, + handleRemoveSubVariableCondition, + handleUpdateSubVariableCondition, + handleToggleSubVariableConditionLogicalOperator, + nodesOutputVars = [], + availableNodes = [], + varsIsVarFileAttribute = {}, + filterVar = () => true, + availableVars = [], +}) => { + const { t } = useTranslation() + + const getAvailableVars = useGetAvailableVars() + + const filterNumberVar = useCallback((varPayload: Var) => { + return varPayload.type === VarType.number + }, []) + + const subVarOptions = SUB_VARIABLES.map(item => ({ + name: item, + value: item, + })) + + if (!conditions) + return
+ + return ( + <> +
+
+ { + conditions && !!conditions.length && ( +
+ +
+ ) + } + +
1 && !isSubVariable && 'ml-[60px]', + )}> + {isSubVariable + ? ( + + + +
{isShowSingleRun && ( > = ({ onRun={handleRun} onStop={handleStop} result={ -
-
-
-
{t(`${i18nPrefix}.loop`, { count: loopRunResult.length })}
- - -
- -
- -
+ } /> )} - {isShowLoopDetail && ( - - )}
) } diff --git a/web/app/components/workflow/nodes/loop/types.ts b/web/app/components/workflow/nodes/loop/types.ts index 16457195c1d4b0..b91c24cc2e23ed 100644 --- a/web/app/components/workflow/nodes/loop/types.ts +++ b/web/app/components/workflow/nodes/loop/types.ts @@ -2,6 +2,7 @@ import type { VarType as NumberVarType } from '../tool/types' import type { BlockEnum, CommonNodeType, + ErrorHandleMode, ValueSelector, Var, VarType, @@ -71,4 +72,5 @@ export type LoopNodeType = CommonNodeType & { logical_operator?: LogicalOperator break_conditions?: Condition[] loop_count: number + error_handle_mode: ErrorHandleMode // how to handle error in the iteration } diff --git a/web/app/components/workflow/nodes/loop/use-config.ts b/web/app/components/workflow/nodes/loop/use-config.ts index cc01db8678a9c5..91eb81c46bc7be 100644 --- a/web/app/components/workflow/nodes/loop/use-config.ts +++ b/web/app/components/workflow/nodes/loop/use-config.ts @@ -9,7 +9,7 @@ import { useWorkflow, } from '../../hooks' import { VarType } from '../../types' -import type { ValueSelector, Var } 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' @@ -79,10 +79,6 @@ const useConfig = (id: string, payload: LoopNodeType) => { showSingleRun() }, [hideLoopDetail, showSingleRun]) - const filterNumberVar = useCallback((varPayload: Var) => { - return varPayload.type === VarType.number - }, []) - const { getIsVarFileAttribute, } = useIsVarFileAttribute({ @@ -175,6 +171,13 @@ const useConfig = (id: string, payload: LoopNodeType) => { }) }, [loopInputKey, runInputData, setRunInputData]) + const changeErrorResponseMode = useCallback((item: { value: unknown }) => { + const newInputs = produce(inputs, (draft) => { + draft.error_handle_mode = item.value as ErrorHandleMode + }) + setInputs(newInputs) + }, [inputs, setInputs]) + const handleAddCondition = useCallback((valueSelector, varItem) => { const newInputs = produce(inputs, (draft) => { if (!draft.break_conditions) @@ -319,6 +322,7 @@ const useConfig = (id: string, payload: LoopNodeType) => { handleRemoveSubVariableCondition, handleToggleSubVariableConditionLogicalOperator, handleUpdateLoopCount, + changeErrorResponseMode, } } diff --git a/web/app/components/workflow/panel/debug-and-preview/hooks.ts b/web/app/components/workflow/panel/debug-and-preview/hooks.ts index 7f94fcfb8455fd..b156da1b00e4a0 100644 --- a/web/app/components/workflow/panel/debug-and-preview/hooks.ts +++ b/web/app/components/workflow/panel/debug-and-preview/hooks.ts @@ -510,4 +510,4 @@ export const useChat = ( isResponding, suggestedQuestions, } -} \ No newline at end of file +} diff --git a/web/app/components/workflow/run/node.tsx b/web/app/components/workflow/run/node.tsx index f98822b4aa5a0b..265716078410bb 100644 --- a/web/app/components/workflow/run/node.tsx +++ b/web/app/components/workflow/run/node.tsx @@ -145,6 +145,7 @@ const NodePanel: FC = ({ onShowIterationResultList={onShowIterationDetail} /> )} + {/* The nav to the Loop detail */} {isLoopNode && !notShowLoopNav && onShowLoopDetail && ( = ({ ) } -export default NodePanel \ No newline at end of file +export default NodePanel diff --git a/web/app/components/workflow/run/result-panel.tsx b/web/app/components/workflow/run/result-panel.tsx index b05e5cb888f15b..ef8536deb861cc 100644 --- a/web/app/components/workflow/run/result-panel.tsx +++ b/web/app/components/workflow/run/result-panel.tsx @@ -13,6 +13,7 @@ import type { import { BlockEnum } from '@/app/components/workflow/types' import { hasRetryNode } from '@/app/components/workflow/utils' import { IterationLogTrigger } from '@/app/components/workflow/run/iteration-log' +import { LoopLogTrigger } from '@/app/components/workflow/run/loop-log' import { RetryLogTrigger } from '@/app/components/workflow/run/retry-log' import { AgentLogTrigger } from '@/app/components/workflow/run/agent-log' @@ -33,6 +34,7 @@ type ResultPanelProps = { exceptionCounts?: number execution_metadata?: any handleShowIterationResultList?: (detail: NodeTracing[][], iterDurationMap: any) => void + handleShowLoopResultList?: (detail: NodeTracing[][], loopDurationMap: any) => void onShowRetryDetail?: (detail: NodeTracing[]) => void handleShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void } @@ -53,11 +55,13 @@ const ResultPanel: FC = ({ exceptionCounts, execution_metadata, handleShowIterationResultList, + handleShowLoopResultList, onShowRetryDetail, handleShowAgentOrToolLog, }) => { const { t } = useTranslation() const isIterationNode = nodeInfo?.node_type === BlockEnum.Iteration && !!nodeInfo?.details?.length + const isLoopNode = nodeInfo?.node_type === BlockEnum.Loop && !!nodeInfo?.details?.length const isRetryNode = hasRetryNode(nodeInfo?.node_type) && !!nodeInfo?.retryDetail?.length const isAgentNode = nodeInfo?.node_type === BlockEnum.Agent && !!nodeInfo?.agentLog?.length const isToolNode = nodeInfo?.node_type === BlockEnum.Tool && !!nodeInfo?.agentLog?.length @@ -82,6 +86,14 @@ const ResultPanel: FC = ({ /> ) } + { + isLoopNode && handleShowLoopResultList && ( + + ) + } { isRetryNode && onShowRetryDetail && ( void + loopResultList?: NodeTracing[][] + loopResultDurationMap?: LoopDurationMap + agentOrToolLogItemStack?: AgentLogItemWithChildren[] agentOrToolLogListMap?: Record handleShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void @@ -31,6 +38,11 @@ const SpecialResultPanel = ({ iterationResultList, iterationResultDurationMap, + showLoopingDetail, + setShowLoopingDetailFalse, + loopResultList, + loopResultDurationMap, + agentOrToolLogItemStack, agentOrToolLogListMap, handleShowAgentOrToolLog, @@ -57,6 +69,15 @@ const SpecialResultPanel = ({ /> ) } + { + showLoopingDetail && !!loopResultList?.length && setShowLoopingDetailFalse && ( + + ) + } { !!agentOrToolLogItemStack?.length && agentOrToolLogListMap && handleShowAgentOrToolLog && ( = ({ = ({ iterationResultList={iterationResultList} iterationResultDurationMap={iterationResultDurationMap} + showLoopingDetail={showLoopingDetail} + setShowLoopingDetailFalse={setShowLoopingDetailFalse} + loopResultList={loopResultList} + loopResultDurationMap={loopResultDurationMap} + agentOrToolLogItemStack={agentOrToolLogItemStack} agentOrToolLogListMap={agentOrToolLogListMap} handleShowAgentOrToolLog={handleShowAgentOrToolLog} diff --git a/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.spec.ts b/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.spec.ts index 18758404ff0f54..d5d4e8a4e4fa70 100644 --- a/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.spec.ts @@ -31,6 +31,16 @@ describe('parseDSL', () => { ]) }) + it('should parse loop nodes correctly', () => { + const dsl = '(loop, loopNode, plainNode1 -> plainNode2)' + const result = parseDSL(dsl) + expect(result).toEqual([ + { id: 'loopNode', node_id: 'loopNode', title: 'loopNode', node_type: 'loop', execution_metadata: {}, status: 'succeeded' }, + { id: 'plainNode1', node_id: 'plainNode1', title: 'plainNode1', execution_metadata: { loop_id: 'loopNode', loop_index: 0 }, status: 'succeeded' }, + { id: 'plainNode2', node_id: 'plainNode2', title: 'plainNode2', execution_metadata: { loop_id: 'loopNode', loop_index: 0 }, status: 'succeeded' }, + ]) + }) + it('should parse parallel nodes correctly', () => { const dsl = '(parallel, parallelNode, nodeA, nodeB -> nodeC)' const result = parseDSL(dsl) diff --git a/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.ts b/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.ts index 5c0b7b64d93843..741fa08ebf83d9 100644 --- a/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.ts +++ b/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.ts @@ -46,10 +46,11 @@ function parseTopLevelFlow(dsl: string): string[] { * Parses a single node string. * If the node is complex (e.g., has parentheses), it extracts the node type, node ID, and parameters. * @param nodeStr - The node string to parse. - * @param parentIterationOrLoopId - The ID of the parent iteration node or loop node (if applicable). + * @param parentIterationId - The ID of the parent iteration node (if applicable). + * @param parentLoopId - The ID of the parent loop node (if applicable). * @returns A parsed node object. */ -function parseNode(nodeStr: string, parentIterationOrLoopId?: string): Node { +function parseNode(nodeStr: string, parentIterationId?: string, parentLoopId?: string): Node { // Check if the node is a complex node if (nodeStr.startsWith('(') && nodeStr.endsWith(')')) { const innerContent = nodeStr.slice(1, -1).trim() // Remove outer parentheses @@ -75,25 +76,33 @@ function parseNode(nodeStr: string, parentIterationOrLoopId?: string): Node { // Extract nodeType, nodeId, and params const [nodeType, nodeId, ...paramsRaw] = parts - const params = parseParams(paramsRaw, nodeType === 'iteration' ? nodeId.trim() : parentIterationOrLoopId) + const params = parseParams(paramsRaw, nodeType === 'iteration' ? nodeId.trim() : parentIterationId, nodeType === 'loop' ? nodeId.trim() : parentLoopId) const complexNode = { nodeType: nodeType.trim(), nodeId: nodeId.trim(), params, } - if (parentIterationOrLoopId) { - (complexNode as any).iterationId = parentIterationOrLoopId; + if (parentIterationId) { + (complexNode as any).iterationId = parentIterationId; (complexNode as any).iterationIndex = 0 // Fixed as 0 } + if (parentLoopId) { + (complexNode as any).loopId = parentLoopId; + (complexNode as any).loopIndex = 0 // Fixed as 0 + } return complexNode } // If it's not a complex node, treat it as a plain node const plainNode: NodePlain = { nodeType: 'plain', nodeId: nodeStr.trim() } - if (parentIterationOrLoopId) { - plainNode.iterationId = parentIterationOrLoopId + if (parentIterationId) { + plainNode.iterationId = parentIterationId plainNode.iterationIndex = 0 // Fixed as 0 } + if (parentLoopId) { + plainNode.loopId = parentLoopId + plainNode.loopIndex = 0 // Fixed as 0 + } return plainNode } @@ -102,18 +111,19 @@ function parseNode(nodeStr: string, parentIterationOrLoopId?: string): Node { * Supports nested flows and complex sub-nodes. * Adds iteration-specific metadata recursively. * @param paramParts - The parameters string split by commas. - * @param parentIterationOrLoopId - The ID of the iteration node or loop node, if applicable. + * @param parentIterationId - The ID of the parent iteration node (if applicable). + * @param parentLoopId - The ID of the parent loop node (if applicable). * @returns An array of parsed parameters (plain nodes, nested nodes, or flows). */ -function parseParams(paramParts: string[], parentIterationOrLoopId?: string): (Node | Node[] | number)[] { +function parseParams(paramParts: string[], parentIteration?: string, parentLoopId?: string): (Node | Node[] | number)[] { return paramParts.map((part) => { if (part.includes('->')) { // Parse as a flow and return an array of nodes - return parseTopLevelFlow(part).map(node => parseNode(node, parentIterationOrLoopId)) + return parseTopLevelFlow(part).map(node => parseNode(node, parentIteration || undefined, parentLoopId || undefined)) } else if (part.startsWith('(')) { // Parse as a nested complex node - return parseNode(part, parentIterationOrLoopId) + return parseNode(part, parentIteration || undefined, parentLoopId || undefined) } else if (!Number.isNaN(Number(part.trim()))) { // Parse as a numeric parameter @@ -121,7 +131,7 @@ function parseParams(paramParts: string[], parentIterationOrLoopId?: string): (N } else { // Parse as a plain node - return parseNode(part, parentIterationOrLoopId) + return parseNode(part, parentIteration || undefined, parentLoopId || undefined) } }) } @@ -220,7 +230,6 @@ function convertIterationNode(node: Node): NodeData[] { return result } - /** * Converts an loop node to node data. */ diff --git a/web/app/components/workflow/run/utils/format-log/index.ts b/web/app/components/workflow/run/utils/format-log/index.ts index 4e8f6c33c23381..2a92520952875e 100644 --- a/web/app/components/workflow/run/utils/format-log/index.ts +++ b/web/app/components/workflow/run/utils/format-log/index.ts @@ -1,5 +1,6 @@ import type { NodeTracing } from '@/types/workflow' import formatIterationNode from './iteration' +import formatLoopNode from './loop' import formatParallelNode from './parallel' import formatRetryNode from './retry' import formatAgentNode from './agent' @@ -15,9 +16,10 @@ const formatToTracingNodeList = (list: NodeTracing[], t: any) => { const formattedRetryList = formatRetryNode(formattedAgentList) // retry one node // would change the structure of the list. Iteration and parallel can include each other. const formattedIterationList = formatIterationNode(formattedRetryList, t) + const formattedLoopList = formatLoopNode(formattedRetryList, t) const formattedParallelList = formatParallelNode(formattedIterationList, t) - const result = formattedParallelList + const result = allItems[0].iteration_id ? formattedParallelList : formattedLoopList // console.log(allItems) // console.log(result) diff --git a/web/app/components/workflow/run/utils/format-log/retry/index.ts b/web/app/components/workflow/run/utils/format-log/retry/index.ts index b8dd0bfa80613e..3a424e0626f920 100644 --- a/web/app/components/workflow/run/utils/format-log/retry/index.ts +++ b/web/app/components/workflow/run/utils/format-log/retry/index.ts @@ -12,6 +12,7 @@ const format = (list: NodeTracing[]): NodeTracing[] => { }).map((item) => { const { execution_metadata } = item const isInIteration = !!execution_metadata?.iteration_id + const isInLoop = !!execution_metadata?.loop_id const nodeId = item.node_id const isRetryBelongNode = retryNodeIds.includes(nodeId) @@ -22,8 +23,14 @@ const format = (list: NodeTracing[]): NodeTracing[] => { if (!isInIteration) return node.node_id === nodeId + if (!isInLoop) + return node.node_id === nodeId + // retry node in iteration - return node.node_id === nodeId && node.execution_metadata?.iteration_index === execution_metadata?.iteration_index + return node.node_id === nodeId && ( + isInIteration ? node.execution_metadata?.iteration_index === execution_metadata?.iteration_index + : isInLoop ? node.execution_metadata?.loop_index === execution_metadata?.loop_index + : false) }), } } diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index c4ffb31bc1cabb..9243beb6ddb2dd 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -83,7 +83,6 @@ export type CommonNodeType = { height?: number _loopLength?: number _loopIndex?: number - isLoopStart?: boolean isInLoop?: boolean loop_id?: string error_strategy?: ErrorHandleTypeEnum @@ -101,10 +100,10 @@ export type CommonEdgeType = { _waitingRun?: boolean isInIteration?: boolean iteration_id?: string - sourceType: BlockEnum - targetType: BlockEnum isInLoop?: boolean loop_id?: string + sourceType: BlockEnum + targetType: BlockEnum } export type Node = ReactFlowNode> diff --git a/web/app/components/workflow/utils.ts b/web/app/components/workflow/utils.ts index 99941c030f41a6..bc9317785a5a46 100644 --- a/web/app/components/workflow/utils.ts +++ b/web/app/components/workflow/utils.ts @@ -360,10 +360,13 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { iterationNodeData.parallel_nums = iterationNodeData.parallel_nums || 10 iterationNodeData.error_handle_mode = iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated } - + // TODO: loop error handle mode - if (node.data.type === BlockEnum.Loop) - node.data._children = iterationOrLoopNodeMap[node.id] || [] + if (node.data.type === BlockEnum.Loop) { + const loopNodeData = node.data as LoopNodeType + loopNodeData._children = iterationOrLoopNodeMap[node.id] || [] + loopNodeData.error_handle_mode = loopNodeData.error_handle_mode || ErrorHandleMode.Terminated + } // legacy provider handle if (node.data.type === BlockEnum.LLM) @@ -572,15 +575,18 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => { if (outgoers.length) { outgoers.forEach((outgoer) => { list.push(outgoer) + if (outgoer.data.type === BlockEnum.Iteration) list.push(...nodes.filter(node => node.parentId === outgoer.id)) if (outgoer.data.type === BlockEnum.Loop) list.push(...nodes.filter(node => node.parentId === outgoer.id)) + traverse(outgoer, depth + 1) }) } else { list.push(root) + if (root.data.type === BlockEnum.Iteration) list.push(...nodes.filter(node => node.parentId === root.id)) if (root.data.type === BlockEnum.Loop) diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 889b64d8d6ab25..fa731cbc530068 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -671,6 +671,12 @@ const translation = { breakCondition: '循环终止条件', loopMaxCount: '最大循环次数', loopMaxCountError: '请输入正确的 最大循环次数,范围为 1 到 100', + errorResponseMethod: '错误响应方法', + ErrorMethod: { + operationTerminated: '错误时终止', + continueOnError: '忽略错误并继续', + removeAbnormalOutput: '移除错误输出', + }, }, note: { addNote: '添加注释', From bf2e090b2ec80e39ace7d78111664585603b4886 Mon Sep 17 00:00:00 2001 From: arkunzz <4873204@qq.com> Date: Fri, 24 Jan 2025 18:32:11 +0800 Subject: [PATCH 04/15] feat: Loop node --- api/controllers/console/app/workflow.py | 81 ++++ .../app/apps/advanced_chat/app_generator.py | 57 +++ api/core/app/apps/advanced_chat/app_runner.py | 7 + .../advanced_chat/generate_task_pipeline.py | 54 ++- api/core/app/apps/workflow/app_generator.py | 56 +++ api/core/app/apps/workflow/app_runner.py | 7 + .../apps/workflow/generate_task_pipeline.py | 57 ++- api/core/app/apps/workflow_app_runner.py | 196 +++++++++- api/core/app/entities/app_invoke_entities.py | 20 + api/core/app/entities/queue_entities.py | 137 +++++++ api/core/app/entities/task_entities.py | 98 +++++ .../task_pipeline/workflow_cycle_manage.py | 94 ++++- .../callbacks/workflow_logging_callback.py | 41 ++ api/core/workflow/entities/node_entities.py | 3 + .../workflow/entities/workflow_entities.py | 5 +- .../workflow/graph_engine/entities/event.py | 63 ++- .../workflow/graph_engine/graph_engine.py | 7 + .../answer/answer_stream_generate_router.py | 1 + .../nodes/answer/answer_stream_processor.py | 2 +- api/core/workflow/nodes/base/__init__.py | 4 +- api/core/workflow/nodes/base/entities.py | 15 + .../nodes/end/end_stream_processor.py | 2 +- api/core/workflow/nodes/enums.py | 1 + api/core/workflow/nodes/loop/__init__.py | 5 + api/core/workflow/nodes/loop/entities.py | 45 ++- api/core/workflow/nodes/loop/loop_node.py | 362 +++++++++++++++++- .../workflow/nodes/loop/loop_start_node.py | 36 ++ api/core/workflow/nodes/node_mapping.py | 9 + api/services/app_generate_service.py | 19 + 29 files changed, 1450 insertions(+), 34 deletions(-) create mode 100644 api/core/workflow/nodes/loop/loop_start_node.py diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 0cc5f31ddddfde..480c54bbb64dea 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -246,6 +246,80 @@ def post(self, app_model: App, node_id: str): 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,13 @@ def get(self, app_model: App): api.add_resource( WorkflowDraftRunIterationNodeApi, "/apps//workflows/draft/iteration/nodes//run" ) +api.add_resource( + AdvancedChatDraftRunLoopNodeApi, + "/apps//advanced-chat/workflows/draft/loop/nodes//run", +) +api.add_resource( + WorkflowDraftRunLoopNodeApi, "/apps//workflows/draft/loop/nodes//run" +) api.add_resource(PublishedWorkflowApi, "/apps//workflows/publish") api.add_resource(PublishedAllWorkflowApi, "/apps//workflows") api.add_resource(DefaultBlockConfigsApi, "/apps//workflows/default-workflow-block-configs") diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index 9d7b91b3f0a4eb..96c0420caf232a 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -225,6 +225,63 @@ def single_iteration_generate( stream=streaming, ) + def single_loop_generate( + self, + app_model: App, + workflow: Workflow, + node_id: str, + user: Account | EndUser, + args: Mapping, + streaming: bool = True, + ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], Any, None]: + """ + Generate App response. + + :param app_model: App + :param workflow: Workflow + :param user: account or end user + :param args: request args + :param invoke_from: invoke from source + :param stream: is stream + """ + if not node_id: + raise ValueError("node_id is required") + + if args.get("inputs") is None: + raise ValueError("inputs is required") + + # convert to app config + app_config = AdvancedChatAppConfigManager.get_app_config(app_model=app_model, workflow=workflow) + + # init application generate entity + application_generate_entity = AdvancedChatAppGenerateEntity( + task_id=str(uuid.uuid4()), + app_config=app_config, + conversation_id=None, + inputs={}, + query="", + files=[], + user_id=user.id, + stream=streaming, + invoke_from=InvokeFrom.DEBUGGER, + extras={"auto_generate_conversation_name": False}, + single_loop_run=AdvancedChatAppGenerateEntity.SingleLoopRunEntity( + node_id=node_id, inputs=args["inputs"] + ), + ) + contexts.tenant_id.set(application_generate_entity.app_config.tenant_id) + contexts.plugin_tool_providers.set({}) + contexts.plugin_tool_providers_lock.set(threading.Lock()) + + return self._generate( + workflow=workflow, + user=user, + invoke_from=InvokeFrom.DEBUGGER, + application_generate_entity=application_generate_entity, + conversation=None, + stream=streaming, + ) + def _generate( self, *, diff --git a/api/core/app/apps/advanced_chat/app_runner.py b/api/core/app/apps/advanced_chat/app_runner.py index 0e918e4929db1c..c83e06bf154634 100644 --- a/api/core/app/apps/advanced_chat/app_runner.py +++ b/api/core/app/apps/advanced_chat/app_runner.py @@ -79,6 +79,13 @@ def run(self) -> None: node_id=self.application_generate_entity.single_iteration_run.node_id, user_inputs=dict(self.application_generate_entity.single_iteration_run.inputs), ) + elif self.application_generate_entity.single_loop_run: + # if only single loop run is requested + graph, variable_pool = self._get_graph_and_variable_pool_of_single_loop( + workflow=workflow, + node_id=self.application_generate_entity.single_loop_run.node_id, + user_inputs=dict(self.application_generate_entity.single_loop_run.inputs), + ) else: inputs = self.application_generate_entity.inputs query = self.application_generate_entity.query diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index 3d6ba5ce374408..583e055b8d4208 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -23,10 +23,14 @@ QueueIterationCompletedEvent, QueueIterationNextEvent, QueueIterationStartEvent, + QueueLoopCompletedEvent, + QueueLoopNextEvent, + QueueLoopStartEvent, QueueMessageReplaceEvent, QueueNodeExceptionEvent, QueueNodeFailedEvent, QueueNodeInIterationFailedEvent, + QueueNodeInLoopFailedEvent, QueueNodeRetryEvent, QueueNodeStartedEvent, QueueNodeSucceededEvent, @@ -372,7 +376,7 @@ def _process_stream_response( 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 +476,54 @@ def _process_stream_response( ) yield iter_finish_resp + elif isinstance(event, QueueLoopStartEvent): + if not self._workflow_run_id: + raise ValueError("workflow run not initialized.") + + with Session(db.engine, expire_on_commit=False) as session: + workflow_run = self._workflow_cycle_manager._get_workflow_run( + session=session, workflow_run_id=self._workflow_run_id + ) + loop_start_resp = self._workflow_cycle_manager._workflow_loop_start_to_stream_response( + session=session, + task_id=self._application_generate_entity.task_id, + workflow_run=workflow_run, + event=event, + ) + + yield loop_start_resp + elif isinstance(event, QueueLoopNextEvent): + if not self._workflow_run_id: + raise ValueError("workflow run not initialized.") + + with Session(db.engine, expire_on_commit=False) as session: + workflow_run = self._workflow_cycle_manager._get_workflow_run( + session=session, workflow_run_id=self._workflow_run_id + ) + loop_next_resp = self._workflow_cycle_manager._workflow_loop_next_to_stream_response( + session=session, + task_id=self._application_generate_entity.task_id, + workflow_run=workflow_run, + event=event, + ) + + yield loop_next_resp + elif isinstance(event, QueueLoopCompletedEvent): + if not self._workflow_run_id: + raise ValueError("workflow run not initialized.") + + with Session(db.engine, expire_on_commit=False) as session: + workflow_run = self._workflow_cycle_manager._get_workflow_run( + session=session, workflow_run_id=self._workflow_run_id + ) + loop_finish_resp = self._workflow_cycle_manager._workflow_loop_completed_to_stream_response( + session=session, + task_id=self._application_generate_entity.task_id, + workflow_run=workflow_run, + event=event, + ) + + yield loop_finish_resp elif isinstance(event, QueueWorkflowSucceededEvent): if not self._workflow_run_id: raise ValueError("workflow run not initialized.") diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index f13cb530093ee2..9293cb9b449ce6 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -250,6 +250,62 @@ def single_iteration_generate( streaming=streaming, ) + def single_loop_generate( + self, + app_model: App, + workflow: Workflow, + node_id: str, + user: Account | EndUser, + args: Mapping[str, Any], + streaming: bool = True, + ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], None, None]: + """ + Generate App response. + + :param app_model: App + :param workflow: Workflow + :param user: account or end user + :param args: request args + :param invoke_from: invoke from source + :param stream: is stream + """ + if not node_id: + raise ValueError("node_id is required") + + if args.get("inputs") is None: + raise ValueError("inputs is required") + + # convert to app config + app_config = WorkflowAppConfigManager.get_app_config(app_model=app_model, workflow=workflow) + + # init application generate entity + application_generate_entity = WorkflowAppGenerateEntity( + task_id=str(uuid.uuid4()), + app_config=app_config, + inputs={}, + files=[], + user_id=user.id, + stream=streaming, + invoke_from=InvokeFrom.DEBUGGER, + extras={"auto_generate_conversation_name": False}, + single_loop_run=WorkflowAppGenerateEntity.SingleLoopRunEntity( + node_id=node_id, inputs=args["inputs"] + ), + workflow_run_id=str(uuid.uuid4()), + ) + contexts.tenant_id.set(application_generate_entity.app_config.tenant_id) + contexts.plugin_tool_providers.set({}) + contexts.plugin_tool_providers_lock.set(threading.Lock()) + + return self._generate( + app_model=app_model, + workflow=workflow, + user=user, + invoke_from=InvokeFrom.DEBUGGER, + application_generate_entity=application_generate_entity, + streaming=streaming, + ) + def _generate_worker( self, flask_app: Flask, diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py index faefcb0ed50629..7bbf3612c95312 100644 --- a/api/core/app/apps/workflow/app_runner.py +++ b/api/core/app/apps/workflow/app_runner.py @@ -81,6 +81,13 @@ def run(self) -> None: node_id=self.application_generate_entity.single_iteration_run.node_id, user_inputs=self.application_generate_entity.single_iteration_run.inputs, ) + elif self.application_generate_entity.single_loop_run: + # if only single loop run is requested + graph, variable_pool = self._get_graph_and_variable_pool_of_single_loop( + workflow=workflow, + node_id=self.application_generate_entity.single_loop_run.node_id, + user_inputs=self.application_generate_entity.single_loop_run.inputs, + ) else: inputs = self.application_generate_entity.inputs files = self.application_generate_entity.files diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py index 9837cf9975cec5..5a49bb3b20832e 100644 --- a/api/core/app/apps/workflow/generate_task_pipeline.py +++ b/api/core/app/apps/workflow/generate_task_pipeline.py @@ -18,9 +18,13 @@ QueueIterationCompletedEvent, QueueIterationNextEvent, QueueIterationStartEvent, + QueueLoopCompletedEvent, + QueueLoopNextEvent, + QueueLoopStartEvent, QueueNodeExceptionEvent, QueueNodeFailedEvent, QueueNodeInIterationFailedEvent, + QueueNodeInLoopFailedEvent, QueueNodeRetryEvent, QueueNodeStartedEvent, QueueNodeSucceededEvent, @@ -323,7 +327,7 @@ def _process_stream_response( 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 +433,57 @@ def _process_stream_response( yield iter_finish_resp + elif isinstance(event, QueueLoopStartEvent): + if not self._workflow_run_id: + raise ValueError("workflow run not initialized.") + + with Session(db.engine, expire_on_commit=False) as session: + workflow_run = self._workflow_cycle_manager._get_workflow_run( + session=session, workflow_run_id=self._workflow_run_id + ) + loop_start_resp = self._workflow_cycle_manager._workflow_loop_start_to_stream_response( + session=session, + task_id=self._application_generate_entity.task_id, + workflow_run=workflow_run, + event=event, + ) + + yield loop_start_resp + + elif isinstance(event, QueueLoopNextEvent): + if not self._workflow_run_id: + raise ValueError("workflow run not initialized.") + + with Session(db.engine, expire_on_commit=False) as session: + workflow_run = self._workflow_cycle_manager._get_workflow_run( + session=session, workflow_run_id=self._workflow_run_id + ) + loop_next_resp = self._workflow_cycle_manager._workflow_loop_next_to_stream_response( + session=session, + task_id=self._application_generate_entity.task_id, + workflow_run=workflow_run, + event=event, + ) + + yield loop_next_resp + + elif isinstance(event, QueueLoopCompletedEvent): + if not self._workflow_run_id: + raise ValueError("workflow run not initialized.") + + with Session(db.engine, expire_on_commit=False) as session: + workflow_run = self._workflow_cycle_manager._get_workflow_run( + session=session, workflow_run_id=self._workflow_run_id + ) + loop_finish_resp = self._workflow_cycle_manager._workflow_loop_completed_to_stream_response( + session=session, + task_id=self._application_generate_entity.task_id, + workflow_run=workflow_run, + event=event, + ) + + yield loop_finish_resp + elif isinstance(event, QueueWorkflowSucceededEvent): if not self._workflow_run_id: raise ValueError("workflow run not initialized.") diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 6d3b8a996b30f1..46da5f821f1bd1 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -9,9 +9,13 @@ QueueIterationCompletedEvent, QueueIterationNextEvent, QueueIterationStartEvent, + QueueLoopCompletedEvent, + QueueLoopNextEvent, + QueueLoopStartEvent, QueueNodeExceptionEvent, QueueNodeFailedEvent, QueueNodeInIterationFailedEvent, + QueueNodeInLoopFailedEvent, QueueNodeRetryEvent, QueueNodeStartedEvent, QueueNodeSucceededEvent, @@ -39,6 +43,11 @@ IterationRunStartedEvent, IterationRunSucceededEvent, NodeInIterationFailedEvent, + LoopRunFailedEvent, + LoopRunNextEvent, + LoopRunStartedEvent, + LoopRunSucceededEvent, + NodeInLoopFailedEvent, NodeRunExceptionEvent, NodeRunFailedEvent, NodeRunRetrieverResourceEvent, @@ -173,6 +182,96 @@ def _get_graph_and_variable_pool_of_single_iteration( 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 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent) 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 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent) 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 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent) 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 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent) 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 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent) 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 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent) 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 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent) 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 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent) 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 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent) 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 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent) error=event.error if isinstance(event, IterationRunFailedEvent) else None, ) ) + elif isinstance(event, LoopRunStartedEvent): + self._publish_event( + QueueLoopStartEvent( + node_execution_id=event.loop_id, + node_id=event.loop_node_id, + node_type=event.loop_node_type, + node_data=event.loop_node_data, + parallel_id=event.parallel_id, + parallel_start_node_id=event.parallel_start_node_id, + parent_parallel_id=event.parent_parallel_id, + parent_parallel_start_node_id=event.parent_parallel_start_node_id, + start_at=event.start_at, + node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, + inputs=event.inputs, + predecessor_node_id=event.predecessor_node_id, + metadata=event.metadata, + ) + ) + elif isinstance(event, LoopRunNextEvent): + self._publish_event( + QueueLoopNextEvent( + node_execution_id=event.loop_id, + node_id=event.loop_node_id, + node_type=event.loop_node_type, + node_data=event.loop_node_data, + parallel_id=event.parallel_id, + parallel_start_node_id=event.parallel_start_node_id, + parent_parallel_id=event.parent_parallel_id, + parent_parallel_start_node_id=event.parent_parallel_start_node_id, + index=event.index, + node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, + output=event.pre_loop_output, + parallel_mode_run_id=event.parallel_mode_run_id, + duration=event.duration, + ) + ) + elif isinstance(event, (LoopRunSucceededEvent | LoopRunFailedEvent)): + self._publish_event( + QueueLoopCompletedEvent( + node_execution_id=event.loop_id, + node_id=event.loop_node_id, + node_type=event.loop_node_type, + node_data=event.loop_node_data, + parallel_id=event.parallel_id, + parallel_start_node_id=event.parallel_start_node_id, + parent_parallel_id=event.parent_parallel_id, + parent_parallel_start_node_id=event.parent_parallel_start_node_id, + start_at=event.start_at, + node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, + inputs=event.inputs, + outputs=event.outputs, + metadata=event.metadata, + steps=event.steps, + error=event.error if isinstance(event, LoopRunFailedEvent) else None, + ) + ) def get_workflow(self, app_model: App, workflow_id: str) -> Optional[Workflow]: """ diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py index df5da619274174..59551431218da4 100644 --- a/api/core/app/entities/app_invoke_entities.py +++ b/api/core/app/entities/app_invoke_entities.py @@ -187,6 +187,16 @@ class SingleIterationRunEntity(BaseModel): single_iteration_run: Optional[SingleIterationRunEntity] = None + class SingleLoopRunEntity(BaseModel): + """ + Single Loop Run Entity. + """ + + node_id: str + inputs: dict + + single_loop_run: Optional[SingleLoopRunEntity] = None + class WorkflowAppGenerateEntity(AppGenerateEntity): """ @@ -206,3 +216,13 @@ class SingleIterationRunEntity(BaseModel): inputs: dict single_iteration_run: Optional[SingleIterationRunEntity] = None + + class SingleLoopRunEntity(BaseModel): + """ + Single Loop Run Entity. + """ + + node_id: str + inputs: dict + + single_loop_run: Optional[SingleLoopRunEntity] = None diff --git a/api/core/app/entities/queue_entities.py b/api/core/app/entities/queue_entities.py index f1cc3ac2216b58..52641859ed87bf 100644 --- a/api/core/app/entities/queue_entities.py +++ b/api/core/app/entities/queue_entities.py @@ -30,6 +30,9 @@ class QueueEvent(StrEnum): ITERATION_START = "iteration_start" ITERATION_NEXT = "iteration_next" ITERATION_COMPLETED = "iteration_completed" + LOOP_START = "loop_start" + LOOP_NEXT = "loop_next" + LOOP_COMPLETED = "loop_completed" NODE_STARTED = "node_started" NODE_SUCCEEDED = "node_succeeded" NODE_FAILED = "node_failed" @@ -149,6 +152,89 @@ class QueueIterationCompletedEvent(AppQueueEvent): error: Optional[str] = None +class QueueLoopStartEvent(AppQueueEvent): + """ + QueueLoopStartEvent entity + """ + + event: QueueEvent = QueueEvent.LOOP_START + node_execution_id: str + node_id: str + node_type: NodeType + node_data: BaseNodeData + parallel_id: Optional[str] = None + """parallel id if node is in parallel""" + parallel_start_node_id: Optional[str] = None + """parallel start node id if node is in parallel""" + parent_parallel_id: Optional[str] = None + """parent parallel id if node is in parallel""" + parent_parallel_start_node_id: Optional[str] = None + """parent parallel start node id if node is in parallel""" + start_at: datetime + + node_run_index: int + inputs: Optional[Mapping[str, Any]] = None + predecessor_node_id: Optional[str] = None + metadata: Optional[Mapping[str, Any]] = None + + +class QueueLoopNextEvent(AppQueueEvent): + """ + QueueLoopNextEvent entity + """ + + event: QueueEvent = QueueEvent.LOOP_NEXT + + index: int + node_execution_id: str + node_id: str + node_type: NodeType + node_data: BaseNodeData + parallel_id: Optional[str] = None + """parallel id if node is in parallel""" + parallel_start_node_id: Optional[str] = None + """parallel start node id if node is in parallel""" + parent_parallel_id: Optional[str] = None + """parent parallel id if node is in parallel""" + parent_parallel_start_node_id: Optional[str] = None + """parent parallel start node id if node is in parallel""" + parallel_mode_run_id: Optional[str] = None + """iteratoin run in parallel mode run id""" + node_run_index: int + output: Optional[Any] = None # output for the current loop + duration: Optional[float] = None + + +class QueueLoopCompletedEvent(AppQueueEvent): + """ + QueueLoopCompletedEvent entity + """ + + event: QueueEvent = QueueEvent.LOOP_COMPLETED + + node_execution_id: str + node_id: str + node_type: NodeType + node_data: BaseNodeData + parallel_id: Optional[str] = None + """parallel id if node is in parallel""" + parallel_start_node_id: Optional[str] = None + """parallel start node id if node is in parallel""" + parent_parallel_id: Optional[str] = None + """parent parallel id if node is in parallel""" + parent_parallel_start_node_id: Optional[str] = None + """parent parallel start node id if node is in parallel""" + start_at: datetime + + node_run_index: int + inputs: Optional[Mapping[str, Any]] = None + outputs: Optional[Mapping[str, Any]] = None + metadata: Optional[Mapping[str, Any]] = None + steps: int = 0 + + error: Optional[str] = None + + class QueueTextChunkEvent(AppQueueEvent): """ QueueTextChunkEvent entity @@ -160,6 +246,8 @@ class QueueTextChunkEvent(AppQueueEvent): """from variable selector""" in_iteration_id: Optional[str] = None """iteration id if node is in iteration""" + in_loop_id: Optional[str] = None + """loop id if node is in loop""" class QueueAgentMessageEvent(AppQueueEvent): @@ -189,6 +277,8 @@ class QueueRetrieverResourcesEvent(AppQueueEvent): retriever_resources: list[dict] in_iteration_id: Optional[str] = None """iteration id if node is in iteration""" + in_loop_id: Optional[str] = None + """loop id if node is in loop""" class QueueAnnotationReplyEvent(AppQueueEvent): @@ -278,6 +368,8 @@ class QueueNodeStartedEvent(AppQueueEvent): """parent parallel start node id if node is in parallel""" in_iteration_id: Optional[str] = None """iteration id if node is in iteration""" + in_loop_id: Optional[str] = None + """loop id if node is in loop""" start_at: datetime parallel_mode_run_id: Optional[str] = None """iteratoin run in parallel mode run id""" @@ -305,6 +397,8 @@ class QueueNodeSucceededEvent(AppQueueEvent): """parent parallel start node id if node is in parallel""" in_iteration_id: Optional[str] = None """iteration id if node is in iteration""" + in_loop_id: Optional[str] = None + """loop id if node is in loop""" start_at: datetime inputs: Optional[Mapping[str, Any]] = None @@ -315,6 +409,8 @@ class QueueNodeSucceededEvent(AppQueueEvent): error: Optional[str] = None """single iteration duration map""" iteration_duration_map: Optional[dict[str, float]] = None + """single loop duration map""" + loop_duration_map: Optional[dict[str, float]] = None class QueueAgentLogEvent(AppQueueEvent): @@ -378,6 +474,37 @@ class QueueNodeInIterationFailedEvent(AppQueueEvent): 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_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 QueueNodeExceptionEvent(AppQueueEvent): """ QueueNodeExceptionEvent entity @@ -399,6 +526,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 +559,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 +680,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 +699,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 +718,6 @@ class QueueParallelBranchRunFailedEvent(AppQueueEvent): """parent parallel start node id if node is in parallel""" in_iteration_id: Optional[str] = None """iteration id if node is in iteration""" + in_loop_id: Optional[str] = None + """loop id if node is in loop""" error: str diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index e64bd416e0cf9b..0708986a651b2b 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -59,6 +59,9 @@ class StreamEvent(Enum): ITERATION_STARTED = "iteration_started" ITERATION_NEXT = "iteration_next" ITERATION_COMPLETED = "iteration_completed" + LOOP_STARTED = "loop_started" + LOOP_NEXT = "loop_next" + LOOP_COMPLETED = "loop_completed" TEXT_CHUNK = "text_chunk" TEXT_REPLACE = "text_replace" AGENT_LOG = "agent_log" @@ -248,6 +251,7 @@ class Data(BaseModel): 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 @@ def to_ignore_detail_dict(self): "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 Data(BaseModel): 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 @@ def to_ignore_detail_dict(self): "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 Data(BaseModel): 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 @@ def to_ignore_detail_dict(self): "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 Data(BaseModel): 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 Data(BaseModel): 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 Data(BaseModel): data: Data +class LoopNodeStartStreamResponse(StreamResponse): + """ + NodeStartStreamResponse entity + """ + + class Data(BaseModel): + """ + Data entity + """ + + id: str + node_id: str + node_type: str + title: str + created_at: int + extras: dict = {} + metadata: Mapping = {} + inputs: Mapping = {} + parallel_id: Optional[str] = None + parallel_start_node_id: Optional[str] = None + + event: StreamEvent = StreamEvent.LOOP_STARTED + workflow_run_id: str + data: Data + + +class LoopNodeNextStreamResponse(StreamResponse): + """ + NodeStartStreamResponse entity + """ + + class Data(BaseModel): + """ + Data entity + """ + + id: str + node_id: str + node_type: str + title: str + index: int + created_at: int + pre_loop_output: Optional[Any] = None + extras: dict = {} + parallel_id: Optional[str] = None + parallel_start_node_id: Optional[str] = None + parallel_mode_run_id: Optional[str] = None + duration: Optional[float] = None + + event: StreamEvent = StreamEvent.LOOP_NEXT + workflow_run_id: str + data: Data + + +class LoopNodeCompletedStreamResponse(StreamResponse): + """ + NodeCompletedStreamResponse entity + """ + + class Data(BaseModel): + """ + Data entity + """ + + id: str + node_id: str + node_type: str + title: str + outputs: Optional[Mapping] = None + created_at: int + extras: Optional[dict] = None + inputs: Optional[Mapping] = None + status: WorkflowNodeExecutionStatus + error: Optional[str] = None + elapsed_time: float + total_tokens: int + execution_metadata: Optional[Mapping] = None + finished_at: int + steps: int + parallel_id: Optional[str] = None + parallel_start_node_id: Optional[str] = None + + event: StreamEvent = StreamEvent.LOOP_COMPLETED + workflow_run_id: str + data: Data + + class TextChunkStreamResponse(StreamResponse): """ TextChunkStreamResponse entity diff --git a/api/core/app/task_pipeline/workflow_cycle_manage.py b/api/core/app/task_pipeline/workflow_cycle_manage.py index 0fff343eb94d43..5a6c1a2b7f4e4d 100644 --- a/api/core/app/task_pipeline/workflow_cycle_manage.py +++ b/api/core/app/task_pipeline/workflow_cycle_manage.py @@ -14,9 +14,13 @@ QueueIterationCompletedEvent, QueueIterationNextEvent, QueueIterationStartEvent, + QueueLoopCompletedEvent, + QueueLoopNextEvent, + QueueLoopStartEvent, QueueNodeExceptionEvent, QueueNodeFailedEvent, QueueNodeInIterationFailedEvent, + QueueNodeInLoopFailedEvent, QueueNodeRetryEvent, QueueNodeStartedEvent, QueueNodeSucceededEvent, @@ -29,6 +33,9 @@ IterationNodeCompletedStreamResponse, IterationNodeNextStreamResponse, IterationNodeStartStreamResponse, + LoopNodeCompletedStreamResponse, + LoopNodeNextStreamResponse, + LoopNodeStartStreamResponse, NodeFinishStreamResponse, NodeRetryStreamResponse, NodeStartStreamResponse, @@ -304,6 +311,7 @@ def _handle_node_execution_start( { 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,7 @@ def _handle_workflow_node_execution_failed( self, *, session: Session, - event: QueueNodeFailedEvent | QueueNodeInIterationFailedEvent | QueueNodeExceptionEvent, + event: QueueNodeFailedEvent | QueueNodeInIterationFailedEvent | QueueNodeInLoopFailedEvent | QueueNodeExceptionEvent, ) -> WorkflowNodeExecution: """ Workflow node execution failed @@ -396,6 +404,7 @@ def _handle_workflow_node_execution_retried( 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 +549,7 @@ def _workflow_node_start_to_stream_response( 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 +573,7 @@ def _workflow_node_finish_to_stream_response( event: QueueNodeSucceededEvent | QueueNodeFailedEvent | QueueNodeInIterationFailedEvent + | QueueNodeInLoopFailedEvent | QueueNodeExceptionEvent, task_id: str, workflow_node_execution: WorkflowNodeExecution, @@ -601,6 +612,7 @@ def _workflow_node_finish_to_stream_response( 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 +658,7 @@ def _workflow_node_retry_to_stream_response( 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 +677,7 @@ def _workflow_parallel_branch_start_to_stream_response( 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 +701,7 @@ def _workflow_parallel_branch_finished_to_stream_response( 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 +785,83 @@ def _workflow_iteration_completed_to_stream_response( ), ) + def _workflow_loop_start_to_stream_response( + self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueLoopStartEvent + ) -> LoopNodeStartStreamResponse: + # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this + _ = session + return LoopNodeStartStreamResponse( + task_id=task_id, + workflow_run_id=workflow_run.id, + data=LoopNodeStartStreamResponse.Data( + id=event.node_id, + node_id=event.node_id, + node_type=event.node_type.value, + title=event.node_data.title, + created_at=int(time.time()), + extras={}, + inputs=event.inputs or {}, + metadata=event.metadata or {}, + parallel_id=event.parallel_id, + parallel_start_node_id=event.parallel_start_node_id, + ), + ) + + def _workflow_loop_next_to_stream_response( + self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueLoopNextEvent + ) -> LoopNodeNextStreamResponse: + # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this + _ = session + return LoopNodeNextStreamResponse( + task_id=task_id, + workflow_run_id=workflow_run.id, + data=LoopNodeNextStreamResponse.Data( + id=event.node_id, + node_id=event.node_id, + node_type=event.node_type.value, + title=event.node_data.title, + index=event.index, + pre_loop_output=event.output, + created_at=int(time.time()), + extras={}, + parallel_id=event.parallel_id, + parallel_start_node_id=event.parallel_start_node_id, + parallel_mode_run_id=event.parallel_mode_run_id, + duration=event.duration, + ), + ) + + def _workflow_loop_completed_to_stream_response( + self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueLoopCompletedEvent + ) -> LoopNodeCompletedStreamResponse: + # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this + _ = session + return LoopNodeCompletedStreamResponse( + task_id=task_id, + workflow_run_id=workflow_run.id, + data=LoopNodeCompletedStreamResponse.Data( + id=event.node_id, + node_id=event.node_id, + node_type=event.node_type.value, + title=event.node_data.title, + outputs=event.outputs, + created_at=int(time.time()), + extras={}, + inputs=event.inputs or {}, + status=WorkflowNodeExecutionStatus.SUCCEEDED + if event.error is None + else WorkflowNodeExecutionStatus.FAILED, + error=None, + elapsed_time=(datetime.now(UTC).replace(tzinfo=None) - event.start_at).total_seconds(), + total_tokens=event.metadata.get("total_tokens", 0) if event.metadata else 0, + execution_metadata=event.metadata, + finished_at=int(time.time()), + steps=event.steps, + parallel_id=event.parallel_id, + parallel_start_node_id=event.parallel_start_node_id, + ), + ) + def _fetch_files_from_node_outputs(self, outputs_dict: Mapping[str, Any]) -> Sequence[Mapping[str, Any]]: """ Fetch files from node outputs diff --git a/api/core/workflow/callbacks/workflow_logging_callback.py b/api/core/workflow/callbacks/workflow_logging_callback.py index b9c6b35ad3476a..e7a9ce6c7bc6dc 100644 --- a/api/core/workflow/callbacks/workflow_logging_callback.py +++ b/api/core/workflow/callbacks/workflow_logging_callback.py @@ -11,6 +11,10 @@ IterationRunNextEvent, IterationRunStartedEvent, IterationRunSucceededEvent, + LoopRunFailedEvent, + LoopRunNextEvent, + LoopRunStartedEvent, + LoopRunSucceededEvent, NodeRunFailedEvent, NodeRunStartedEvent, NodeRunStreamChunkEvent, @@ -62,6 +66,12 @@ def on_event(self, event: GraphEngineEvent) -> None: 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 @@ def on_workflow_parallel_started(self, event: ParallelBranchRunStartedEvent) -> 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 @@ def on_workflow_parallel_completed( 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,33 @@ def on_workflow_iteration_completed(self, event: IterationRunSucceededEvent | It ) self.print_text(f"Node ID: {event.iteration_id}", color="blue") + def on_workflow_loop_started(self, event: LoopRunStartedEvent) -> None: + """ + Publish loop started + """ + self.print_text("\n[LoopRunStartedEvent]", color="blue") + self.print_text(f"Loop Node ID: {event.loop_id}", color="blue") + + def on_workflow_loop_next(self, event: LoopRunNextEvent) -> None: + """ + Publish loop next + """ + self.print_text("\n[LoopRunNextEvent]", color="blue") + self.print_text(f"Loop Node ID: {event.loop_id}", color="blue") + self.print_text(f"Loop Index: {event.index}", color="blue") + + def on_workflow_loop_completed(self, event: LoopRunSucceededEvent | LoopRunFailedEvent) -> None: + """ + Publish loop completed + """ + self.print_text( + "\n[LoopRunSucceededEvent]" + if isinstance(event, LoopRunSucceededEvent) + else "\n[LoopRunFailedEvent]", + color="blue", + ) + self.print_text(f"Node ID: {event.loop_id}", color="blue") + def print_text(self, text: str, color: Optional[str] = None, end: str = "\n") -> None: """Print text with highlighting and no end characters.""" text_to_print = self._get_colored_text(text, color) if color else text diff --git a/api/core/workflow/entities/node_entities.py b/api/core/workflow/entities/node_entities.py index 27c0e6702a5107..70d40d87e9b43f 100644 --- a/api/core/workflow/entities/node_entities.py +++ b/api/core/workflow/entities/node_entities.py @@ -20,12 +20,15 @@ class NodeRunMetadataKey(StrEnum): AGENT_LOG = "agent_log" ITERATION_ID = "iteration_id" ITERATION_INDEX = "iteration_index" + LOOP_ID = "loop_id" + LOOP_INDEX = "loop_index" PARALLEL_ID = "parallel_id" PARALLEL_START_NODE_ID = "parallel_start_node_id" PARENT_PARALLEL_ID = "parent_parallel_id" PARENT_PARALLEL_START_NODE_ID = "parent_parallel_start_node_id" PARALLEL_MODE_RUN_ID = "parallel_mode_run_id" ITERATION_DURATION_MAP = "iteration_duration_map" # single iteration duration if iteration node runs + LOOP_DURATION_MAP = "loop_duration_map" # single loop duration if loop node runs ERROR_STRATEGY = "error_strategy" # node in continue on error mode return the field diff --git a/api/core/workflow/entities/workflow_entities.py b/api/core/workflow/entities/workflow_entities.py index da56af1407d94f..1d6d8c96bd51fa 100644 --- a/api/core/workflow/entities/workflow_entities.py +++ b/api/core/workflow/entities/workflow_entities.py @@ -3,7 +3,7 @@ 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, BaseNode, BaseLoopState 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 @@ def __init__( self.workflow_node_steps = 1 self.workflow_node_runs = [] self.current_iteration_state = None + self.current_loop_state = None diff --git a/api/core/workflow/graph_engine/entities/event.py b/api/core/workflow/graph_engine/entities/event.py index 3130bb25da75e7..caadb79cbd2168 100644 --- a/api/core/workflow/graph_engine/entities/event.py +++ b/api/core/workflow/graph_engine/entities/event.py @@ -63,6 +63,8 @@ class BaseNodeEvent(GraphEngineEvent): """parent parallel start node id if node is in parallel""" in_iteration_id: Optional[str] = None """iteration id if node is in iteration""" + in_loop_id: Optional[str] = None + """loop id if node is in loop""" class NodeRunStartedEvent(BaseNodeEvent): @@ -100,6 +102,10 @@ class NodeInIterationFailedEvent(BaseNodeEvent): error: str = Field(..., description="error") +class NodeInLoopFailedEvent(BaseNodeEvent): + error: str = Field(..., description="error") + + class NodeRunRetryEvent(NodeRunStartedEvent): error: str = Field(..., description="error") retry_index: int = Field(..., description="which retry attempt is about to be performed") @@ -122,6 +128,8 @@ class BaseParallelBranchEvent(GraphEngineEvent): """parent parallel start node id if node is in parallel""" in_iteration_id: Optional[str] = None """iteration id if node is in iteration""" + in_loop_id: Optional[str] = None + """loop id if node is in loop""" class ParallelBranchRunStartedEvent(BaseParallelBranchEvent): @@ -189,6 +197,59 @@ class IterationRunFailedEvent(BaseIterationEvent): error: str = Field(..., description="failed reason") +########################################### +# Loop Events +########################################### + + +class BaseLoopEvent(GraphEngineEvent): + loop_id: str = Field(..., description="loop node execution id") + loop_node_id: str = Field(..., description="loop node id") + loop_node_type: NodeType = Field(..., description="node type, loop or loop") + loop_node_data: BaseNodeData = Field(..., description="node data") + parallel_id: Optional[str] = None + """parallel id if node is in parallel""" + parallel_start_node_id: Optional[str] = None + """parallel start node id if node is in parallel""" + parent_parallel_id: Optional[str] = None + """parent parallel id if node is in parallel""" + parent_parallel_start_node_id: Optional[str] = None + """parent parallel start node id if node is in parallel""" + parallel_mode_run_id: Optional[str] = None + """loop run in parallel mode run id""" + + +class LoopRunStartedEvent(BaseLoopEvent): + start_at: datetime = Field(..., description="start at") + inputs: Optional[Mapping[str, Any]] = None + metadata: Optional[Mapping[str, Any]] = None + predecessor_node_id: Optional[str] = None + + +class LoopRunNextEvent(BaseLoopEvent): + index: int = Field(..., description="index") + pre_loop_output: Optional[Any] = None + duration: Optional[float] = None + + +class LoopRunSucceededEvent(BaseLoopEvent): + start_at: datetime = Field(..., description="start at") + inputs: Optional[Mapping[str, Any]] = None + outputs: Optional[Mapping[str, Any]] = None + metadata: Optional[Mapping[str, Any]] = None + steps: int = 0 + loop_duration_map: Optional[dict[str, float]] = None + + +class LoopRunFailedEvent(BaseLoopEvent): + start_at: datetime = Field(..., description="start at") + inputs: Optional[Mapping[str, Any]] = None + outputs: Optional[Mapping[str, Any]] = None + metadata: Optional[Mapping[str, Any]] = None + steps: int = 0 + error: str = Field(..., description="failed reason") + + ########################################### # Agent Events ########################################### @@ -209,4 +270,4 @@ class AgentLogEvent(BaseAgentEvent): metadata: Optional[Mapping[str, Any]] = Field(default=None, description="metadata") -InNodeEvent = BaseNodeEvent | BaseParallelBranchEvent | BaseIterationEvent | BaseAgentEvent +InNodeEvent = BaseNodeEvent | BaseParallelBranchEvent | BaseIterationEvent | BaseAgentEvent | BaseLoopEvent diff --git a/api/core/workflow/graph_engine/graph_engine.py b/api/core/workflow/graph_engine/graph_engine.py index a05cc30caba5ab..4b2671d24254e6 100644 --- a/api/core/workflow/graph_engine/graph_engine.py +++ b/api/core/workflow/graph_engine/graph_engine.py @@ -19,6 +19,7 @@ from core.workflow.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 @@ def _run_node( item.parallel_start_node_id = parallel_start_node_id item.parent_parallel_id = parent_parallel_id item.parent_parallel_start_node_id = parent_parallel_start_node_id + elif isinstance(item, BaseLoopEvent): + # add parallel info to loop event + item.parallel_id = parallel_id + item.parallel_start_node_id = parallel_start_node_id + item.parent_parallel_id = parent_parallel_id + item.parent_parallel_start_node_id = parent_parallel_start_node_id yield item else: diff --git a/api/core/workflow/nodes/answer/answer_stream_generate_router.py b/api/core/workflow/nodes/answer/answer_stream_generate_router.py index 7d652d39f70ef4..1d9c3e9b96d135 100644 --- a/api/core/workflow/nodes/answer/answer_stream_generate_router.py +++ b/api/core/workflow/nodes/answer/answer_stream_generate_router.py @@ -158,6 +158,7 @@ def _recursive_fetch_answer_dependencies( NodeType.IF_ELSE, NodeType.QUESTION_CLASSIFIER, NodeType.ITERATION, + NodeType.LOOP, NodeType.VARIABLE_ASSIGNER, } or source_node_data.get("error_strategy") == ErrorStrategy.FAIL_BRANCH diff --git a/api/core/workflow/nodes/answer/answer_stream_processor.py b/api/core/workflow/nodes/answer/answer_stream_processor.py index 40213bd151f7af..4617d478d328d6 100644 --- a/api/core/workflow/nodes/answer/answer_stream_processor.py +++ b/api/core/workflow/nodes/answer/answer_stream_processor.py @@ -35,7 +35,7 @@ def process(self, generator: Generator[GraphEngineEvent, None, None]) -> Generat yield event elif isinstance(event, NodeRunStreamChunkEvent): - if event.in_iteration_id: + if event.in_iteration_id or event.in_loop_id: yield event continue diff --git a/api/core/workflow/nodes/base/__init__.py b/api/core/workflow/nodes/base/__init__.py index 72d6392d4e01ea..54606b9e771977 100644 --- a/api/core/workflow/nodes/base/__init__.py +++ b/api/core/workflow/nodes/base/__init__.py @@ -1,4 +1,4 @@ -from .entities import BaseIterationNodeData, BaseIterationState, BaseNodeData +from .entities import BaseIterationNodeData, BaseIterationState, BaseNodeData, BaseLoopNodeData, BaseLoopState from .node import BaseNode -__all__ = ["BaseIterationNodeData", "BaseIterationState", "BaseNode", "BaseNodeData"] +__all__ = ["BaseIterationNodeData", "BaseIterationState", "BaseNode", "BaseNodeData", "BaseLoopNodeData", "BaseLoopState"] diff --git a/api/core/workflow/nodes/base/entities.py b/api/core/workflow/nodes/base/entities.py index 6bf8899f5d698b..d853eb71be1460 100644 --- a/api/core/workflow/nodes/base/entities.py +++ b/api/core/workflow/nodes/base/entities.py @@ -147,3 +147,18 @@ class MetaData(BaseModel): pass metadata: MetaData + + +class BaseLoopNodeData(BaseNodeData): + start_node_id: Optional[str] = None + + +class BaseLoopState(BaseModel): + loop_node_id: str + index: int + inputs: dict + + class MetaData(BaseModel): + pass + + metadata: MetaData diff --git a/api/core/workflow/nodes/end/end_stream_processor.py b/api/core/workflow/nodes/end/end_stream_processor.py index a770eb951f6c8c..3ae5af7137026b 100644 --- a/api/core/workflow/nodes/end/end_stream_processor.py +++ b/api/core/workflow/nodes/end/end_stream_processor.py @@ -33,7 +33,7 @@ def process(self, generator: Generator[GraphEngineEvent, None, None]) -> Generat yield event elif isinstance(event, NodeRunStreamChunkEvent): - if event.in_iteration_id: + if event.in_iteration_id or event.in_loop_id: if self.has_output and event.node_id not in self.output_node_ids: event.chunk_content = "\n" + event.chunk_content diff --git a/api/core/workflow/nodes/enums.py b/api/core/workflow/nodes/enums.py index 25e049577ac817..d9a2c2d8a8cf7d 100644 --- a/api/core/workflow/nodes/enums.py +++ b/api/core/workflow/nodes/enums.py @@ -16,6 +16,7 @@ class NodeType(StrEnum): VARIABLE_AGGREGATOR = "variable-aggregator" LEGACY_VARIABLE_AGGREGATOR = "variable-assigner" # TODO: Merge this into VARIABLE_AGGREGATOR in the database. LOOP = "loop" + LOOP_START = "loop-start" ITERATION = "iteration" ITERATION_START = "iteration-start" # Fake start node for iteration. PARAMETER_EXTRACTOR = "parameter-extractor" diff --git a/api/core/workflow/nodes/loop/__init__.py b/api/core/workflow/nodes/loop/__init__.py index e69de29bb2d1d6..60a0de65369cb1 100644 --- a/api/core/workflow/nodes/loop/__init__.py +++ b/api/core/workflow/nodes/loop/__init__.py @@ -0,0 +1,5 @@ +from .entities import LoopNodeData +from .loop_node import LoopNode +from .loop_start_node import LoopStartNode + +__all__ = ["LoopNode", "LoopNodeData", "LoopStartNode"] \ No newline at end of file diff --git a/api/core/workflow/nodes/loop/entities.py b/api/core/workflow/nodes/loop/entities.py index b7cd7a948e3f8b..8d5d7b070f97fe 100644 --- a/api/core/workflow/nodes/loop/entities.py +++ b/api/core/workflow/nodes/loop/entities.py @@ -1,13 +1,52 @@ -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 LoopStartNodeData(BaseNodeData): + """ + Loop Start Node Data. + """ + + pass -class LoopState(BaseIterationState): +class LoopState(BaseLoopState): """ Loop State. """ + + outputs: list[Any] = Field(default_factory=list) + current_output: Optional[Any] = None + + class MetaData(BaseLoopState.MetaData): + """ + Data. + """ + + loop_length: int + + def get_last_output(self) -> Optional[Any]: + """ + Get last output. + """ + if self.outputs: + return self.outputs[-1] + return None + + def get_current_output(self) -> Optional[Any]: + """ + Get current output. + """ + return self.current_output diff --git a/api/core/workflow/nodes/loop/loop_node.py b/api/core/workflow/nodes/loop/loop_node.py index a366c287c2ac56..7472764a3858a3 100644 --- a/api/core/workflow/nodes/loop/loop_node.py +++ b/api/core/workflow/nodes/loop/loop_node.py @@ -1,37 +1,357 @@ -from typing import Any +import logging +from datetime import datetime, timezone +from collections.abc import Generator +from typing import Any, Mapping, Sequence, 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, + NodeRunSucceededEvent, + NodeRunStreamChunkEvent, +) +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]): """ Loop Node. """ - _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) + + # Initialize graph engine + 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() + + 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): + if event.route_node_state.node_run_result: + # Update metadata + metadata = event.route_node_state.node_run_result.metadata or {} + if NodeRunMetadataKey.LOOP_ID not in metadata: + metadata[NodeRunMetadataKey.LOOP_ID] = self.node_id + index_variable = variable_pool.get([self.node_id, "index"]) + if not isinstance(index_variable, IntegerSegment): + yield RunCompletedEvent( + run_result=NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + error=f"Invalid index variable type: {type(index_variable)}", + metadata={ + NodeRunMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens + } + ) + ) + return + metadata[NodeRunMetadataKey.LOOP_INDEX] = index_variable.value + event.route_node_state.node_run_result.metadata = metadata + + yield event + + # 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 cast(InNodeEvent, event) + + # 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 + 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") + + next_index = current_index_variable.value + 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"]) + + @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")) + node_cls = node_type_classes_mapping.get(node_type) + if not node_cls: + continue + + 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, list[str]], sub_node_variable_mapping) + except NotImplementedError: + sub_node_variable_mapping = {} + + # remove loop variables + sub_node_variable_mapping = { + sub_node_id + "." + key: value + for key, value in sub_node_variable_mapping.items() + if value[0] != node_id + } + + variable_mapping.update(sub_node_variable_mapping) + + # remove variable out from loop + variable_mapping = { + key: value for key, value in variable_mapping.items() if value[0] not in loop_graph.node_ids + } + + return variable_mapping diff --git a/api/core/workflow/nodes/loop/loop_start_node.py b/api/core/workflow/nodes/loop/loop_start_node.py new file mode 100644 index 00000000000000..3b35e4aeddb647 --- /dev/null +++ b/api/core/workflow/nodes/loop/loop_start_node.py @@ -0,0 +1,36 @@ +from collections.abc import Mapping, Sequence +from typing import Any + +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 LoopNodeData, 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) + + @classmethod + def _extract_variable_selector_to_variable_mapping( + cls, graph_config: Mapping[str, Any], node_id: str, node_data: LoopNodeData + ) -> Mapping[str, Sequence[str]]: + """ + Extract variable selector to variable mapping + :param graph_config: graph config + :param node_id: node id + :param node_data: node data + :return: + """ + return {} diff --git a/api/core/workflow/nodes/node_mapping.py b/api/core/workflow/nodes/node_mapping.py index 6341b9455c5943..72c235885599a3 100644 --- a/api/core/workflow/nodes/node_mapping.py +++ b/api/core/workflow/nodes/node_mapping.py @@ -10,6 +10,7 @@ from core.workflow.nodes.http_request import HttpRequestNode from core.workflow.nodes.if_else import IfElseNode from core.workflow.nodes.iteration import IterationNode, IterationStartNode +from core.workflow.nodes.loop import LoopNode, LoopStartNode from core.workflow.nodes.knowledge_retrieval import KnowledgeRetrievalNode from core.workflow.nodes.list_operator import ListOperatorNode from core.workflow.nodes.llm import LLMNode @@ -85,6 +86,14 @@ LATEST_VERSION: IterationStartNode, "1": IterationStartNode, }, + NodeType.LOOP: { + LATEST_VERSION: LoopNode, + "1": LoopNode, + }, + NodeType.LOOP_START: { + LATEST_VERSION: LoopStartNode, + "1": LoopStartNode, + }, NodeType.PARAMETER_EXTRACTOR: { LATEST_VERSION: ParameterExtractorNode, "1": ParameterExtractorNode, diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 39b4afa252ab7d..cbea47f0b49419 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -137,6 +137,25 @@ def generate_single_iteration(cls, app_model: App, user: Account, node_id: str, 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, From a038d056c4e451cc0da9d021ed29295a0410b4d2 Mon Sep 17 00:00:00 2001 From: woo0ood Date: Fri, 24 Jan 2025 23:16:01 +0000 Subject: [PATCH 05/15] fix: temporarily hide error handling field in `Loop` node panel. --- web/app/components/workflow/nodes/loop/panel.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/app/components/workflow/nodes/loop/panel.tsx b/web/app/components/workflow/nodes/loop/panel.tsx index 4d3b777be814cb..a3a95dafb42384 100644 --- a/web/app/components/workflow/nodes/loop/panel.tsx +++ b/web/app/components/workflow/nodes/loop/panel.tsx @@ -110,12 +110,13 @@ const Panel: FC> = ({
-
+ {/* Error handling for the Loop node is currently not considered. */} + {/*
-
+
*/} {isShowSingleRun && ( Date: Fri, 24 Jan 2025 23:17:44 +0000 Subject: [PATCH 06/15] fix: correct nested level in tracking logs. The main reason is that the original design only considered nodes with nested sub-nodes of type Iterator. After adding the Loop, additional checks and handling are required. --- .../components/workflow/run/utils/format-log/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/app/components/workflow/run/utils/format-log/index.ts b/web/app/components/workflow/run/utils/format-log/index.ts index 2a92520952875e..186c8fba6a7dab 100644 --- a/web/app/components/workflow/run/utils/format-log/index.ts +++ b/web/app/components/workflow/run/utils/format-log/index.ts @@ -5,9 +5,11 @@ import formatParallelNode from './parallel' import formatRetryNode from './retry' import formatAgentNode from './agent' import { cloneDeep } from 'lodash-es' +import { BlockEnum } from '../../../types' const formatToTracingNodeList = (list: NodeTracing[], t: any) => { const allItems = cloneDeep([...list]).sort((a, b) => a.index - b.index) + const loopRelatedList = allItems.filter(item => (item.execution_metadata?.loop_id || item.node_type === BlockEnum.Loop)) /* * First handle not change list structure node * Because Handle struct node will put the node in different @@ -15,11 +17,11 @@ const formatToTracingNodeList = (list: NodeTracing[], t: any) => { const formattedAgentList = formatAgentNode(allItems) const formattedRetryList = formatRetryNode(formattedAgentList) // retry one node // would change the structure of the list. Iteration and parallel can include each other. - const formattedIterationList = formatIterationNode(formattedRetryList, t) - const formattedLoopList = formatLoopNode(formattedRetryList, t) - const formattedParallelList = formatParallelNode(formattedIterationList, t) + const formattedIterationList = formatIterationNode(formattedRetryList.filter(item => !loopRelatedList.includes(item)), t) + const formattedLoopList = formatLoopNode(loopRelatedList, t) + const formattedParallelList = formatParallelNode([...formattedIterationList, ...formattedLoopList], t) - const result = allItems[0].iteration_id ? formattedParallelList : formattedLoopList + const result = formattedParallelList // console.log(allItems) // console.log(result) From 5f2ed42cef330d80c44e00bbdb1cd19e452ef58a Mon Sep 17 00:00:00 2001 From: woo0ood Date: Fri, 24 Jan 2025 23:31:52 +0000 Subject: [PATCH 07/15] chore: remove some temporarily unused code. --- .../components/condition-files-list-value.tsx | 2 +- .../components/workflow/nodes/loop/panel.tsx | 18 +----------------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx b/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx index d4203d1bc2ed2b..1cf34e5aba2a2d 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-files-list-value.tsx @@ -71,7 +71,7 @@ const ConditionValue = ({ : '' } return '' - }, []) + }, [t]) return (
diff --git a/web/app/components/workflow/nodes/loop/panel.tsx b/web/app/components/workflow/nodes/loop/panel.tsx index a3a95dafb42384..f17c46a43a5fd7 100644 --- a/web/app/components/workflow/nodes/loop/panel.tsx +++ b/web/app/components/workflow/nodes/loop/panel.tsx @@ -7,11 +7,10 @@ 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 { ErrorHandleMode, type NodePanelProps } from '@/app/components/workflow/types' +import type { NodePanelProps } from '@/app/components/workflow/types' import ConditionWrap from './components/condition-wrap' import Field from '@/app/components/workflow/nodes/_base/components/field' import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/before-run-form' -import Select from '@/app/components/base/select' import formatTracing from '@/app/components/workflow/run/utils/format-log' import { useLogs } from '@/app/components/workflow/run/hooks' @@ -23,20 +22,6 @@ const Panel: FC> = ({ data, }) => { const { t } = useTranslation() - const responseMethod = [ - { - value: ErrorHandleMode.Terminated, - name: t(`${i18nPrefix}.ErrorMethod.operationTerminated`), - }, - { - value: ErrorHandleMode.ContinueOnError, - name: t(`${i18nPrefix}.ErrorMethod.continueOnError`), - }, - { - value: ErrorHandleMode.RemoveAbnormalOutput, - name: t(`${i18nPrefix}.ErrorMethod.removeAbnormalOutput`), - }, - ] const { readOnly, @@ -62,7 +47,6 @@ const Panel: FC> = ({ handleUpdateSubVariableCondition, handleToggleSubVariableConditionLogicalOperator, handleUpdateLoopCount, - changeErrorResponseMode, } = useConfig(id, data) const nodeInfo = formatTracing(loopRunResult, t)[0] From 201d0c3c8178b1e90100db42373d20cc6c5125a9 Mon Sep 17 00:00:00 2001 From: arkunzz <4873204@qq.com> Date: Sun, 26 Jan 2025 14:39:06 +0800 Subject: [PATCH 08/15] fix: loop node extract variable --- api/core/workflow/nodes/loop/loop_node.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/api/core/workflow/nodes/loop/loop_node.py b/api/core/workflow/nodes/loop/loop_node.py index 7472764a3858a3..56ad36271dcc32 100644 --- a/api/core/workflow/nodes/loop/loop_node.py +++ b/api/core/workflow/nodes/loop/loop_node.py @@ -326,17 +326,18 @@ def _extract_variable_selector_to_variable_mapping( # variable selector to variable mapping try: # Get node class - from core.workflow.nodes.node_mapping import node_type_classes_mapping + from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING node_type = NodeType(sub_node_config.get("data", {}).get("type")) - node_cls = node_type_classes_mapping.get(node_type) - if not node_cls: + 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, list[str]], sub_node_variable_mapping) + sub_node_variable_mapping = cast(dict[str, Sequence[str]], sub_node_variable_mapping) except NotImplementedError: sub_node_variable_mapping = {} From 1d9669b27bc4bde48568cc01a7b52f6c102b5905 Mon Sep 17 00:00:00 2001 From: woo0ood Date: Sun, 26 Jan 2025 04:06:37 +0000 Subject: [PATCH 09/15] fix: correct the sorting issue of Tracing information. --- web/app/components/workflow/run/utils/format-log/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/app/components/workflow/run/utils/format-log/index.ts b/web/app/components/workflow/run/utils/format-log/index.ts index 186c8fba6a7dab..a58cb07ce12298 100644 --- a/web/app/components/workflow/run/utils/format-log/index.ts +++ b/web/app/components/workflow/run/utils/format-log/index.ts @@ -6,6 +6,7 @@ import formatRetryNode from './retry' import formatAgentNode from './agent' import { cloneDeep } from 'lodash-es' import { BlockEnum } from '../../../types' +import { orderBy } from 'lodash-es' const formatToTracingNodeList = (list: NodeTracing[], t: any) => { const allItems = cloneDeep([...list]).sort((a, b) => a.index - b.index) @@ -19,7 +20,8 @@ const formatToTracingNodeList = (list: NodeTracing[], t: any) => { // would change the structure of the list. Iteration and parallel can include each other. const formattedIterationList = formatIterationNode(formattedRetryList.filter(item => !loopRelatedList.includes(item)), t) const formattedLoopList = formatLoopNode(loopRelatedList, t) - const formattedParallelList = formatParallelNode([...formattedIterationList, ...formattedLoopList], t) + const orderedNodeList = orderBy([...formattedIterationList, ...formattedLoopList], 'index', 'asc') + const formattedParallelList = formatParallelNode(orderedNodeList, t) const result = formattedParallelList // console.log(allItems) From 0e2386b3609d5a03f9aa02971dea611e10036ae0 Mon Sep 17 00:00:00 2001 From: woo0ood Date: Sun, 26 Jan 2025 06:06:41 +0000 Subject: [PATCH 10/15] fix: resolve issue where Loop node on-step-run could not view Loop details. --- .../workflow/nodes/_base/hooks/use-one-step-run.ts | 2 +- web/app/components/workflow/nodes/loop/panel.tsx | 12 ++---------- .../workflow/run/loop-log/loop-log-trigger.tsx | 2 +- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index 49ad6a2493d41d..70aabb6332fe85 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts @@ -417,7 +417,7 @@ const useOneStepRun = ({ }, ) } - if (res.error) + if (res && res.error) throw new Error(res.error) } catch (e: any) { diff --git a/web/app/components/workflow/nodes/loop/panel.tsx b/web/app/components/workflow/nodes/loop/panel.tsx index f17c46a43a5fd7..3343af861896c8 100644 --- a/web/app/components/workflow/nodes/loop/panel.tsx +++ b/web/app/components/workflow/nodes/loop/panel.tsx @@ -34,9 +34,6 @@ const Panel: FC> = ({ handleRun, handleStop, runResult, - inputVarValues, - setInputVarValues, - usedOutVars, loopRunResult, handleAddCondition, handleUpdateCondition, @@ -105,16 +102,11 @@ const Panel: FC> = ({ } diff --git a/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx b/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx index f4b475323495bb..378f09119f201b 100644 --- a/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx +++ b/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx @@ -35,7 +35,7 @@ const LoopLogTrigger = ({ const handleOnShowLoopDetail = (e: React.MouseEvent) => { e.stopPropagation() e.nativeEvent.stopImmediatePropagation() - onShowLoopResultList(nodeInfo.details || [], nodeInfo?.iterDurationMap || nodeInfo.execution_metadata?.loop_duration_map || {}) + onShowLoopResultList(nodeInfo.details || [], nodeInfo?.loopDurationMap || nodeInfo.execution_metadata?.loop_duration_map || {}) } return (