diff --git a/frontend/src/components/workflow/GenericNode.tsx b/frontend/src/components/workflow/GenericNode.tsx
index 7bca604..2ee64eb 100644
--- a/frontend/src/components/workflow/GenericNode.tsx
+++ b/frontend/src/components/workflow/GenericNode.tsx
@@ -2,11 +2,11 @@
/**
* 通用节点渲染器。所有节点类型共用一个组件,规格来自 NodeSpecsContext
- * (后端 /api/node-types)。悬停/选中时在右上角显示「编辑 / 删除」按钮。
+ * (后端 /api/node-types)。悬停/选中时在右上角显示「编辑 / 复制 / 删除」按钮。
*/
import { Handle, Position, type NodeProps } from "@xyflow/react";
-import { MessageSquareText, Pencil, Trash2 } from "lucide-react";
+import { Copy, MessageSquareText, Pencil, Trash2 } from "lucide-react";
import { useContext } from "react";
import { cn } from "@/lib/utils";
@@ -130,6 +130,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
{type !== "start" && (
-
+ <>
+
+
+ >
)}
diff --git a/frontend/src/components/workflow/WorkflowCanvas.tsx b/frontend/src/components/workflow/WorkflowCanvas.tsx
index 4f3c030..66b6950 100644
--- a/frontend/src/components/workflow/WorkflowCanvas.tsx
+++ b/frontend/src/components/workflow/WorkflowCanvas.tsx
@@ -328,6 +328,50 @@ export function WorkflowCanvas({
[editingNodeId, nodes, onEditingNodeIdChange, setNodes, setEdges],
);
+ const duplicateNode = useCallback(
+ (id: string) => {
+ const source = nodes.find((node) => node.id === id);
+ if (!source || source.type === "start") return;
+
+ const spec = specsByType[source.type as string];
+ if (!spec) return;
+ const limit = spec.constraints.maxInstances;
+ if (
+ limit !== undefined &&
+ nodes.filter((node) => node.type === source.type).length >= limit
+ ) {
+ return;
+ }
+
+ nodeSeq += 1;
+ const duplicateId = `${source.type}-${Date.now()}-${nodeSeq}`;
+ const sourceData = source.data as WorkflowNodeData;
+ const duplicateData = structuredClone(sourceData);
+ duplicateData.name = `${sourceData.name || spec.displayName}(副本)`;
+
+ setNodes((currentNodes) => [
+ ...currentNodes.map((node) => ({ ...node, selected: false })),
+ {
+ id: duplicateId,
+ type: source.type,
+ position: {
+ x: source.position.x + 40,
+ y: source.position.y + 40,
+ },
+ data: duplicateData,
+ selected: true,
+ },
+ ]);
+ onEditingNodeIdChange(null);
+ },
+ [
+ nodes,
+ onEditingNodeIdChange,
+ setNodes,
+ specsByType,
+ ],
+ );
+
const handleNodesChange = useCallback(
(changes: NodeChange[]) => {
const startIds = new Set(
@@ -431,10 +475,12 @@ export function WorkflowCanvas({
onEditingEdgeIdChange(null);
onEditingNodeIdChange(id);
},
+ duplicate: duplicateNode,
remove: deleteNode,
}),
[
deleteNode,
+ duplicateNode,
onDebugOpenChange,
onEditingEdgeIdChange,
onEditingNodeIdChange,
diff --git a/frontend/src/components/workflow/context.ts b/frontend/src/components/workflow/context.ts
index 6263d44..466fc9b 100644
--- a/frontend/src/components/workflow/context.ts
+++ b/frontend/src/components/workflow/context.ts
@@ -2,7 +2,7 @@
/**
* 画布内共享上下文。GenericNode / ConditionEdge 由 ReactFlow 渲染,拿不到外层
- * 回调,统一通过这些 context 取节点规格与编辑/删除动作。
+ * 回调,统一通过这些 context 取节点规格与编辑/复制/删除动作。
*/
import { createContext } from "react";
@@ -17,9 +17,14 @@ export type ElementActions = {
remove: (id: string) => void;
};
-const noop: ElementActions = { edit: () => {}, remove: () => {} };
+export type NodeActions = ElementActions & {
+ duplicate: (id: string) => void;
+};
-export const NodeActionContext = createContext(noop);
+const noop: ElementActions = { edit: () => {}, remove: () => {} };
+const noopNodeActions: NodeActions = { ...noop, duplicate: () => {} };
+
+export const NodeActionContext = createContext(noopNodeActions);
export const EdgeActionContext = createContext(noop);
/** 调试通话中当前激活的节点 id(画布据此高亮),无激活为 null。 */