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";