From 025e606db456f7df305a7dfb15cc8400ceb2b249 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Mon, 2 Jun 2025 23:06:18 +0800 Subject: [PATCH] feat(ui): duplicate workflow node --- .../node-processor/execute_failure_node.go | 1 - .../node-processor/execute_success_node.go | 1 - .../workflow/node-processor/start_node.go | 2 +- .../workflow/node/ExecuteResultNode.tsx | 18 +- .../components/workflow/node/UnknownNode.tsx | 2 +- .../components/workflow/node/_SharedNode.tsx | 41 ++++- ui/src/domain/workflow.ts | 167 +++++++++++++----- .../i18n/locales/en/nls.workflow.nodes.json | 8 +- .../i18n/locales/zh/nls.workflow.nodes.json | 8 +- ui/src/stores/workflow/index.ts | 52 +++++- 10 files changed, 223 insertions(+), 77 deletions(-) diff --git a/internal/workflow/node-processor/execute_failure_node.go b/internal/workflow/node-processor/execute_failure_node.go index d3f61e30..40be18ed 100644 --- a/internal/workflow/node-processor/execute_failure_node.go +++ b/internal/workflow/node-processor/execute_failure_node.go @@ -22,7 +22,6 @@ func NewExecuteFailureNode(node *domain.WorkflowNode) *executeFailureNode { func (n *executeFailureNode) Process(ctx context.Context) error { // 此类型节点不需要执行任何操作,直接返回 - n.logger.Info("the previous node execution was failed") return nil } diff --git a/internal/workflow/node-processor/execute_success_node.go b/internal/workflow/node-processor/execute_success_node.go index 46a74482..2cd78ff3 100644 --- a/internal/workflow/node-processor/execute_success_node.go +++ b/internal/workflow/node-processor/execute_success_node.go @@ -22,7 +22,6 @@ func NewExecuteSuccessNode(node *domain.WorkflowNode) *executeSuccessNode { func (n *executeSuccessNode) Process(ctx context.Context) error { // 此类型节点不需要执行任何操作,直接返回 - n.logger.Info("the previous node execution was succeeded") return nil } diff --git a/internal/workflow/node-processor/start_node.go b/internal/workflow/node-processor/start_node.go index 30dee424..bdfea1b7 100644 --- a/internal/workflow/node-processor/start_node.go +++ b/internal/workflow/node-processor/start_node.go @@ -22,7 +22,7 @@ func NewStartNode(node *domain.WorkflowNode) *startNode { func (n *startNode) Process(ctx context.Context) error { // 此类型节点不需要执行任何操作,直接返回 - n.logger.Info("ready to start ...") + n.logger.Info("workflow is started") return nil } diff --git a/ui/src/components/workflow/node/ExecuteResultNode.tsx b/ui/src/components/workflow/node/ExecuteResultNode.tsx index 69a0949c..ce991d95 100644 --- a/ui/src/components/workflow/node/ExecuteResultNode.tsx +++ b/ui/src/components/workflow/node/ExecuteResultNode.tsx @@ -1,5 +1,4 @@ import { memo } from "react"; -import { useTranslation } from "react-i18next"; import { CheckCircleOutlined as CheckCircleOutlinedIcon, CloseCircleOutlined as CloseCircleOutlinedIcon, @@ -17,8 +16,6 @@ export type ConditionNodeProps = SharedNodeProps & { }; const ExecuteResultNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeProps) => { - const { t } = useTranslation(); - const { token: themeToken } = theme.useToken(); return ( @@ -42,16 +39,15 @@ const ExecuteResultNode = ({ node, disabled, branchId, branchIndex }: ConditionN
{node.type === WorkflowNodeType.ExecuteSuccess ? ( - <> - -
{t("workflow_node.execute_success.label")}
- + ) : ( - <> - -
{t("workflow_node.execute_failure.label")}
- + )} +
diff --git a/ui/src/components/workflow/node/UnknownNode.tsx b/ui/src/components/workflow/node/UnknownNode.tsx index 7cb64aae..2586c6e2 100644 --- a/ui/src/components/workflow/node/UnknownNode.tsx +++ b/ui/src/components/workflow/node/UnknownNode.tsx @@ -14,7 +14,7 @@ const UnknownNode = ({ node, disabled }: MonitorNodeProps) => { const { removeNode } = useWorkflowStore(useZustandShallowSelector(["removeNode"])); const handleClickRemove = () => { - removeNode(node.id); + removeNode(node); }; return ( diff --git a/ui/src/components/workflow/node/_SharedNode.tsx b/ui/src/components/workflow/node/_SharedNode.tsx index 72f4b967..5fe6fc3e 100644 --- a/ui/src/components/workflow/node/_SharedNode.tsx +++ b/ui/src/components/workflow/node/_SharedNode.tsx @@ -5,6 +5,7 @@ import { EllipsisOutlined as EllipsisOutlinedIcon, FormOutlined as FormOutlinedIcon, MoreOutlined as MoreOutlinedIcon, + SnippetsOutlined as SnippetsOutlinedIcon, } from "@ant-design/icons"; import { useControllableValue } from "ahooks"; import { Button, Card, Drawer, Dropdown, Input, type InputRef, type MenuProps, Modal, Popover, Space } from "antd"; @@ -82,14 +83,27 @@ const isNodeBranchLike = (node: WorkflowNode) => { ); }; -const isNodeReadOnly = (node: WorkflowNode) => { +const isNodeUnduplicatable = (node: WorkflowNode) => { + return ( + node.type === WorkflowNodeType.Start || + node.type === WorkflowNodeType.End || + node.type === WorkflowNodeType.Branch || + node.type === WorkflowNodeType.ExecuteResultBranch || + node.type === WorkflowNodeType.ExecuteSuccess || + node.type === WorkflowNodeType.ExecuteFailure + ); +}; + +const isNodeUnremovable = (node: WorkflowNode) => { return node.type === WorkflowNodeType.Start || node.type === WorkflowNodeType.End; }; const SharedNodeMenu = ({ menus, trigger, node, disabled, branchId, branchIndex, afterUpdate, afterDelete }: SharedNodeMenuProps) => { const { t } = useTranslation(); - const { updateNode, removeNode, removeBranch } = useWorkflowStore(useZustandShallowSelector(["updateNode", "removeNode", "removeBranch"])); + const { duplicateNode, updateNode, removeNode, duplicateBranch, removeBranch } = useWorkflowStore( + useZustandShallowSelector(["duplicateNode", "updateNode", "removeNode", "duplicateBranch", "removeBranch"]) + ); const [modalApi, ModelContextHolder] = Modal.useModal(); @@ -112,11 +126,19 @@ const SharedNodeMenu = ({ menus, trigger, node, disabled, branchId, branchIndex, afterUpdate?.(); }; - const handleDeleteClick = async () => { + const handleDuplicateClick = async () => { + if (isNodeBranchLike(node)) { + await duplicateBranch(branchId!, branchIndex!); + } else { + await duplicateNode(node); + } + }; + + const handleRemoveClick = async () => { if (isNodeBranchLike(node)) { await removeBranch(branchId!, branchIndex!); } else { - await removeNode(node.id); + await removeNode(node); } afterDelete?.(); @@ -155,16 +177,23 @@ const SharedNodeMenu = ({ menus, trigger, node, disabled, branchId, branchIndex, setTimeout(() => nameInputRef.current?.focus(), 1); }, }, + { + key: "duplicate", + disabled: disabled || isNodeUnduplicatable(node), + label: isNodeBranchLike(node) ? t("workflow_node.action.duplicate_branch") : t("workflow_node.action.duplicate_node"), + icon: , + onClick: handleDuplicateClick, + }, { type: "divider", }, { key: "remove", - disabled: disabled || isNodeReadOnly(node), + disabled: disabled || isNodeUnremovable(node), label: isNodeBranchLike(node) ? t("workflow_node.action.remove_branch") : t("workflow_node.action.remove_node"), icon: , danger: true, - onClick: handleDeleteClick, + onClick: handleRemoveClick, }, ] satisfies MenuProps["items"]; diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 9a41a393..0a71749b 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -1,5 +1,5 @@ import dayjs from "dayjs"; -import { produce } from "immer"; +import { Immer, produce } from "immer"; import { nanoid } from "nanoid"; import i18n from "@/i18n"; @@ -234,7 +234,7 @@ export type NotExpr = { type: ExprType.Not; expr: Expr }; export type Expr = ConstantExpr | VariantExpr | ComparisonExpr | LogicalExpr | NotExpr; // #endregion -const isBranchLike = (node: WorkflowNode) => { +const isBranchNode = (node: WorkflowNode) => { return node.type === WorkflowNodeType.Branch || node.type === WorkflowNodeType.ExecuteResultBranch; }; @@ -458,8 +458,75 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {} return node; }; -export const updateNode = (node: WorkflowNode, targetNode: WorkflowNode) => { - return produce(node, (draft) => { +export const cloneNode = (sourceNode: WorkflowNode): WorkflowNode => { + const { produce } = new Immer({ autoFreeze: false }); + const deepClone = (node: WorkflowNode): WorkflowNode => { + return produce(node, (draft) => { + draft.id = nanoid(); + + if (draft.next) { + draft.next = cloneNode(draft.next); + } + + if (draft.branches) { + draft.branches = draft.branches.map((branch) => cloneNode(branch)); + } + + return draft; + }); + }; + + const copyNode = produce(sourceNode, (draft) => { + draft.name = `${draft.name}-copy`; + }); + return deepClone(copyNode); +}; + +export const addNode = (root: WorkflowNode, targetNode: WorkflowNode, previousNodeId: string) => { + if (isBranchNode(targetNode)) { + throw new Error("Cannot add a branch node directly. Use `addBranch` instead."); + } + + return produce(root, (draft) => { + let current = draft; + while (current) { + if (current.id === previousNodeId && !isBranchNode(targetNode)) { + targetNode.next = current.next; + current.next = targetNode; + break; + } else if (current.id === previousNodeId && isBranchNode(targetNode)) { + targetNode.branches![0].next = current.next; + current.next = targetNode; + break; + } + + if (isBranchNode(current)) { + current.branches ??= []; + current.branches = current.branches.map((branch) => addNode(branch, targetNode, previousNodeId)); + } + + current = current.next as WorkflowNode; + } + + return draft; + }); +}; + +export const duplicateNode = (root: WorkflowNode, targetNode: WorkflowNode) => { + if (isBranchNode(targetNode)) { + throw new Error("Cannot duplicate a branch node directly. Use `duplicateBranch` instead."); + } + + const copiedNode = cloneNode(targetNode); + return addNode(root, copiedNode, targetNode.id); +}; + +export const updateNode = (root: WorkflowNode, targetNode: WorkflowNode) => { + if (isBranchNode(targetNode)) { + throw new Error("Cannot update a branch node directly. Use `updateBranch` instead."); + } + + return produce(root, (draft) => { let current = draft; while (current) { if (current.id === targetNode.id) { @@ -476,7 +543,7 @@ export const updateNode = (node: WorkflowNode, targetNode: WorkflowNode) => { break; } - if (isBranchLike(current)) { + if (isBranchNode(current)) { current.branches ??= []; current.branches = current.branches.map((branch) => updateNode(branch, targetNode)); } @@ -488,23 +555,18 @@ export const updateNode = (node: WorkflowNode, targetNode: WorkflowNode) => { }); }; -export const addNode = (node: WorkflowNode, previousNodeId: string, targetNode: WorkflowNode) => { - return produce(node, (draft) => { +export const removeNode = (root: WorkflowNode, targetNodeId: string) => { + return produce(root, (draft) => { let current = draft; while (current) { - if (current.id === previousNodeId && !isBranchLike(targetNode)) { - targetNode.next = current.next; - current.next = targetNode; - break; - } else if (current.id === previousNodeId && isBranchLike(targetNode)) { - targetNode.branches![0].next = current.next; - current.next = targetNode; + if (current.next?.id === targetNodeId) { + current.next = current.next.next; break; } - if (isBranchLike(current)) { + if (isBranchNode(current)) { current.branches ??= []; - current.branches = current.branches.map((branch) => addNode(branch, previousNodeId, targetNode)); + current.branches = current.branches.map((branch) => removeNode(branch, targetNodeId)); } current = current.next as WorkflowNode; @@ -514,8 +576,8 @@ export const addNode = (node: WorkflowNode, previousNodeId: string, targetNode: }); }; -export const addBranch = (node: WorkflowNode, branchNodeId: string) => { - return produce(node, (draft) => { +export const addBranch = (root: WorkflowNode, branchNodeId: string) => { + return produce(root, (draft) => { let current = draft; while (current) { if (current.id === branchNodeId) { @@ -532,7 +594,7 @@ export const addBranch = (node: WorkflowNode, branchNodeId: string) => { break; } - if (isBranchLike(current)) { + if (isBranchNode(current)) { current.branches ??= []; current.branches = current.branches.map((branch) => addBranch(branch, branchNodeId)); } @@ -544,29 +606,8 @@ export const addBranch = (node: WorkflowNode, branchNodeId: string) => { }); }; -export const removeNode = (node: WorkflowNode, targetNodeId: string) => { - return produce(node, (draft) => { - let current = draft; - while (current) { - if (current.next?.id === targetNodeId) { - current.next = current.next.next; - break; - } - - if (isBranchLike(current)) { - current.branches ??= []; - current.branches = current.branches.map((branch) => removeNode(branch, targetNodeId)); - } - - current = current.next as WorkflowNode; - } - - return draft; - }); -}; - -export const removeBranch = (node: WorkflowNode, branchNodeId: string, branchIndex: number) => { - return produce(node, (draft) => { +export const duplicateBranch = (root: WorkflowNode, branchNodeId: string, branchIndex: number) => { + return produce(root, (draft) => { let current = draft; let last: WorkflowNode | undefined = { id: "", @@ -576,7 +617,41 @@ export const removeBranch = (node: WorkflowNode, branchNodeId: string, branchInd }; while (current && last) { if (current.id === branchNodeId) { - if (!isBranchLike(current)) { + if (!isBranchNode(current)) { + return draft; + } + + current.branches ??= []; + current.branches.splice(branchIndex + 1, 0, cloneNode(current.branches[branchIndex])); + + break; + } + + if (isBranchNode(current)) { + current.branches ??= []; + current.branches = current.branches.map((branch) => duplicateBranch(branch, branchNodeId, branchIndex)); + } + + current = current.next as WorkflowNode; + last = last.next; + } + + return draft; + }); +}; + +export const removeBranch = (root: WorkflowNode, branchNodeId: string, branchIndex: number) => { + return produce(root, (draft) => { + let current = draft; + let last: WorkflowNode | undefined = { + id: "", + name: "", + type: WorkflowNodeType.Start, + next: draft, + }; + while (current && last) { + if (current.id === branchNodeId) { + if (!isBranchNode(current)) { return draft; } @@ -601,7 +676,7 @@ export const removeBranch = (node: WorkflowNode, branchNodeId: string, branchInd break; } - if (isBranchLike(current)) { + if (isBranchNode(current)) { current.branches ??= []; current.branches = current.branches.map((branch) => removeBranch(branch, branchNodeId, branchIndex)); } @@ -647,7 +722,7 @@ export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, typeFi }); } - if (isBranchLike(current)) { + if (isBranchNode(current)) { let currentLength = output.length; const latestOutput = output.length > 0 ? output[output.length - 1] : null; for (const branch of current.branches!) { @@ -679,7 +754,7 @@ export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, typeFi export const isAllNodesValidated = (node: WorkflowNode): boolean => { let current = node as typeof node | undefined; while (current) { - if (isBranchLike(current)) { + if (isBranchNode(current)) { for (const branch of current.branches!) { if (!isAllNodesValidated(branch)) { return false; diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index a22cd8c4..0c44f107 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -2,9 +2,11 @@ "workflow_node.action.configure_node": "Configure node", "workflow_node.action.add_node": "Add node", "workflow_node.action.rename_node": "Rename node", + "workflow_node.action.duplicate_node": "Duplicate node", "workflow_node.action.remove_node": "Delete node", "workflow_node.action.add_branch": "Add branch", "workflow_node.action.rename_branch": "Rename branch", + "workflow_node.action.duplicate_branch": "Duplicate branch", "workflow_node.action.remove_branch": "Delete branch", "workflow_node.unsaved_changes.confirm": "You have unsaved changes. Do you really want to close the panel and drop those changes?", @@ -901,11 +903,11 @@ "workflow_node.condition.form.expression.add_condition.button": "Add condition", "workflow_node.execute_result_branch.label": "Execution result branch", - "workflow_node.execute_result_branch.default_name": "Execution result branch", + "workflow_node.execute_result_branch.default_name": "Branch", "workflow_node.execute_success.label": "If the previous node succeeded ...", - "workflow_node.execute_success.default_name": "If the previous node succeeded ...", + "workflow_node.execute_success.default_name": "On Succeeded", "workflow_node.execute_failure.label": "If the previous node failed ...", - "workflow_node.execute_failure.default_name": "If the previous node failed ..." + "workflow_node.execute_failure.default_name": "On Failed" } diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index b568d2ca..9f244ef2 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -2,9 +2,11 @@ "workflow_node.action.configure_node": "配置节点", "workflow_node.branch.add_node": "添加节点", "workflow_node.action.rename_node": "重命名", + "workflow_node.action.duplicate_node": "复制节点", "workflow_node.action.remove_node": "删除节点", "workflow_node.action.add_branch": "添加分支", "workflow_node.action.rename_branch": "重命名", + "workflow_node.action.duplicate_branch": "复制分支", "workflow_node.action.remove_branch": "删除分支", "workflow_node.unsaved_changes.confirm": "你有尚未保存的更改。确定要关闭面板吗?", @@ -900,11 +902,11 @@ "workflow_node.condition.form.expression.add_condition.button": "添加条件", "workflow_node.execute_result_branch.label": "执行结果分支", - "workflow_node.execute_result_branch.default_name": "执行结果分支", + "workflow_node.execute_result_branch.default_name": "分支", "workflow_node.execute_success.label": "若上一节点执行成功…", - "workflow_node.execute_success.default_name": "若上一节点执行成功…", + "workflow_node.execute_success.default_name": "执行成功", "workflow_node.execute_failure.label": "若上一节点执行失败…", - "workflow_node.execute_failure.default_name": "若上一节点执行失败…" + "workflow_node.execute_failure.default_name": "执行失败" } diff --git a/ui/src/stores/workflow/index.ts b/ui/src/stores/workflow/index.ts index 67bc25f9..7a086708 100644 --- a/ui/src/stores/workflow/index.ts +++ b/ui/src/stores/workflow/index.ts @@ -7,6 +7,8 @@ import { type WorkflowNodeConfigForStart, addBranch, addNode, + duplicateBranch, + duplicateNode, getOutputBeforeNodeId, removeBranch, removeNode, @@ -26,10 +28,12 @@ export type WorkflowState = { destroy(): void; addNode: (node: WorkflowNode, previousNodeId: string) => void; + duplicateNode: (node: WorkflowNode) => void; updateNode: (node: WorkflowNode) => void; - removeNode: (nodeId: string) => void; + removeNode: (node: WorkflowNode) => void; addBranch: (branchId: string) => void; + duplicateBranch: (branchId: string, index: number) => void; removeBranch: (branchId: string, index: number) => void; getWorkflowOuptutBeforeId: (nodeId: string, typeFilter?: string | string[]) => WorkflowNode[]; @@ -146,7 +150,27 @@ export const useWorkflowStore = create((set, get) => ({ addNode: async (node: WorkflowNode, previousNodeId: string) => { if (!get().initialized) throw "Workflow not initialized yet"; - const root = addNode(get().workflow.draft!, previousNodeId, node); + const root = addNode(get().workflow.draft!, node, previousNodeId); + const resp = await saveWorkflow({ + id: get().workflow.id!, + draft: root, + hasDraft: true, + }); + + set((state: WorkflowState) => { + return { + workflow: produce(state.workflow, (draft) => { + draft.draft = resp.draft; + draft.hasDraft = resp.hasDraft; + }), + }; + }); + }, + + duplicateNode: async (node: WorkflowNode) => { + if (!get().initialized) throw "Workflow not initialized yet"; + + const root = duplicateNode(get().workflow.draft!, node); const resp = await saveWorkflow({ id: get().workflow.id!, draft: root, @@ -183,10 +207,10 @@ export const useWorkflowStore = create((set, get) => ({ }); }, - removeNode: async (nodeId: string) => { + removeNode: async (node: WorkflowNode) => { if (!get().initialized) throw "Workflow not initialized yet"; - const root = removeNode(get().workflow.draft!, nodeId); + const root = removeNode(get().workflow.draft!, node.id); const resp = await saveWorkflow({ id: get().workflow.id!, draft: root, @@ -223,6 +247,26 @@ export const useWorkflowStore = create((set, get) => ({ }); }, + duplicateBranch: async (branchId: string, index: number) => { + if (!get().initialized) throw "Workflow not initialized yet"; + + const root = duplicateBranch(get().workflow.draft!, branchId, index); + const resp = await saveWorkflow({ + id: get().workflow.id!, + draft: root, + hasDraft: true, + }); + + set((state: WorkflowState) => { + return { + workflow: produce(state.workflow, (draft) => { + draft.draft = resp.draft; + draft.hasDraft = resp.hasDraft; + }), + }; + }); + }, + removeBranch: async (branchId: string, index: number) => { if (!get().initialized) throw "Workflow not initialized yet";