瀏覽代碼

feat: workflow interaction (#4214)

zxhlyh 11 月之前
父節點
當前提交
9b24f12bf5
共有 54 個文件被更改,包括 1906 次插入382 次删除
  1. 二進制
      web/app/components/base/icons/assets/vender/line/arrows/flip-backward.zip
  2. 二進制
      web/app/components/base/icons/assets/vender/line/arrows/flip-forward.zip
  3. 5 0
      web/app/components/base/icons/assets/vender/line/arrows/reverse-left.svg
  4. 5 0
      web/app/components/base/icons/assets/vender/line/editor/cursor-02c.svg
  5. 5 0
      web/app/components/base/icons/assets/vender/line/editor/hand-02.svg
  6. 5 0
      web/app/components/base/icons/assets/vender/line/editor/zoom-in.svg
  7. 5 0
      web/app/components/base/icons/assets/vender/line/editor/zoom-out.svg
  8. 5 0
      web/app/components/base/icons/assets/vender/solid/editor/cursor-02c.svg
  9. 5 0
      web/app/components/base/icons/assets/vender/solid/editor/hand-02.svg
  10. 39 0
      web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.json
  11. 16 0
      web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.tsx
  12. 1 0
      web/app/components/base/icons/src/vender/line/arrows/index.ts
  13. 38 0
      web/app/components/base/icons/src/vender/line/editor/Cursor02C.json
  14. 16 0
      web/app/components/base/icons/src/vender/line/editor/Cursor02C.tsx
  15. 39 0
      web/app/components/base/icons/src/vender/line/editor/Hand02.json
  16. 16 0
      web/app/components/base/icons/src/vender/line/editor/Hand02.tsx
  17. 39 0
      web/app/components/base/icons/src/vender/line/editor/ZoomIn.json
  18. 16 0
      web/app/components/base/icons/src/vender/line/editor/ZoomIn.tsx
  19. 39 0
      web/app/components/base/icons/src/vender/line/editor/ZoomOut.json
  20. 16 0
      web/app/components/base/icons/src/vender/line/editor/ZoomOut.tsx
  21. 4 0
      web/app/components/base/icons/src/vender/line/editor/index.ts
  22. 36 0
      web/app/components/base/icons/src/vender/solid/editor/Cursor02C.json
  23. 16 0
      web/app/components/base/icons/src/vender/solid/editor/Cursor02C.tsx
  24. 38 0
      web/app/components/base/icons/src/vender/solid/editor/Hand02.json
  25. 16 0
      web/app/components/base/icons/src/vender/solid/editor/Hand02.tsx
  26. 2 0
      web/app/components/base/icons/src/vender/solid/editor/index.ts
  27. 12 3
      web/app/components/base/tooltip-plus/index.tsx
  28. 8 10
      web/app/components/workflow/block-selector/blocks.tsx
  29. 11 13
      web/app/components/workflow/block-selector/tools.tsx
  30. 81 0
      web/app/components/workflow/candidate-node.tsx
  31. 9 86
      web/app/components/workflow/header/run-and-history.tsx
  32. 3 0
      web/app/components/workflow/hooks/index.ts
  33. 131 86
      web/app/components/workflow/hooks/use-nodes-interactions.ts
  34. 37 0
      web/app/components/workflow/hooks/use-panel-interactions.ts
  35. 109 0
      web/app/components/workflow/hooks/use-selection-interactions.ts
  36. 88 0
      web/app/components/workflow/hooks/use-workflow-start-run.tsx
  37. 73 14
      web/app/components/workflow/index.tsx
  38. 44 0
      web/app/components/workflow/node-contextmenu.tsx
  39. 6 94
      web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx
  40. 181 0
      web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx
  41. 13 8
      web/app/components/workflow/nodes/_base/node.tsx
  42. 110 0
      web/app/components/workflow/operator/add-block.tsx
  43. 85 0
      web/app/components/workflow/operator/control.tsx
  44. 8 39
      web/app/components/workflow/operator/index.tsx
  45. 34 0
      web/app/components/workflow/operator/tip-popup.tsx
  46. 177 29
      web/app/components/workflow/operator/zoom-in-out.tsx
  47. 123 0
      web/app/components/workflow/panel-contextmenu.tsx
  48. 32 0
      web/app/components/workflow/shortcuts-name.tsx
  49. 38 0
      web/app/components/workflow/store.ts
  50. 11 0
      web/app/components/workflow/style.css
  51. 3 0
      web/app/components/workflow/types.ts
  52. 45 0
      web/app/components/workflow/utils.ts
  53. 6 0
      web/i18n/en-US/workflow.ts
  54. 6 0
      web/i18n/zh-Hans/workflow.ts

二進制
web/app/components/base/icons/assets/vender/line/arrows/flip-backward.zip


二進制
web/app/components/base/icons/assets/vender/line/arrows/flip-forward.zip


+ 5 - 0
web/app/components/base/icons/assets/vender/line/arrows/reverse-left.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Icon_2" d="M2.66699 4.66667H9.33366C11.5428 4.66667 13.3337 6.45753 13.3337 8.66667C13.3337 10.8758 11.5428 12.6667 9.33366 12.6667H2.66699M2.66699 4.66667L5.33366 2M2.66699 4.66667L5.33366 7.33333" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+</svg>

+ 5 - 0
web/app/components/base/icons/assets/vender/line/editor/cursor-02c.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Vector" d="M2.4598 3.3093L6.05377 13.551C6.25503 14.1246 7.05599 14.1516 7.29552 13.593L9.08053 9.43022C9.14793 9.27295 9.27326 9.14762 9.43053 9.08022L13.5933 7.29522C14.1519 7.05569 14.1249 6.25472 13.5513 6.05346L3.30961 2.45949C2.78207 2.27437 2.27468 2.78176 2.4598 3.3093Z" stroke="#667085" stroke-width="1.5" stroke-linejoin="round"/>
+</g>
+</svg>

+ 5 - 0
web/app/components/base/icons/assets/vender/line/editor/hand-02.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Vector" d="M11.3344 5C11.3344 4.44771 11.7821 4 12.3344 4C12.8867 4 13.3344 4.44771 13.3344 5V9.21947C13.3344 11.8597 11.1941 14 8.55387 14C6.779 14 5.15019 13.0167 4.32353 11.446L2.53767 8.05287C2.41421 7.81827 2.44145 7.53287 2.60703 7.32587L2.83481 7.04113C3.29483 6.46614 4.13389 6.37291 4.7089 6.83293L5.33441 7.33333V3.66667C5.33441 3.11438 5.78213 2.66667 6.33441 2.66667C6.88667 2.66667 7.3344 3.11438 7.3344 3.66667M11.3344 5V3.66667C11.3344 3.11438 10.8867 2.66667 10.3344 2.66667C9.78213 2.66667 9.3344 3.11438 9.3344 3.66667M11.3344 5V8M7.3344 3.66667V3C7.3344 2.44771 7.78213 2 8.3344 2C8.88667 2 9.3344 2.44771 9.3344 3V3.66667M7.3344 3.66667V7.33333M9.3344 3.66667V7.66667" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+</svg>

+ 5 - 0
web/app/components/base/icons/assets/vender/line/editor/zoom-in.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Icon_2" d="M14 14L11.1 11.1M7.33333 5.33333V9.33333M5.33333 7.33333H9.33333M12.6667 7.33333C12.6667 10.2789 10.2789 12.6667 7.33333 12.6667C4.38781 12.6667 2 10.2789 2 7.33333C2 4.38781 4.38781 2 7.33333 2C10.2789 2 12.6667 4.38781 12.6667 7.33333Z" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+</svg>

+ 5 - 0
web/app/components/base/icons/assets/vender/line/editor/zoom-out.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Icon_2" d="M14 14L11.1 11.1M5.33333 7.33333H9.33333M12.6667 7.33333C12.6667 10.2789 10.2789 12.6667 7.33333 12.6667C4.38781 12.6667 2 10.2789 2 7.33333C2 4.38781 4.38781 2 7.33333 2C10.2789 2 12.6667 4.38781 12.6667 7.33333Z" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+</svg>

+ 5 - 0
web/app/components/base/icons/assets/vender/solid/editor/cursor-02c.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Vector" d="M3.53647 1.81277C2.46674 1.43738 1.43787 2.46625 1.81326 3.53598L5.40722 13.7777C5.81532 14.9407 7.43953 14.9956 7.92526 13.8628L9.70733 9.70683L13.8633 7.92476C14.9961 7.4391 14.9412 5.81484 13.7782 5.40674L3.53647 1.81277Z" fill="#155EEF"/>
+</g>
+</svg>

+ 5 - 0
web/app/components/base/icons/assets/vender/solid/editor/hand-02.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Vector" fill-rule="evenodd" clip-rule="evenodd" d="M8.04519 1.33331C7.62792 1.33331 7.28966 1.66963 7.28966 2.08449V6.59153C7.28966 6.79898 7.12053 6.96711 6.91193 6.96711C6.70333 6.96711 6.53417 6.79898 6.53417 6.59153V2.83566C6.53417 2.4208 6.19593 2.08449 5.77868 2.08449C5.36143 2.08449 5.02318 2.4208 5.02318 2.83566V7.43091C5.02318 7.58418 4.92957 7.72205 4.78663 7.77931C4.6437 7.83658 4.4801 7.80178 4.37325 7.69138L3.47554 6.76385C2.95809 6.22921 2.07117 6.32919 1.68723 6.96545L1.66699 6.99898L3.52969 11.5222C4.31291 13.4242 6.17482 14.6666 8.24186 14.6666C11.054 14.6666 13.3337 12.4 13.3337 9.60398V4.33801C13.3337 3.92315 12.9954 3.58683 12.5782 3.58683C12.1609 3.58683 11.8227 3.92315 11.8227 4.33801V7.34271C11.8227 7.55011 11.6535 7.71831 11.4449 7.71831C11.2363 7.71831 11.0672 7.55011 11.0672 7.34271V2.83566C11.0672 2.4208 10.7289 2.08449 10.3117 2.08449C9.89439 2.08449 9.55619 2.4208 9.55619 2.83566V6.96711C9.55619 7.17458 9.38706 7.34271 9.17839 7.34271C8.96979 7.34271 8.80066 7.17458 8.80066 6.96711V2.08449C8.80066 1.66963 8.46239 1.33331 8.04519 1.33331Z" fill="#155EEF"/>
+</g>
+</svg>

+ 39 - 0
web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.json

@@ -0,0 +1,39 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "Icon"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Icon_2",
+							"d": "M2.66699 4.66667H9.33366C11.5428 4.66667 13.3337 6.45753 13.3337 8.66667C13.3337 10.8758 11.5428 12.6667 9.33366 12.6667H2.66699M2.66699 4.66667L5.33366 2M2.66699 4.66667L5.33366 7.33333",
+							"stroke": "currentColor",
+							"stroke-width": "1.5",
+							"stroke-linecap": "round",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "ReverseLeft"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './ReverseLeft.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'ReverseLeft'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/line/arrows/index.ts

@@ -9,3 +9,4 @@ export { default as Collapse04 } from './Collapse04'
 export { default as FlipBackward } from './FlipBackward'
 export { default as RefreshCcw01 } from './RefreshCcw01'
 export { default as RefreshCw05 } from './RefreshCw05'
+export { default as ReverseLeft } from './ReverseLeft'

+ 38 - 0
web/app/components/base/icons/src/vender/line/editor/Cursor02C.json

@@ -0,0 +1,38 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "Icon"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Vector",
+							"d": "M2.4598 3.3093L6.05377 13.551C6.25503 14.1246 7.05599 14.1516 7.29552 13.593L9.08053 9.43022C9.14793 9.27295 9.27326 9.14762 9.43053 9.08022L13.5933 7.29522C14.1519 7.05569 14.1249 6.25472 13.5513 6.05346L3.30961 2.45949C2.78207 2.27437 2.27468 2.78176 2.4598 3.3093Z",
+							"stroke": "currentColor",
+							"stroke-width": "1.5",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "Cursor02C"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/editor/Cursor02C.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Cursor02C.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Cursor02C'
+
+export default Icon

+ 39 - 0
web/app/components/base/icons/src/vender/line/editor/Hand02.json

@@ -0,0 +1,39 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "Icon"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Vector",
+							"d": "M11.3344 5C11.3344 4.44771 11.7821 4 12.3344 4C12.8867 4 13.3344 4.44771 13.3344 5V9.21947C13.3344 11.8597 11.1941 14 8.55387 14C6.779 14 5.15019 13.0167 4.32353 11.446L2.53767 8.05287C2.41421 7.81827 2.44145 7.53287 2.60703 7.32587L2.83481 7.04113C3.29483 6.46614 4.13389 6.37291 4.7089 6.83293L5.33441 7.33333V3.66667C5.33441 3.11438 5.78213 2.66667 6.33441 2.66667C6.88667 2.66667 7.3344 3.11438 7.3344 3.66667M11.3344 5V3.66667C11.3344 3.11438 10.8867 2.66667 10.3344 2.66667C9.78213 2.66667 9.3344 3.11438 9.3344 3.66667M11.3344 5V8M7.3344 3.66667V3C7.3344 2.44771 7.78213 2 8.3344 2C8.88667 2 9.3344 2.44771 9.3344 3V3.66667M7.3344 3.66667V7.33333M9.3344 3.66667V7.66667",
+							"stroke": "currentColor",
+							"stroke-width": "1.5",
+							"stroke-linecap": "round",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "Hand02"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/editor/Hand02.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Hand02.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Hand02'
+
+export default Icon

+ 39 - 0
web/app/components/base/icons/src/vender/line/editor/ZoomIn.json

@@ -0,0 +1,39 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "Icon"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Icon_2",
+							"d": "M14 14L11.1 11.1M7.33333 5.33333V9.33333M5.33333 7.33333H9.33333M12.6667 7.33333C12.6667 10.2789 10.2789 12.6667 7.33333 12.6667C4.38781 12.6667 2 10.2789 2 7.33333C2 4.38781 4.38781 2 7.33333 2C10.2789 2 12.6667 4.38781 12.6667 7.33333Z",
+							"stroke": "currentColor",
+							"stroke-width": "1.5",
+							"stroke-linecap": "round",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "ZoomIn"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/editor/ZoomIn.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './ZoomIn.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'ZoomIn'
+
+export default Icon

+ 39 - 0
web/app/components/base/icons/src/vender/line/editor/ZoomOut.json

@@ -0,0 +1,39 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "Icon"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Icon_2",
+							"d": "M14 14L11.1 11.1M5.33333 7.33333H9.33333M12.6667 7.33333C12.6667 10.2789 10.2789 12.6667 7.33333 12.6667C4.38781 12.6667 2 10.2789 2 7.33333C2 4.38781 4.38781 2 7.33333 2C10.2789 2 12.6667 4.38781 12.6667 7.33333Z",
+							"stroke": "currentColor",
+							"stroke-width": "1.5",
+							"stroke-linecap": "round",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "ZoomOut"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/editor/ZoomOut.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './ZoomOut.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'ZoomOut'
+
+export default Icon

+ 4 - 0
web/app/components/base/icons/src/vender/line/editor/index.ts

@@ -1,7 +1,11 @@
 export { default as AlignLeft } from './AlignLeft'
 export { default as BezierCurve03 } from './BezierCurve03'
 export { default as Colors } from './Colors'
+export { default as Cursor02C } from './Cursor02C'
+export { default as Hand02 } from './Hand02'
 export { default as ImageIndentLeft } from './ImageIndentLeft'
 export { default as LeftIndent02 } from './LeftIndent02'
 export { default as LetterSpacing01 } from './LetterSpacing01'
 export { default as TypeSquare } from './TypeSquare'
+export { default as ZoomIn } from './ZoomIn'
+export { default as ZoomOut } from './ZoomOut'

+ 36 - 0
web/app/components/base/icons/src/vender/solid/editor/Cursor02C.json

@@ -0,0 +1,36 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "Icon"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Vector",
+							"d": "M3.53647 1.81277C2.46674 1.43738 1.43787 2.46625 1.81326 3.53598L5.40722 13.7777C5.81532 14.9407 7.43953 14.9956 7.92526 13.8628L9.70733 9.70683L13.8633 7.92476C14.9961 7.4391 14.9412 5.81484 13.7782 5.40674L3.53647 1.81277Z",
+							"fill": "currentColor"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "Cursor02C"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/solid/editor/Cursor02C.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Cursor02C.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Cursor02C'
+
+export default Icon

+ 38 - 0
web/app/components/base/icons/src/vender/solid/editor/Hand02.json

@@ -0,0 +1,38 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "Icon"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Vector",
+							"fill-rule": "evenodd",
+							"clip-rule": "evenodd",
+							"d": "M8.04519 1.33331C7.62792 1.33331 7.28966 1.66963 7.28966 2.08449V6.59153C7.28966 6.79898 7.12053 6.96711 6.91193 6.96711C6.70333 6.96711 6.53417 6.79898 6.53417 6.59153V2.83566C6.53417 2.4208 6.19593 2.08449 5.77868 2.08449C5.36143 2.08449 5.02318 2.4208 5.02318 2.83566V7.43091C5.02318 7.58418 4.92957 7.72205 4.78663 7.77931C4.6437 7.83658 4.4801 7.80178 4.37325 7.69138L3.47554 6.76385C2.95809 6.22921 2.07117 6.32919 1.68723 6.96545L1.66699 6.99898L3.52969 11.5222C4.31291 13.4242 6.17482 14.6666 8.24186 14.6666C11.054 14.6666 13.3337 12.4 13.3337 9.60398V4.33801C13.3337 3.92315 12.9954 3.58683 12.5782 3.58683C12.1609 3.58683 11.8227 3.92315 11.8227 4.33801V7.34271C11.8227 7.55011 11.6535 7.71831 11.4449 7.71831C11.2363 7.71831 11.0672 7.55011 11.0672 7.34271V2.83566C11.0672 2.4208 10.7289 2.08449 10.3117 2.08449C9.89439 2.08449 9.55619 2.4208 9.55619 2.83566V6.96711C9.55619 7.17458 9.38706 7.34271 9.17839 7.34271C8.96979 7.34271 8.80066 7.17458 8.80066 6.96711V2.08449C8.80066 1.66963 8.46239 1.33331 8.04519 1.33331Z",
+							"fill": "currentColor"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "Hand02"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/solid/editor/Hand02.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Hand02.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Hand02'
+
+export default Icon

+ 2 - 0
web/app/components/base/icons/src/vender/solid/editor/index.ts

@@ -1,5 +1,7 @@
 export { default as Brush01 } from './Brush01'
 export { default as Citations } from './Citations'
 export { default as Colors } from './Colors'
+export { default as Cursor02C } from './Cursor02C'
+export { default as Hand02 } from './Hand02'
 export { default as Paragraph } from './Paragraph'
 export { default as TypeSquare } from './TypeSquare'

+ 12 - 3
web/app/components/base/tooltip-plus/index.tsx

@@ -1,13 +1,17 @@
 'use client'
 import type { FC } from 'react'
 import React, { useState } from 'react'
+import cn from 'classnames'
+import type { OffsetOptions, Placement } from '@floating-ui/react'
 import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
 export type TooltipProps = {
-  position?: 'top' | 'right' | 'bottom' | 'left'
+  position?: Placement
   triggerMethod?: 'hover' | 'click'
   popupContent: React.ReactNode
   children: React.ReactNode
   hideArrow?: boolean
+  popupClassName?: string
+  offset?: OffsetOptions
 }
 
 const arrow = (
@@ -20,6 +24,8 @@ const Tooltip: FC<TooltipProps> = ({
   popupContent,
   children,
   hideArrow,
+  popupClassName,
+  offset,
 }) => {
   const [open, setOpen] = useState(false)
 
@@ -28,7 +34,7 @@ const Tooltip: FC<TooltipProps> = ({
       open={open}
       onOpenChange={setOpen}
       placement={position}
-      offset={10}
+      offset={offset ?? 10}
     >
       <PortalToFollowElemTrigger
         onClick={() => triggerMethod === 'click' && setOpen(v => !v)}
@@ -40,7 +46,10 @@ const Tooltip: FC<TooltipProps> = ({
       <PortalToFollowElemContent
         className="z-[9999]"
       >
-        <div className='relative px-3 py-2 text-xs font-normal text-gray-700 bg-white rounded-md shadow-lg'>
+        <div className={cn(
+          'relative px-3 py-2 text-xs font-normal text-gray-700 bg-white rounded-md shadow-lg',
+          popupClassName,
+        )}>
           {popupContent}
           {!hideArrow && arrow}
         </div>

+ 8 - 10
web/app/components/workflow/block-selector/blocks.tsx

@@ -72,15 +72,13 @@ const Blocks = ({
               className='!p-0 !px-3 !py-2.5 !w-[200px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !bg-transparent !rounded-xl !shadow-lg'
               htmlContent={(
                 <div>
-                  <div className='flex items-center mb-2'>
-                    <BlockIcon
-                      size='md'
-                      className='mr-2'
-                      type={block.type}
-                    />
-                    <div className='text-sm text-gray-900'>{block.title}</div>
-                  </div>
-                  {nodesExtraData[block.type].about}
+                  <BlockIcon
+                    size='md'
+                    className='mb-2'
+                    type={block.type}
+                  />
+                  <div className='mb-1 text-sm leading-5 text-gray-900'>{block.title}</div>
+                  <div className='text-xs text-gray-700 leading-[18px]'>{nodesExtraData[block.type].about}</div>
                 </div>
               )}
               noArrow
@@ -91,7 +89,7 @@ const Blocks = ({
                 onClick={() => onSelect(block.type)}
               >
                 <BlockIcon
-                  className='mr-2'
+                  className='mr-2 shrink-0'
                   type={block.type}
                 />
                 <div className='text-sm text-gray-900'>{block.title}</div>

+ 11 - 13
web/app/components/workflow/block-selector/tools.tsx

@@ -57,16 +57,14 @@ const Blocks = ({
               className='!p-0 !px-3 !py-2.5 !w-[200px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !bg-transparent !rounded-xl !shadow-lg'
               htmlContent={(
                 <div>
-                  <div className='flex items-center mb-2'>
-                    <BlockIcon
-                      size='md'
-                      className='mr-2'
-                      type={BlockEnum.Tool}
-                      toolIcon={toolWithProvider.icon}
-                    />
-                    <div className='text-sm text-gray-900'>{tool.label[language]}</div>
-                  </div>
-                  {tool.description[language]}
+                  <BlockIcon
+                    size='md'
+                    className='mb-2'
+                    type={BlockEnum.Tool}
+                    toolIcon={toolWithProvider.icon}
+                  />
+                  <div className='mb-1 text-sm leading-5 text-gray-900'>{tool.label[language]}</div>
+                  <div className='text-xs text-gray-700 leading-[18px]'>{tool.description[language]}</div>
                 </div>
               )}
               noArrow
@@ -83,11 +81,11 @@ const Blocks = ({
                 })}
               >
                 <BlockIcon
-                  className='mr-2'
+                  className='mr-2 shrink-0'
                   type={BlockEnum.Tool}
                   toolIcon={toolWithProvider.icon}
                 />
-                <div className='text-sm text-gray-900'>{tool.label[language]}</div>
+                <div className='text-sm text-gray-900 truncate'>{tool.label[language]}</div>
               </div>
             </Tooltip>
           ))
@@ -97,7 +95,7 @@ const Blocks = ({
   }, [onSelect, language])
 
   return (
-    <div className='p-1 max-h-[464px] overflow-y-auto'>
+    <div className='p-1 max-w-[320px] max-h-[464px] overflow-y-auto'>
       {
         !tools.length && (
           <div className='flex items-center px-3 h-[22px] text-xs font-medium text-gray-500'>{t('workflow.tabs.noResult')}</div>

+ 81 - 0
web/app/components/workflow/candidate-node.tsx

@@ -0,0 +1,81 @@
+import {
+  memo,
+} from 'react'
+import produce from 'immer'
+import {
+  useReactFlow,
+  useStoreApi,
+  useViewport,
+} from 'reactflow'
+import { useEventListener } from 'ahooks'
+import {
+  useStore,
+  useWorkflowStore,
+} from './store'
+import CustomNode from './nodes'
+
+const CandidateNode = () => {
+  const store = useStoreApi()
+  const reactflow = useReactFlow()
+  const workflowStore = useWorkflowStore()
+  const candidateNode = useStore(s => s.candidateNode)
+  const mousePosition = useStore(s => s.mousePosition)
+  const { zoom } = useViewport()
+
+  useEventListener('click', (e) => {
+    const { candidateNode, mousePosition } = workflowStore.getState()
+
+    if (candidateNode) {
+      e.preventDefault()
+      const {
+        getNodes,
+        setNodes,
+      } = store.getState()
+      const { screenToFlowPosition } = reactflow
+      const nodes = getNodes()
+      const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
+      const newNodes = produce(nodes, (draft) => {
+        draft.push({
+          ...candidateNode,
+          data: {
+            ...candidateNode.data,
+            _isCandidate: false,
+          },
+          position: {
+            x,
+            y,
+          },
+        })
+      })
+      setNodes(newNodes)
+      workflowStore.setState({ candidateNode: undefined })
+    }
+  })
+
+  useEventListener('contextmenu', (e) => {
+    const { candidateNode } = workflowStore.getState()
+    if (candidateNode) {
+      e.preventDefault()
+      workflowStore.setState({ candidateNode: undefined })
+    }
+  })
+
+  if (!candidateNode)
+    return null
+
+  return (
+    <div
+      className='absolute z-10'
+      style={{
+        left: mousePosition.elementX,
+        top: mousePosition.elementY,
+        transform: `scale(${zoom})`,
+        transformOrigin: '0 0',
+      }}
+    >
+      <CustomNode {...candidateNode as any} />
+    </div>
+  )
+}
+
+export default memo(CandidateNode)

+ 9 - 86
web/app/components/workflow/header/run-and-history.tsx

@@ -1,90 +1,29 @@
 import type { FC } from 'react'
-import { memo, useCallback } from 'react'
+import { memo } from 'react'
 import { useTranslation } from 'react-i18next'
-import { useStoreApi } from 'reactflow'
 import cn from 'classnames'
-import {
-  useStore,
-  useWorkflowStore,
-} from '../store'
+import { useStore } from '../store'
 import {
   useIsChatMode,
-  useNodesSyncDraft,
-  useWorkflowInteractions,
   useWorkflowRun,
+  useWorkflowStartRun,
 } from '../hooks'
-import {
-  BlockEnum,
-  WorkflowRunningStatus,
-} from '../types'
+import { WorkflowRunningStatus } from '../types'
 import ViewHistory from './view-history'
 import {
   Play,
   StopCircle,
 } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
 import { Loading02 } from '@/app/components/base/icons/src/vender/line/general'
-import { useFeaturesStore } from '@/app/components/base/features/hooks'
 import { MessagePlay } from '@/app/components/base/icons/src/vender/line/communication'
 
 const RunMode = memo(() => {
   const { t } = useTranslation()
-  const store = useStoreApi()
-  const workflowStore = useWorkflowStore()
-  const featuresStore = useFeaturesStore()
-  const {
-    handleStopRun,
-    handleRun,
-  } = useWorkflowRun()
-  const {
-    doSyncWorkflowDraft,
-  } = useNodesSyncDraft()
-  const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
+  const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun()
+  const { handleStopRun } = useWorkflowRun()
   const workflowRunningData = useStore(s => s.workflowRunningData)
   const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running
 
-  const handleClick = useCallback(async () => {
-    const {
-      workflowRunningData,
-    } = workflowStore.getState()
-
-    if (workflowRunningData?.result.status === WorkflowRunningStatus.Running)
-      return
-
-    const { getNodes } = store.getState()
-    const nodes = getNodes()
-    const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
-    const startVariables = startNode?.data.variables || []
-    const fileSettings = featuresStore!.getState().features.file
-    const {
-      showDebugAndPreviewPanel,
-      setShowDebugAndPreviewPanel,
-      setShowInputsPanel,
-    } = workflowStore.getState()
-
-    if (showDebugAndPreviewPanel) {
-      handleCancelDebugAndPreviewPanel()
-      return
-    }
-
-    if (!startVariables.length && !fileSettings?.image?.enabled) {
-      await doSyncWorkflowDraft()
-      handleRun({ inputs: {}, files: [] })
-      setShowDebugAndPreviewPanel(true)
-      setShowInputsPanel(false)
-    }
-    else {
-      setShowDebugAndPreviewPanel(true)
-      setShowInputsPanel(true)
-    }
-  }, [
-    workflowStore,
-    handleRun,
-    doSyncWorkflowDraft,
-    store,
-    featuresStore,
-    handleCancelDebugAndPreviewPanel,
-  ])
-
   return (
     <>
       <div
@@ -93,7 +32,7 @@ const RunMode = memo(() => {
           'hover:bg-primary-50 cursor-pointer',
           isRunning && 'bg-primary-50 !cursor-not-allowed',
         )}
-        onClick={handleClick}
+        onClick={() => handleWorkflowStartRunInWorkflow()}
       >
         {
           isRunning
@@ -128,23 +67,7 @@ RunMode.displayName = 'RunMode'
 
 const PreviewMode = memo(() => {
   const { t } = useTranslation()
-  const workflowStore = useWorkflowStore()
-  const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
-
-  const handleClick = () => {
-    const {
-      showDebugAndPreviewPanel,
-      setShowDebugAndPreviewPanel,
-      setHistoryWorkflowData,
-    } = workflowStore.getState()
-
-    if (showDebugAndPreviewPanel)
-      handleCancelDebugAndPreviewPanel()
-    else
-      setShowDebugAndPreviewPanel(true)
-
-    setHistoryWorkflowData(undefined)
-  }
+  const { handleWorkflowStartRunInChatflow } = useWorkflowStartRun()
 
   return (
     <div
@@ -152,7 +75,7 @@ const PreviewMode = memo(() => {
         'flex items-center px-1.5 h-7 rounded-md text-[13px] font-medium text-primary-600',
         'hover:bg-primary-50 cursor-pointer',
       )}
-      onClick={() => handleClick()}
+      onClick={() => handleWorkflowStartRunInChatflow()}
     >
       <MessagePlay className='mr-1 w-4 h-4' />
       {t('workflow.common.debugAndPreview')}

+ 3 - 0
web/app/components/workflow/hooks/index.ts

@@ -9,3 +9,6 @@ export * from './use-workflow-template'
 export * from './use-checklist'
 export * from './use-workflow-mode'
 export * from './use-workflow-interactions'
+export * from './use-selection-interactions'
+export * from './use-panel-interactions'
+export * from './use-workflow-start-run'

+ 131 - 86
web/app/components/workflow/hooks/use-nodes-interactions.ts

@@ -1,3 +1,4 @@
+import type { MouseEvent } from 'react'
 import { useCallback, useRef } from 'react'
 import { useTranslation } from 'react-i18next'
 import produce from 'immer'
@@ -11,6 +12,7 @@ import type {
 import {
   getConnectedEdges,
   getOutgoers,
+  useReactFlow,
   useStoreApi,
 } from 'reactflow'
 import type { ToolDefaultValue } from '../block-selector/types'
@@ -29,6 +31,7 @@ import {
 import {
   generateNewNode,
   getNodesConnectedSourceOrTargetHandleIdsMap,
+  getTopLeftNodePosition,
 } from '../utils'
 import { useNodesExtraData } from './use-nodes-data'
 import { useNodesSyncDraft } from './use-nodes-sync-draft'
@@ -41,6 +44,7 @@ export const useNodesInteractions = () => {
   const { t } = useTranslation()
   const store = useStoreApi()
   const workflowStore = useWorkflowStore()
+  const reactflow = useReactFlow()
   const nodesExtraData = useNodesExtraData()
   const { handleSyncWorkflowDraft } = useNodesSyncDraft()
   const {
@@ -705,7 +709,51 @@ export const useNodesInteractions = () => {
     handleSyncWorkflowDraft()
   }, [store, handleSyncWorkflowDraft, getNodesReadOnly, t])
 
-  const handleNodeCopySelected = useCallback((): undefined | Node[] => {
+  const handleNodeCancelRunningStatus = useCallback(() => {
+    const {
+      getNodes,
+      setNodes,
+    } = store.getState()
+
+    const nodes = getNodes()
+    const newNodes = produce(nodes, (draft) => {
+      draft.forEach((node) => {
+        node.data._runningStatus = undefined
+      })
+    })
+    setNodes(newNodes)
+  }, [store])
+
+  const handleNodesCancelSelected = useCallback(() => {
+    const {
+      getNodes,
+      setNodes,
+    } = store.getState()
+
+    const nodes = getNodes()
+    const newNodes = produce(nodes, (draft) => {
+      draft.forEach((node) => {
+        node.data.selected = false
+      })
+    })
+    setNodes(newNodes)
+  }, [store])
+
+  const handleNodeContextMenu = useCallback((e: MouseEvent, node: Node) => {
+    e.preventDefault()
+    const container = document.querySelector('#workflow-container')
+    const { x, y } = container!.getBoundingClientRect()
+    workflowStore.setState({
+      nodeMenu: {
+        top: e.clientY - y,
+        left: e.clientX - x,
+        nodeId: node.id,
+      },
+    })
+    handleNodeSelect(node.id)
+  }, [workflowStore, handleNodeSelect])
+
+  const handleNodesCopy = useCallback(() => {
     if (getNodesReadOnly())
       return
 
@@ -723,14 +771,20 @@ export const useNodesInteractions = () => {
     } = store.getState()
 
     const nodes = getNodes()
-    const nodesToCopy = nodes.filter(node => node.data.selected && node.data.type !== BlockEnum.Start)
+    const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
 
-    setClipboardElements(nodesToCopy)
+    if (bundledNodes.length) {
+      setClipboardElements(bundledNodes)
+      return
+    }
+
+    const selectedNode = nodes.find(node => node.data.selected && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
 
-    return nodesToCopy
+    if (selectedNode)
+      setClipboardElements([selectedNode])
   }, [getNodesReadOnly, store, workflowStore])
 
-  const handleNodePaste = useCallback((): undefined | Node[] => {
+  const handleNodesPaste = useCallback(() => {
     if (getNodesReadOnly())
       return
 
@@ -738,6 +792,7 @@ export const useNodesInteractions = () => {
       clipboardElements,
       shortcutsDisabled,
       showFeaturesPanel,
+      mousePosition,
     } = workflowStore.getState()
 
     if (shortcutsDisabled || showFeaturesPanel)
@@ -751,55 +806,77 @@ export const useNodesInteractions = () => {
     const nodesToPaste: Node[] = []
     const nodes = getNodes()
 
-    for (const nodeToPaste of clipboardElements) {
-      const nodeType = nodeToPaste.data.type
+    if (clipboardElements.length) {
+      const { x, y } = getTopLeftNodePosition(clipboardElements)
+      const { screenToFlowPosition } = reactflow
+      const currentPosition = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
+      const offsetX = currentPosition.x - x
+      const offsetY = currentPosition.y - y
+      clipboardElements.forEach((nodeToPaste, index) => {
+        const nodeType = nodeToPaste.data.type
+        const nodesWithSameType = nodes.filter(node => node.data.type === nodeType)
+
+        const newNode = generateNewNode({
+          data: {
+            ...NODES_INITIAL_DATA[nodeType],
+            ...nodeToPaste.data,
+            selected: false,
+            _isBundled: false,
+            _connectedSourceHandleIds: [],
+            _connectedTargetHandleIds: [],
+            title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${nodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${nodeType}`),
+          },
+          position: {
+            x: nodeToPaste.position.x + offsetX,
+            y: nodeToPaste.position.y + offsetY,
+          },
+        })
+        newNode.id = newNode.id + index
+        nodesToPaste.push(newNode)
+      })
+
+      setNodes([...nodes, ...nodesToPaste])
+      handleSyncWorkflowDraft()
+    }
+  }, [t, getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, reactflow])
+
+  const handleNodesDuplicate = useCallback(() => {
+    if (getNodesReadOnly())
+      return
+
+    const {
+      getNodes,
+      setNodes,
+    } = store.getState()
+    const nodes = getNodes()
+
+    const selectedNode = nodes.find(node => node.data.selected && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
+
+    if (selectedNode) {
+      const nodeType = selectedNode.data.type
       const nodesWithSameType = nodes.filter(node => node.data.type === nodeType)
 
       const newNode = generateNewNode({
         data: {
-          ...NODES_INITIAL_DATA[nodeType],
-          ...nodeToPaste.data,
+          ...NODES_INITIAL_DATA[nodeType as BlockEnum],
+          ...selectedNode.data,
+          selected: false,
+          _isBundled: false,
           _connectedSourceHandleIds: [],
           _connectedTargetHandleIds: [],
           title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${nodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${nodeType}`),
-          selected: true,
         },
         position: {
-          x: nodeToPaste.position.x + 10,
-          y: nodeToPaste.position.y + 10,
+          x: selectedNode.position.x + selectedNode.width! + 10,
+          y: selectedNode.position.y,
         },
       })
-      nodesToPaste.push(newNode)
-    }
-
-    setNodes([...nodes.map((n: Node) => ({ ...n, selected: false, data: { ...n.data, selected: false } })), ...nodesToPaste])
-
-    handleSyncWorkflowDraft()
-
-    return nodesToPaste
-  }, [getNodesReadOnly, handleSyncWorkflowDraft, store, t, workflowStore])
-
-  const handleNodeDuplicateSelected = useCallback(() => {
-    if (getNodesReadOnly())
-      return
-
-    handleNodeCopySelected()
-    handleNodePaste()
-  }, [getNodesReadOnly, handleNodeCopySelected, handleNodePaste])
-
-  const handleNodeCut = useCallback(() => {
-    if (getNodesReadOnly())
-      return
-
-    const nodesToCut = handleNodeCopySelected()
-    if (!nodesToCut)
-      return
 
-    for (const node of nodesToCut)
-      handleNodeDelete(node.id)
-  }, [getNodesReadOnly, handleNodeCopySelected, handleNodeDelete])
+      setNodes([...nodes, newNode])
+    }
+  }, [store, t, getNodesReadOnly])
 
-  const handleNodeDeleteSelected = useCallback(() => {
+  const handleNodesDelete = useCallback(() => {
     if (getNodesReadOnly())
       return
 
@@ -813,53 +890,21 @@ export const useNodesInteractions = () => {
 
     const {
       getNodes,
-      edges,
     } = store.getState()
 
-    const currentEdgeIndex = edges.findIndex(edge => edge.selected)
-
-    if (currentEdgeIndex > -1)
-      return
-
     const nodes = getNodes()
-    const nodesToDelete = nodes.filter(node => node.data.selected)
+    const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
 
-    if (!nodesToDelete)
+    if (bundledNodes.length) {
+      bundledNodes.forEach(node => handleNodeDelete(node.id))
       return
+    }
 
-    for (const node of nodesToDelete)
-      handleNodeDelete(node.id)
-  }, [getNodesReadOnly, handleNodeDelete, store, workflowStore])
-
-  const handleNodeCancelRunningStatus = useCallback(() => {
-    const {
-      getNodes,
-      setNodes,
-    } = store.getState()
-
-    const nodes = getNodes()
-    const newNodes = produce(nodes, (draft) => {
-      draft.forEach((node) => {
-        node.data._runningStatus = undefined
-      })
-    })
-    setNodes(newNodes)
-  }, [store])
-
-  const handleNodesCancelSelected = useCallback(() => {
-    const {
-      getNodes,
-      setNodes,
-    } = store.getState()
+    const selectedNode = nodes.find(node => node.data.selected && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
 
-    const nodes = getNodes()
-    const newNodes = produce(nodes, (draft) => {
-      draft.forEach((node) => {
-        node.data.selected = false
-      })
-    })
-    setNodes(newNodes)
-  }, [store])
+    if (selectedNode)
+      handleNodeDelete(selectedNode.id)
+  }, [store, workflowStore, getNodesReadOnly, handleNodeDelete])
 
   return {
     handleNodeDragStart,
@@ -875,12 +920,12 @@ export const useNodesInteractions = () => {
     handleNodeDelete,
     handleNodeChange,
     handleNodeAdd,
-    handleNodeDuplicateSelected,
-    handleNodeCopySelected,
-    handleNodeCut,
-    handleNodeDeleteSelected,
-    handleNodePaste,
     handleNodeCancelRunningStatus,
     handleNodesCancelSelected,
+    handleNodeContextMenu,
+    handleNodesCopy,
+    handleNodesPaste,
+    handleNodesDuplicate,
+    handleNodesDelete,
   }
 }

+ 37 - 0
web/app/components/workflow/hooks/use-panel-interactions.ts

@@ -0,0 +1,37 @@
+import type { MouseEvent } from 'react'
+import { useCallback } from 'react'
+import { useWorkflowStore } from '../store'
+
+export const usePanelInteractions = () => {
+  const workflowStore = useWorkflowStore()
+
+  const handlePaneContextMenu = useCallback((e: MouseEvent) => {
+    e.preventDefault()
+    const container = document.querySelector('#workflow-container')
+    const { x, y } = container!.getBoundingClientRect()
+    workflowStore.setState({
+      panelMenu: {
+        top: e.clientY - y,
+        left: e.clientX - x,
+      },
+    })
+  }, [workflowStore])
+
+  const handlePaneContextmenuCancel = useCallback(() => {
+    workflowStore.setState({
+      panelMenu: undefined,
+    })
+  }, [workflowStore])
+
+  const handleNodeContextmenuCancel = useCallback(() => {
+    workflowStore.setState({
+      nodeMenu: undefined,
+    })
+  }, [workflowStore])
+
+  return {
+    handlePaneContextMenu,
+    handlePaneContextmenuCancel,
+    handleNodeContextmenuCancel,
+  }
+}

+ 109 - 0
web/app/components/workflow/hooks/use-selection-interactions.ts

@@ -0,0 +1,109 @@
+import type { MouseEvent } from 'react'
+import {
+  useCallback,
+} from 'react'
+import produce from 'immer'
+import type {
+  OnSelectionChangeFunc,
+} from 'reactflow'
+import { useStoreApi } from 'reactflow'
+import { useWorkflowStore } from '../store'
+import type { Node } from '../types'
+
+export const useSelectionInteractions = () => {
+  const store = useStoreApi()
+  const workflowStore = useWorkflowStore()
+
+  const handleSelectionStart = useCallback(() => {
+    const {
+      getNodes,
+      setNodes,
+      edges,
+      setEdges,
+      userSelectionRect,
+    } = store.getState()
+
+    if (!userSelectionRect?.width || !userSelectionRect?.height) {
+      const nodes = getNodes()
+      const newNodes = produce(nodes, (draft) => {
+        draft.forEach((node) => {
+          if (node.data._isBundled)
+            node.data._isBundled = false
+        })
+      })
+      setNodes(newNodes)
+      const newEdges = produce(edges, (draft) => {
+        draft.forEach((edge) => {
+          if (edge.data._isBundled)
+            edge.data._isBundled = false
+        })
+      })
+      setEdges(newEdges)
+    }
+  }, [store])
+
+  const handleSelectionChange = useCallback<OnSelectionChangeFunc>(({ nodes: nodesInSelection, edges: edgesInSelection }) => {
+    const {
+      getNodes,
+      setNodes,
+      edges,
+      setEdges,
+      userSelectionRect,
+    } = store.getState()
+
+    const nodes = getNodes()
+
+    if (!userSelectionRect?.width || !userSelectionRect?.height)
+      return
+
+    const newNodes = produce(nodes, (draft) => {
+      draft.forEach((node) => {
+        const nodeInSelection = nodesInSelection.find(n => n.id === node.id)
+
+        if (nodeInSelection)
+          node.data._isBundled = true
+        else
+          node.data._isBundled = false
+      })
+    })
+    setNodes(newNodes)
+    const newEdges = produce(edges, (draft) => {
+      draft.forEach((edge) => {
+        const edgeInSelection = edgesInSelection.find(e => e.id === edge.id)
+
+        if (edgeInSelection)
+          edge.data._isBundled = true
+        else
+          edge.data._isBundled = false
+      })
+    })
+    setEdges(newEdges)
+  }, [store])
+
+  const handleSelectionDrag = useCallback((_: MouseEvent, nodesWithDrag: Node[]) => {
+    const {
+      getNodes,
+      setNodes,
+    } = store.getState()
+
+    workflowStore.setState({
+      nodeAnimation: false,
+    })
+    const nodes = getNodes()
+    const newNodes = produce(nodes, (draft) => {
+      draft.forEach((node) => {
+        const dragNode = nodesWithDrag.find(n => n.id === node.id)
+
+        if (dragNode)
+          node.position = dragNode.position
+      })
+    })
+    setNodes(newNodes)
+  }, [store, workflowStore])
+
+  return {
+    handleSelectionStart,
+    handleSelectionChange,
+    handleSelectionDrag,
+  }
+}

+ 88 - 0
web/app/components/workflow/hooks/use-workflow-start-run.tsx

@@ -0,0 +1,88 @@
+import { useCallback } from 'react'
+import { useStoreApi } from 'reactflow'
+import { useWorkflowStore } from '../store'
+import {
+  BlockEnum,
+  WorkflowRunningStatus,
+} from '../types'
+import {
+  useIsChatMode,
+  useNodesSyncDraft,
+  useWorkflowInteractions,
+  useWorkflowRun,
+} from './index'
+import { useFeaturesStore } from '@/app/components/base/features/hooks'
+
+export const useWorkflowStartRun = () => {
+  const store = useStoreApi()
+  const workflowStore = useWorkflowStore()
+  const featuresStore = useFeaturesStore()
+  const isChatMode = useIsChatMode()
+  const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
+  const { handleRun } = useWorkflowRun()
+  const { doSyncWorkflowDraft } = useNodesSyncDraft()
+
+  const handleWorkflowStartRunInWorkflow = useCallback(async () => {
+    const {
+      workflowRunningData,
+    } = workflowStore.getState()
+
+    if (workflowRunningData?.result.status === WorkflowRunningStatus.Running)
+      return
+
+    const { getNodes } = store.getState()
+    const nodes = getNodes()
+    const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
+    const startVariables = startNode?.data.variables || []
+    const fileSettings = featuresStore!.getState().features.file
+    const {
+      showDebugAndPreviewPanel,
+      setShowDebugAndPreviewPanel,
+      setShowInputsPanel,
+    } = workflowStore.getState()
+
+    if (showDebugAndPreviewPanel) {
+      handleCancelDebugAndPreviewPanel()
+      return
+    }
+
+    if (!startVariables.length && !fileSettings?.image?.enabled) {
+      await doSyncWorkflowDraft()
+      handleRun({ inputs: {}, files: [] })
+      setShowDebugAndPreviewPanel(true)
+      setShowInputsPanel(false)
+    }
+    else {
+      setShowDebugAndPreviewPanel(true)
+      setShowInputsPanel(true)
+    }
+  }, [store, workflowStore, featuresStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft])
+
+  const handleWorkflowStartRunInChatflow = useCallback(async () => {
+    const {
+      showDebugAndPreviewPanel,
+      setShowDebugAndPreviewPanel,
+      setHistoryWorkflowData,
+    } = workflowStore.getState()
+
+    if (showDebugAndPreviewPanel)
+      handleCancelDebugAndPreviewPanel()
+    else
+      setShowDebugAndPreviewPanel(true)
+
+    setHistoryWorkflowData(undefined)
+  }, [workflowStore, handleCancelDebugAndPreviewPanel])
+
+  const handleStartWorkflowRun = useCallback(() => {
+    if (!isChatMode)
+      handleWorkflowStartRunInWorkflow()
+    else
+      handleWorkflowStartRunInChatflow()
+  }, [isChatMode, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow])
+
+  return {
+    handleStartWorkflowRun,
+    handleWorkflowStartRunInWorkflow,
+    handleWorkflowStartRunInChatflow,
+  }
+}

+ 73 - 14
web/app/components/workflow/index.tsx

@@ -6,19 +6,24 @@ import {
   useCallback,
   useEffect,
   useMemo,
+  useRef,
 } from 'react'
 import { setAutoFreeze } from 'immer'
 import {
+  useEventListener,
   useKeyPress,
 } from 'ahooks'
 import ReactFlow, {
   Background,
   ReactFlowProvider,
+  SelectionMode,
   useEdgesState,
   useNodesState,
   useOnViewportChange,
 } from 'reactflow'
-import type { Viewport } from 'reactflow'
+import type {
+  Viewport,
+} from 'reactflow'
 import 'reactflow/dist/style.css'
 import './style.css'
 import type {
@@ -31,9 +36,12 @@ import {
   useNodesInteractions,
   useNodesReadOnly,
   useNodesSyncDraft,
+  usePanelInteractions,
+  useSelectionInteractions,
   useWorkflow,
   useWorkflowInit,
   useWorkflowReadOnly,
+  useWorkflowStartRun,
 } from './hooks'
 import Header from './header'
 import CustomNode from './nodes'
@@ -43,8 +51,15 @@ import CustomConnectionLine from './custom-connection-line'
 import Panel from './panel'
 import Features from './features'
 import HelpLine from './help-line'
-import { useStore } from './store'
+import CandidateNode from './candidate-node'
+import PanelContextmenu from './panel-contextmenu'
+import NodeContextmenu from './node-contextmenu'
+import {
+  useStore,
+  useWorkflowStore,
+} from './store'
 import {
+  getKeyboardKeyCodeBySystem,
   initialEdges,
   initialNodes,
 } from './utils'
@@ -71,9 +86,12 @@ const Workflow: FC<WorkflowProps> = memo(({
   edges: originalEdges,
   viewport,
 }) => {
+  const workflowContainerRef = useRef<HTMLDivElement>(null)
+  const workflowStore = useWorkflowStore()
   const [nodes, setNodes] = useNodesState(originalNodes)
   const [edges, setEdges] = useEdgesState(originalEdges)
   const showFeaturesPanel = useStore(state => state.showFeaturesPanel)
+  const controlMode = useStore(s => s.controlMode)
   const nodeAnimation = useStore(s => s.nodeAnimation)
   const {
     handleSyncWorkflowDraft,
@@ -118,6 +136,25 @@ const Workflow: FC<WorkflowProps> = memo(({
     }
   }, [handleSyncWorkflowDraftWhenPageClose])
 
+  useEventListener('keydown', (e) => {
+    if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey))
+      e.preventDefault()
+  })
+  useEventListener('mousemove', (e) => {
+    const containerClientRect = workflowContainerRef.current?.getBoundingClientRect()
+
+    if (containerClientRect) {
+      workflowStore.setState({
+        mousePosition: {
+          pageX: e.clientX,
+          pageY: e.clientY,
+          elementX: e.clientX - containerClientRect.left,
+          elementY: e.clientY - containerClientRect.top,
+        },
+      })
+    }
+  })
+
   const {
     handleNodeDragStart,
     handleNodeDrag,
@@ -128,11 +165,11 @@ const Workflow: FC<WorkflowProps> = memo(({
     handleNodeConnect,
     handleNodeConnectStart,
     handleNodeConnectEnd,
-    handleNodeDuplicateSelected,
-    handleNodeCopySelected,
-    handleNodeCut,
-    handleNodeDeleteSelected,
-    handleNodePaste,
+    handleNodeContextMenu,
+    handleNodesCopy,
+    handleNodesPaste,
+    handleNodesDuplicate,
+    handleNodesDelete,
   } = useNodesInteractions()
   const {
     handleEdgeEnter,
@@ -140,9 +177,18 @@ const Workflow: FC<WorkflowProps> = memo(({
     handleEdgeDelete,
     handleEdgesChange,
   } = useEdgesInteractions()
+  const {
+    handleSelectionStart,
+    handleSelectionChange,
+    handleSelectionDrag,
+  } = useSelectionInteractions()
+  const {
+    handlePaneContextMenu,
+  } = usePanelInteractions()
   const {
     isValidConnection,
   } = useWorkflow()
+  const { handleStartWorkflowRun } = useWorkflowStartRun()
 
   useOnViewportChange({
     onEnd: () => {
@@ -150,12 +196,12 @@ const Workflow: FC<WorkflowProps> = memo(({
     },
   })
 
-  useKeyPress(['delete', 'backspace'], handleNodeDeleteSelected)
-  useKeyPress(['delete', 'backspace'], handleEdgeDelete)
-  useKeyPress(['ctrl.c', 'meta.c'], handleNodeCopySelected)
-  useKeyPress(['ctrl.x', 'meta.x'], handleNodeCut)
-  useKeyPress(['ctrl.v', 'meta.v'], handleNodePaste)
-  useKeyPress(['ctrl.alt.d', 'meta.shift.d'], handleNodeDuplicateSelected)
+  useKeyPress('delete', handleNodesDelete)
+  useKeyPress('delete', handleEdgeDelete)
+  useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, handleNodesCopy, { exactMatch: true, useCapture: true })
+  useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.v`, handleNodesPaste, { exactMatch: true, useCapture: true })
+  useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.d`, handleNodesDuplicate, { exactMatch: true, useCapture: true })
+  useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, handleStartWorkflowRun, { exactMatch: true, useCapture: true })
 
   return (
     <div
@@ -165,13 +211,17 @@ const Workflow: FC<WorkflowProps> = memo(({
         ${workflowReadOnly && 'workflow-panel-animation'}
         ${nodeAnimation && 'workflow-node-animation'}
       `}
+      ref={workflowContainerRef}
     >
+      <CandidateNode />
       <Header />
       <Panel />
       <Operator />
       {
         showFeaturesPanel && <Features />
       }
+      <PanelContextmenu />
+      <NodeContextmenu />
       <HelpLine />
       <ReactFlow
         nodeTypes={nodeTypes}
@@ -184,12 +234,17 @@ const Workflow: FC<WorkflowProps> = memo(({
         onNodeMouseEnter={handleNodeEnter}
         onNodeMouseLeave={handleNodeLeave}
         onNodeClick={handleNodeClick}
+        onNodeContextMenu={handleNodeContextMenu}
         onConnect={handleNodeConnect}
         onConnectStart={handleNodeConnectStart}
         onConnectEnd={handleNodeConnectEnd}
         onEdgeMouseEnter={handleEdgeEnter}
         onEdgeMouseLeave={handleEdgeLeave}
         onEdgesChange={handleEdgesChange}
+        onSelectionStart={handleSelectionStart}
+        onSelectionChange={handleSelectionChange}
+        onSelectionDrag={handleSelectionDrag}
+        onPaneContextMenu={handlePaneContextMenu}
         connectionLineComponent={CustomConnectionLine}
         defaultViewport={viewport}
         multiSelectionKeyCode={null}
@@ -198,11 +253,15 @@ const Workflow: FC<WorkflowProps> = memo(({
         nodesConnectable={!nodesReadOnly}
         nodesFocusable={!nodesReadOnly}
         edgesFocusable={!nodesReadOnly}
-        panOnDrag={!workflowReadOnly}
+        panOnDrag={controlMode === 'hand' && !workflowReadOnly}
         zoomOnPinch={!workflowReadOnly}
         zoomOnScroll={!workflowReadOnly}
         zoomOnDoubleClick={!workflowReadOnly}
         isValidConnection={isValidConnection}
+        selectionKeyCode={null}
+        selectionMode={SelectionMode.Partial}
+        selectionOnDrag={controlMode === 'pointer' && !workflowReadOnly}
+        minZoom={0.25}
       >
         <Background
           gap={[14, 14]}

+ 44 - 0
web/app/components/workflow/node-contextmenu.tsx

@@ -0,0 +1,44 @@
+import {
+  memo,
+  useRef,
+} from 'react'
+import { useClickAway } from 'ahooks'
+import { useNodes } from 'reactflow'
+import PanelOperatorPopup from './nodes/_base/components/panel-operator/panel-operator-popup'
+import type { Node } from './types'
+import { useStore } from './store'
+import { usePanelInteractions } from './hooks'
+
+const PanelContextmenu = () => {
+  const ref = useRef(null)
+  const nodes = useNodes()
+  const { handleNodeContextmenuCancel } = usePanelInteractions()
+  const nodeMenu = useStore(s => s.nodeMenu)
+  const currentNode = nodes.find(node => node.id === nodeMenu?.nodeId) as Node
+
+  useClickAway(() => {
+    handleNodeContextmenuCancel()
+  }, ref)
+
+  if (!nodeMenu || !currentNode)
+    return null
+
+  return (
+    <div
+      className='absolute z-[9]'
+      style={{
+        left: nodeMenu.left,
+        top: nodeMenu.top,
+      }}
+      ref={ref}
+    >
+      <PanelOperatorPopup
+        id={currentNode.id}
+        data={currentNode.data}
+        onClosePopup={() => handleNodeContextmenuCancel()}
+      />
+    </div>
+  )
+}
+
+export default memo(PanelContextmenu)

+ 6 - 94
web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx

@@ -1,19 +1,10 @@
 import {
   memo,
   useCallback,
-  useMemo,
   useState,
 } from 'react'
-import { useTranslation } from 'react-i18next'
-import { useEdges } from 'reactflow'
 import type { OffsetOptions } from '@floating-ui/react'
-import ChangeBlock from './change-block'
-import { useStore } from '@/app/components/workflow/store'
-import {
-  useNodesExtraData,
-  useNodesInteractions,
-  useNodesReadOnly,
-} from '@/app/components/workflow/hooks'
+import PanelOperatorPopup from './panel-operator-popup'
 import { DotsHorizontal } from '@/app/components/base/icons/src/vender/line/general'
 import {
   PortalToFollowElem,
@@ -21,8 +12,6 @@ import {
   PortalToFollowElemTrigger,
 } from '@/app/components/base/portal-to-follow-elem'
 import type { Node } from '@/app/components/workflow/types'
-import { BlockEnum } from '@/app/components/workflow/types'
-import { useGetLanguage } from '@/context/i18n'
 
 type PanelOperatorProps = {
   id: string
@@ -43,35 +32,7 @@ const PanelOperator = ({
   onOpenChange,
   inNode,
 }: PanelOperatorProps) => {
-  const { t } = useTranslation()
-  const language = useGetLanguage()
-  const edges = useEdges()
-  const { handleNodeDelete } = useNodesInteractions()
-  const { nodesReadOnly } = useNodesReadOnly()
-  const nodesExtraData = useNodesExtraData()
-  const buildInTools = useStore(s => s.buildInTools)
-  const customTools = useStore(s => s.customTools)
   const [open, setOpen] = useState(false)
-  const edge = edges.find(edge => edge.target === id)
-  const author = useMemo(() => {
-    if (data.type !== BlockEnum.Tool)
-      return nodesExtraData[data.type].author
-
-    if (data.provider_type === 'builtin')
-      return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author
-
-    return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author
-  }, [data, nodesExtraData, buildInTools, customTools])
-
-  const about = useMemo(() => {
-    if (data.type !== BlockEnum.Tool)
-      return nodesExtraData[data.type].about
-
-    if (data.provider_type === 'builtin')
-      return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
-
-    return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
-  }, [data, nodesExtraData, language, buildInTools, customTools])
 
   const handleOpenChange = useCallback((newOpen: boolean) => {
     setOpen(newOpen)
@@ -100,60 +61,11 @@ const PanelOperator = ({
         </div>
       </PortalToFollowElemTrigger>
       <PortalToFollowElemContent className='z-[11]'>
-        <div className='w-[240px] border-[0.5px] border-gray-200 rounded-lg shadow-xl bg-white'>
-          <div className='p-1'>
-            {
-              data.type !== BlockEnum.Start && !nodesReadOnly && (
-                <ChangeBlock
-                  nodeId={id}
-                  nodeType={data.type}
-                  sourceHandle={edge?.sourceHandle || 'source'}
-                />
-              )
-            }
-            <a
-              href={
-                language === 'zh_Hans'
-                  ? 'https://docs.dify.ai/v/zh-hans/guides/workflow'
-                  : 'https://docs.dify.ai/features/workflow'
-              }
-              target='_blank'
-              className='flex items-center px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
-            >
-              {t('workflow.panel.helpLink')}
-            </a>
-          </div>
-          {
-            data.type !== BlockEnum.Start && !nodesReadOnly && (
-              <>
-                <div className='h-[1px] bg-gray-100'></div>
-                <div className='p-1'>
-                  <div
-                    className={`
-                    flex items-center px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer
-                    hover:bg-rose-50 hover:text-red-500
-                    `}
-                    onClick={() => handleNodeDelete(id)}
-                  >
-                    {t('common.operation.delete')}
-                  </div>
-                </div>
-              </>
-            )
-          }
-          <div className='h-[1px] bg-gray-100'></div>
-          <div className='p-1'>
-            <div className='px-3 py-2 text-xs text-gray-500'>
-              <div className='flex items-center mb-1 h-[22px] font-medium'>
-                {t('workflow.panel.about').toLocaleUpperCase()}
-              </div>
-              <div className='mb-1 text-gray-700 leading-[18px]'>{about}</div>
-              <div className='leading-[18px]'>
-                {t('workflow.panel.createdBy')} {author}
-              </div>
-            </div>
-          </div>
-        </div>
+        <PanelOperatorPopup
+          id={id}
+          data={data}
+          onClosePopup={() => setOpen(false)}
+        />
       </PortalToFollowElemContent>
     </PortalToFollowElem>
   )

+ 181 - 0
web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx

@@ -0,0 +1,181 @@
+import {
+  memo,
+  useMemo,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import { useEdges } from 'reactflow'
+import ChangeBlock from './change-block'
+import {
+  canRunBySingle,
+} from '@/app/components/workflow/utils'
+import { useStore } from '@/app/components/workflow/store'
+import {
+  useNodeDataUpdate,
+  useNodesExtraData,
+  useNodesInteractions,
+  useNodesReadOnly,
+  useNodesSyncDraft,
+} from '@/app/components/workflow/hooks'
+import ShortcutsName from '@/app/components/workflow/shortcuts-name'
+import type { Node } from '@/app/components/workflow/types'
+import { BlockEnum } from '@/app/components/workflow/types'
+import { useGetLanguage } from '@/context/i18n'
+
+type PanelOperatorPopupProps = {
+  id: string
+  data: Node['data']
+  onClosePopup: () => void
+}
+const PanelOperatorPopup = ({
+  id,
+  data,
+  onClosePopup,
+}: PanelOperatorPopupProps) => {
+  const { t } = useTranslation()
+  const language = useGetLanguage()
+  const edges = useEdges()
+  const {
+    handleNodeDelete,
+    handleNodesDuplicate,
+    handleNodeSelect,
+    handleNodesCopy,
+  } = useNodesInteractions()
+  const { handleNodeDataUpdate } = useNodeDataUpdate()
+  const { handleSyncWorkflowDraft } = useNodesSyncDraft()
+  const { nodesReadOnly } = useNodesReadOnly()
+  const nodesExtraData = useNodesExtraData()
+  const buildInTools = useStore(s => s.buildInTools)
+  const customTools = useStore(s => s.customTools)
+  const edge = edges.find(edge => edge.target === id)
+  const author = useMemo(() => {
+    if (data.type !== BlockEnum.Tool)
+      return nodesExtraData[data.type].author
+
+    if (data.provider_type === 'builtin')
+      return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author
+
+    return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author
+  }, [data, nodesExtraData, buildInTools, customTools])
+
+  const about = useMemo(() => {
+    if (data.type !== BlockEnum.Tool)
+      return nodesExtraData[data.type].about
+
+    if (data.provider_type === 'builtin')
+      return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
+
+    return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
+  }, [data, nodesExtraData, language, buildInTools, customTools])
+
+  const showChangeBlock = data.type !== BlockEnum.Start && !nodesReadOnly
+
+  return (
+    <div className='w-[240px] border-[0.5px] border-gray-200 rounded-lg shadow-xl bg-white'>
+      {
+        (showChangeBlock || canRunBySingle(data.type)) && (
+          <>
+            <div className='p-1'>
+              {
+                canRunBySingle(data.type) && (
+                  <div
+                    className={`
+                      flex items-center px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer
+                      hover:bg-gray-50
+                    `}
+                    onClick={() => {
+                      handleNodeSelect(id)
+                      handleNodeDataUpdate({ id, data: { _isSingleRun: true } })
+                      handleSyncWorkflowDraft(true)
+                      onClosePopup()
+                    }}
+                  >
+                    {t('workflow.panel.runThisStep')}
+                  </div>
+                )
+              }
+              {
+                showChangeBlock && (
+                  <ChangeBlock
+                    nodeId={id}
+                    nodeType={data.type}
+                    sourceHandle={edge?.sourceHandle || 'source'}
+                  />
+                )
+              }
+            </div>
+            <div className='h-[1px] bg-gray-100'></div>
+          </>
+        )
+      }
+      {
+        data.type !== BlockEnum.Start && data.type !== BlockEnum.End && !nodesReadOnly && (
+          <>
+            <div className='p-1'>
+              <div
+                className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
+                onClick={() => {
+                  onClosePopup()
+                  handleNodesCopy()
+                }}
+              >
+                {t('workflow.common.copy')}
+                <ShortcutsName keys={['ctrl', 'c']} />
+              </div>
+              <div
+                className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
+                onClick={() => {
+                  onClosePopup()
+                  handleNodesDuplicate()
+                }}
+              >
+                {t('workflow.common.duplicate')}
+                <ShortcutsName keys={['ctrl', 'd']} />
+              </div>
+            </div>
+            <div className='h-[1px] bg-gray-100'></div>
+            <div className='p-1'>
+              <div
+                className={`
+                flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer
+                hover:bg-rose-50 hover:text-red-500
+                `}
+                onClick={() => handleNodeDelete(id)}
+              >
+                {t('common.operation.delete')}
+                <ShortcutsName keys={['del']} />
+              </div>
+            </div>
+            <div className='h-[1px] bg-gray-100'></div>
+          </>
+        )
+      }
+      <div className='p-1'>
+        <a
+          href={
+            language === 'zh_Hans'
+              ? 'https://docs.dify.ai/v/zh-hans/guides/workflow'
+              : 'https://docs.dify.ai/features/workflow'
+          }
+          target='_blank'
+          className='flex items-center px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
+        >
+          {t('workflow.panel.helpLink')}
+        </a>
+      </div>
+      <div className='h-[1px] bg-gray-100'></div>
+      <div className='p-1'>
+        <div className='px-3 py-2 text-xs text-gray-500'>
+          <div className='flex items-center mb-1 h-[22px] font-medium'>
+            {t('workflow.panel.about').toLocaleUpperCase()}
+          </div>
+          <div className='mb-1 text-gray-700 leading-[18px]'>{about}</div>
+          <div className='leading-[18px]'>
+            {t('workflow.panel.createdBy')} {author}
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default memo(PanelOperatorPopup)

+ 13 - 8
web/app/components/workflow/nodes/_base/node.tsx

@@ -6,6 +6,7 @@ import {
   cloneElement,
   memo,
   useMemo,
+  useRef,
 } from 'react'
 import type { NodeProps } from '../../types'
 import {
@@ -37,27 +38,30 @@ const BaseNode: FC<BaseNodeProps> = ({
   data,
   children,
 }) => {
+  const nodeRef = useRef<HTMLDivElement>(null)
   const { nodesReadOnly } = useNodesReadOnly()
   const toolIcon = useToolIcon(data)
 
+  const showSelectedBorder = data.selected || data._isBundled
   const {
     showRunningBorder,
     showSuccessBorder,
     showFailedBorder,
   } = useMemo(() => {
     return {
-      showRunningBorder: data._runningStatus === NodeRunningStatus.Running && !data.selected,
-      showSuccessBorder: data._runningStatus === NodeRunningStatus.Succeeded && !data.selected,
-      showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !data.selected,
+      showRunningBorder: data._runningStatus === NodeRunningStatus.Running && !showSelectedBorder,
+      showSuccessBorder: data._runningStatus === NodeRunningStatus.Succeeded && !showSelectedBorder,
+      showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
     }
-  }, [data._runningStatus, data.selected])
+  }, [data._runningStatus, showSelectedBorder])
 
   return (
     <div
       className={`
         flex border-[2px] rounded-2xl
-        ${(data.selected && !data._isInvalidConnection) ? 'border-primary-600' : 'border-transparent'}
+        ${(showSelectedBorder && !data._isInvalidConnection) ? 'border-primary-600' : 'border-transparent'}
       `}
+      ref={nodeRef}
     >
       <div
         className={`
@@ -68,10 +72,11 @@ const BaseNode: FC<BaseNodeProps> = ({
           ${showSuccessBorder && '!border-[#12B76A]'}
           ${showFailedBorder && '!border-[#F04438]'}
           ${data._isInvalidConnection && '!border-[#F04438]'}
+          ${data._isBundled && '!shadow-lg'}
         `}
       >
         {
-          data.type !== BlockEnum.VariableAssigner && (
+          data.type !== BlockEnum.VariableAssigner && !data._isCandidate && (
             <NodeTargetHandle
               id={id}
               data={data}
@@ -81,7 +86,7 @@ const BaseNode: FC<BaseNodeProps> = ({
           )
         }
         {
-          data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && (
+          data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && !data._isCandidate && (
             <NodeSourceHandle
               id={id}
               data={data}
@@ -91,7 +96,7 @@ const BaseNode: FC<BaseNodeProps> = ({
           )
         }
         {
-          !data._runningStatus && !nodesReadOnly && (
+          !data._runningStatus && !nodesReadOnly && !data._isCandidate && (
             <NodeControl
               id={id}
               data={data}

+ 110 - 0
web/app/components/workflow/operator/add-block.tsx

@@ -0,0 +1,110 @@
+import {
+  memo,
+  useCallback,
+  useState,
+} from 'react'
+import cn from 'classnames'
+import { useStoreApi } from 'reactflow'
+import { useTranslation } from 'react-i18next'
+import type { OffsetOptions } from '@floating-ui/react'
+import {
+  generateNewNode,
+} from '../utils'
+import {
+  useNodesExtraData,
+  useNodesReadOnly,
+  usePanelInteractions,
+} from '../hooks'
+import { NODES_INITIAL_DATA } from '../constants'
+import { useWorkflowStore } from '../store'
+import TipPopup from './tip-popup'
+import BlockSelector from '@/app/components/workflow/block-selector'
+import { Plus } from '@/app/components/base/icons/src/vender/line/general'
+import type {
+  OnSelectBlock,
+} from '@/app/components/workflow/types'
+import {
+  BlockEnum,
+} from '@/app/components/workflow/types'
+
+type AddBlockProps = {
+  renderTrigger?: (open: boolean) => React.ReactNode
+  offset?: OffsetOptions
+}
+const AddBlock = ({
+  renderTrigger,
+  offset,
+}: AddBlockProps) => {
+  const { t } = useTranslation()
+  const store = useStoreApi()
+  const workflowStore = useWorkflowStore()
+  const nodesExtraData = useNodesExtraData()
+  const { nodesReadOnly } = useNodesReadOnly()
+  const { handlePaneContextmenuCancel } = usePanelInteractions()
+  const [open, setOpen] = useState(false)
+  const availableNextNodes = nodesExtraData[BlockEnum.Start].availableNextNodes
+
+  const handleOpenChange = useCallback((open: boolean) => {
+    setOpen(open)
+    if (!open)
+      handlePaneContextmenuCancel()
+  }, [handlePaneContextmenuCancel])
+
+  const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
+    const {
+      getNodes,
+    } = store.getState()
+    const nodes = getNodes()
+    const nodesWithSameType = nodes.filter(node => node.data.type === type)
+    const newNode = generateNewNode({
+      data: {
+        ...NODES_INITIAL_DATA[type],
+        title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${type}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${type}`),
+        ...(toolDefaultValue || {}),
+        _isCandidate: true,
+      },
+      position: {
+        x: 0,
+        y: 0,
+      },
+    })
+    workflowStore.setState({
+      candidateNode: newNode,
+    })
+  }, [store, workflowStore, t])
+
+  const renderTriggerElement = useCallback((open: boolean) => {
+    return (
+      <TipPopup
+        title={t('workflow.common.addBlock')}
+      >
+        <div className={cn(
+          'flex items-center justify-center w-8 h-8 rounded-lg hover:bg-black/5 hover:text-gray-700 cursor-pointer',
+          `${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
+          open && '!bg-black/5',
+        )}>
+          <Plus className='w-4 h-4' />
+        </div>
+      </TipPopup>
+    )
+  }, [nodesReadOnly, t])
+
+  return (
+    <BlockSelector
+      open={open}
+      onOpenChange={handleOpenChange}
+      disabled={nodesReadOnly}
+      onSelect={handleSelect}
+      placement='top-start'
+      offset={offset ?? {
+        mainAxis: 4,
+        crossAxis: -8,
+      }}
+      trigger={renderTrigger || renderTriggerElement}
+      popupClassName='!min-w-[256px]'
+      availableBlocksTypes={availableNextNodes}
+    />
+  )
+}
+
+export default memo(AddBlock)

+ 85 - 0
web/app/components/workflow/operator/control.tsx

@@ -0,0 +1,85 @@
+import { memo } from 'react'
+import { useTranslation } from 'react-i18next'
+import cn from 'classnames'
+import {
+  useNodesReadOnly,
+  useWorkflow,
+} from '../hooks'
+import { useStore } from '../store'
+import AddBlock from './add-block'
+import TipPopup from './tip-popup'
+import {
+  Cursor02C,
+  Hand02,
+} from '@/app/components/base/icons/src/vender/line/editor'
+import {
+  Cursor02C as Cursor02CSolid,
+  Hand02 as Hand02Solid,
+} from '@/app/components/base/icons/src/vender/solid/editor'
+import { OrganizeGrid } from '@/app/components/base/icons/src/vender/line/layout'
+
+const Control = () => {
+  const { t } = useTranslation()
+  const controlMode = useStore(s => s.controlMode)
+  const setControlMode = useStore(s => s.setControlMode)
+  const { handleLayout } = useWorkflow()
+  const {
+    nodesReadOnly,
+    getNodesReadOnly,
+  } = useNodesReadOnly()
+
+  const goLayout = () => {
+    if (getNodesReadOnly())
+      return
+    handleLayout()
+  }
+
+  return (
+    <div className='flex items-center p-0.5 rounded-lg border-[0.5px] border-gray-100 bg-white shadow-lg text-gray-500'>
+      <AddBlock />
+      <div className='mx-[3px] w-[1px] h-3.5 bg-gray-200'></div>
+      <TipPopup title={t('workflow.common.pointerMode')}>
+        <div
+          className={cn(
+            'flex items-center justify-center mr-[1px] w-8 h-8 rounded-lg cursor-pointer',
+            controlMode === 'pointer' ? 'bg-primary-50 text-primary-600' : 'hover:bg-black/5 hover:text-gray-700',
+            `${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
+          )}
+          onClick={() => setControlMode('pointer')}
+        >
+          {
+            controlMode === 'pointer' ? <Cursor02CSolid className='w-4 h-4' /> : <Cursor02C className='w-4 h-4' />
+          }
+        </div>
+      </TipPopup>
+      <TipPopup title={t('workflow.common.handMode')}>
+        <div
+          className={cn(
+            'flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer',
+            controlMode === 'hand' ? 'bg-primary-50 text-primary-600' : 'hover:bg-black/5 hover:text-gray-700',
+            `${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
+          )}
+          onClick={() => setControlMode('hand')}
+        >
+          {
+            controlMode === 'hand' ? <Hand02Solid className='w-4 h-4' /> : <Hand02 className='w-4 h-4' />
+          }
+        </div>
+      </TipPopup>
+      <div className='mx-[3px] w-[1px] h-3.5 bg-gray-200'></div>
+      <TipPopup title={t('workflow.panel.organizeBlocks')}>
+        <div
+          className={cn(
+            'flex items-center justify-center w-8 h-8 rounded-lg hover:bg-black/5 hover:text-gray-700 cursor-pointer',
+            `${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
+          )}
+          onClick={goLayout}
+        >
+          <OrganizeGrid className='w-4 h-4' />
+        </div>
+      </TipPopup>
+    </div>
+  )
+}
+
+export default memo(Control)

+ 8 - 39
web/app/components/workflow/operator/index.tsx

@@ -1,54 +1,23 @@
 import { memo } from 'react'
-import { useTranslation } from 'react-i18next'
 import { MiniMap } from 'reactflow'
-import {
-  useNodesReadOnly,
-  useWorkflow,
-} from '../hooks'
 import ZoomInOut from './zoom-in-out'
-import { OrganizeGrid } from '@/app/components/base/icons/src/vender/line/layout'
-import TooltipPlus from '@/app/components/base/tooltip-plus'
+import Control from './control'
 
 const Operator = () => {
-  const { t } = useTranslation()
-  const { handleLayout } = useWorkflow()
-  const {
-    nodesReadOnly,
-    getNodesReadOnly,
-  } = useNodesReadOnly()
-
-  const goLayout = () => {
-    if (getNodesReadOnly())
-      return
-    handleLayout()
-  }
-
   return (
-    <div className={`
-      absolute left-6 bottom-6 z-[9]
-    `}>
+    <>
       <MiniMap
         style={{
-          width: 128,
-          height: 80,
+          width: 102,
+          height: 72,
         }}
-        className='!static !m-0 !w-[128px] !h-[80px] !border-[0.5px] !border-black/[0.08] !rounded-lg !shadow-lg'
+        className='!absolute !left-4 !bottom-14 z-[9] !m-0 !w-[102px] !h-[72px] !border-[0.5px] !border-black/[0.08] !rounded-lg !shadow-lg'
       />
-      <div className='flex items-center mt-1 p-0.5 rounded-lg border-[0.5px] border-gray-100 bg-white shadow-lg text-gray-500'>
+      <div className='flex items-center mt-1 gap-2 absolute left-4 bottom-4 z-[9]'>
         <ZoomInOut />
-        <TooltipPlus popupContent={t('workflow.panel.organizeBlocks')}>
-          <div
-            className={`
-              ml-[1px] flex items-center justify-center w-8 h-8 cursor-pointer hover:bg-black/5 rounded-lg
-              ${nodesReadOnly && '!cursor-not-allowed opacity-50'}
-            `}
-            onClick={goLayout}
-          >
-            <OrganizeGrid className='w-4 h-4' />
-          </div>
-        </TooltipPlus>
+        <Control />
       </div>
-    </div>
+    </>
   )
 }
 

+ 34 - 0
web/app/components/workflow/operator/tip-popup.tsx

@@ -0,0 +1,34 @@
+import { memo } from 'react'
+import ShortcutsName from '../shortcuts-name'
+import TooltipPlus from '@/app/components/base/tooltip-plus'
+
+type TipPopupProps = {
+  title: string
+  children: React.ReactNode
+  shortcuts?: string[]
+}
+const TipPopup = ({
+  title,
+  children,
+  shortcuts,
+}: TipPopupProps) => {
+  return (
+    <TooltipPlus
+      offset={4}
+      hideArrow
+      popupClassName='!p-0 !bg-gray-25'
+      popupContent={
+        <div className='flex items-center gap-1 px-2 h-6 text-xs font-medium text-gray-700 rounded-lg border-[0.5px] border-black/5'>
+          {title}
+          {
+            shortcuts && <ShortcutsName keys={shortcuts} className='!text-[11px]' />
+          }
+        </div>
+      }
+    >
+      {children}
+    </TooltipPlus>
+  )
+}
+
+export default memo(TipPopup)

+ 177 - 29
web/app/components/workflow/operator/zoom-in-out.tsx

@@ -5,6 +5,8 @@ import {
   useCallback,
   useState,
 } from 'react'
+import cn from 'classnames'
+import { useKeyPress } from 'ahooks'
 import { useTranslation } from 'react-i18next'
 import {
   useReactFlow,
@@ -14,13 +16,32 @@ import {
   useNodesSyncDraft,
   useWorkflowReadOnly,
 } from '../hooks'
+import {
+  getKeyboardKeyCodeBySystem,
+  getKeyboardKeyNameBySystem,
+} from '../utils'
+import ShortcutsName from '../shortcuts-name'
+import TipPopup from './tip-popup'
 import {
   PortalToFollowElem,
   PortalToFollowElemContent,
   PortalToFollowElemTrigger,
 } from '@/app/components/base/portal-to-follow-elem'
-import { SearchLg } from '@/app/components/base/icons/src/vender/line/general'
-import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows'
+import {
+  ZoomIn,
+  ZoomOut,
+} from '@/app/components/base/icons/src/vender/line/editor'
+
+enum ZoomType {
+  zoomIn = 'zoomIn',
+  zoomOut = 'zoomOut',
+  zoomToFit = 'zoomToFit',
+  zoomTo25 = 'zoomTo25',
+  zoomTo50 = 'zoomTo50',
+  zoomTo75 = 'zoomTo75',
+  zoomTo100 = 'zoomTo100',
+  zoomTo200 = 'zoomTo200',
+}
 
 const ZoomInOut: FC = () => {
   const { t } = useTranslation()
@@ -41,27 +62,29 @@ const ZoomInOut: FC = () => {
   const ZOOM_IN_OUT_OPTIONS = [
     [
       {
-        key: 'in',
-        text: t('workflow.operator.zoomIn'),
+        key: ZoomType.zoomTo200,
+        text: '200%',
       },
       {
-        key: 'out',
-        text: t('workflow.operator.zoomOut'),
+        key: ZoomType.zoomTo100,
+        text: '100%',
       },
-    ],
-    [
       {
-        key: 'to50',
-        text: t('workflow.operator.zoomTo50'),
+        key: ZoomType.zoomTo75,
+        text: '75%',
       },
       {
-        key: 'to100',
-        text: t('workflow.operator.zoomTo100'),
+        key: ZoomType.zoomTo50,
+        text: '50%',
+      },
+      {
+        key: ZoomType.zoomTo25,
+        text: '25%',
       },
     ],
     [
       {
-        key: 'fit',
+        key: ZoomType.zoomToFit,
         text: t('workflow.operator.zoomToFit'),
       },
     ],
@@ -71,24 +94,99 @@ const ZoomInOut: FC = () => {
     if (workflowReadOnly)
       return
 
-    if (type === 'in')
-      zoomIn()
-
-    if (type === 'out')
-      zoomOut()
-
-    if (type === 'fit')
+    if (type === ZoomType.zoomToFit)
       fitView()
 
-    if (type === 'to50')
+    if (type === ZoomType.zoomTo25)
+      zoomTo(0.25)
+
+    if (type === ZoomType.zoomTo50)
       zoomTo(0.5)
 
-    if (type === 'to100')
+    if (type === ZoomType.zoomTo75)
+      zoomTo(0.75)
+
+    if (type === ZoomType.zoomTo100)
       zoomTo(1)
 
+    if (type === ZoomType.zoomTo200)
+      zoomTo(2)
+
     handleSyncWorkflowDraft()
   }
 
+  useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.1`, (e) => {
+    e.preventDefault()
+    if (workflowReadOnly)
+      return
+
+    fitView()
+    handleSyncWorkflowDraft()
+  }, {
+    exactMatch: true,
+    useCapture: true,
+  })
+
+  useKeyPress('shift.1', (e) => {
+    e.preventDefault()
+    if (workflowReadOnly)
+      return
+
+    zoomTo(1)
+    handleSyncWorkflowDraft()
+  }, {
+    exactMatch: true,
+    useCapture: true,
+  })
+
+  useKeyPress('shift.2', (e) => {
+    e.preventDefault()
+    if (workflowReadOnly)
+      return
+
+    zoomTo(2)
+    handleSyncWorkflowDraft()
+  }, {
+    exactMatch: true,
+    useCapture: true,
+  })
+
+  useKeyPress('shift.5', (e) => {
+    e.preventDefault()
+    if (workflowReadOnly)
+      return
+
+    zoomTo(0.5)
+    handleSyncWorkflowDraft()
+  }, {
+    exactMatch: true,
+    useCapture: true,
+  })
+
+  useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.dash`, (e) => {
+    e.preventDefault()
+    if (workflowReadOnly)
+      return
+
+    zoomOut()
+    handleSyncWorkflowDraft()
+  }, {
+    exactMatch: true,
+    useCapture: true,
+  })
+
+  useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.equalsign`, (e) => {
+    e.preventDefault()
+    if (workflowReadOnly)
+      return
+
+    zoomIn()
+    handleSyncWorkflowDraft()
+  }, {
+    exactMatch: true,
+    useCapture: true,
+  })
+
   const handleTrigger = useCallback(() => {
     if (getWorkflowReadOnly())
       return
@@ -108,17 +206,47 @@ const ZoomInOut: FC = () => {
     >
       <PortalToFollowElemTrigger asChild onClick={handleTrigger}>
         <div className={`
-          flex items-center px-2 h-8 cursor-pointer text-[13px] hover:bg-gray-50 rounded-lg
-          ${open && 'bg-gray-50'}
+          p-0.5 h-9 cursor-pointer text-[13px] text-gray-500 font-medium rounded-lg bg-white shadow-lg border-[0.5px] border-gray-100
           ${workflowReadOnly && '!cursor-not-allowed opacity-50'}
         `}>
-          <SearchLg className='mr-1 w-4 h-4' />
-          <div className='w-[34px]'>{parseFloat(`${zoom * 100}`).toFixed(0)}%</div>
-          <ChevronDown className='ml-1 w-4 h-4' />
+          <div className={cn(
+            'flex items-center justify-between w-[98px] h-8 hover:bg-gray-50 rounded-lg',
+            open && 'bg-gray-50',
+          )}>
+            <TipPopup
+              title={t('workflow.operator.zoomOut')}
+              shortcuts={['ctrl', '-']}
+            >
+              <div
+                className='flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer hover:bg-black/5'
+                onClick={(e) => {
+                  e.stopPropagation()
+                  zoomOut()
+                }}
+              >
+                <ZoomOut className='w-4 h-4' />
+              </div>
+            </TipPopup>
+            <div className='w-[34px]'>{parseFloat(`${zoom * 100}`).toFixed(0)}%</div>
+            <TipPopup
+              title={t('workflow.operator.zoomIn')}
+              shortcuts={['ctrl', '+']}
+            >
+              <div
+                className='flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer hover:bg-black/5'
+                onClick={(e) => {
+                  e.stopPropagation()
+                  zoomIn()
+                }}
+              >
+                <ZoomIn className='w-4 h-4' />
+              </div>
+            </TipPopup>
+          </div>
         </div>
       </PortalToFollowElemTrigger>
       <PortalToFollowElemContent className='z-10'>
-        <div className='w-[168px] rounded-lg border-[0.5px] border-gray-200 bg-white shadow-lg'>
+        <div className='w-[145px] rounded-lg border-[0.5px] border-gray-200 bg-white shadow-lg'>
           {
             ZOOM_IN_OUT_OPTIONS.map((options, i) => (
               <Fragment key={i}>
@@ -132,10 +260,30 @@ const ZoomInOut: FC = () => {
                     options.map(option => (
                       <div
                         key={option.key}
-                        className='flex items-center px-3 h-8 rounded-lg hover:bg-gray-50 cursor-pointer text-sm text-gray-700'
+                        className='flex items-center justify-between px-3 h-8 rounded-lg hover:bg-gray-50 cursor-pointer text-sm text-gray-700'
                         onClick={() => handleZoom(option.key)}
                       >
                         {option.text}
+                        {
+                          option.key === ZoomType.zoomToFit && (
+                            <ShortcutsName keys={[`${getKeyboardKeyNameBySystem('ctrl')}`, '1']} />
+                          )
+                        }
+                        {
+                          option.key === ZoomType.zoomTo50 && (
+                            <ShortcutsName keys={['shift', '5']} />
+                          )
+                        }
+                        {
+                          option.key === ZoomType.zoomTo100 && (
+                            <ShortcutsName keys={['shift', '1']} />
+                          )
+                        }
+                        {
+                          option.key === ZoomType.zoomTo200 && (
+                            <ShortcutsName keys={['shift', '2']} />
+                          )
+                        }
                       </div>
                     ))
                   }

+ 123 - 0
web/app/components/workflow/panel-contextmenu.tsx

@@ -0,0 +1,123 @@
+import {
+  memo,
+  useRef,
+} from 'react'
+import cn from 'classnames'
+import { useTranslation } from 'react-i18next'
+import { useClickAway } from 'ahooks'
+import ShortcutsName from './shortcuts-name'
+import { useStore } from './store'
+import {
+  useNodesInteractions,
+  usePanelInteractions,
+  useWorkflowStartRun,
+} from './hooks'
+import AddBlock from './operator/add-block'
+import { exportAppConfig } from '@/service/apps'
+import { useToastContext } from '@/app/components/base/toast'
+import { useStore as useAppStore } from '@/app/components/app/store'
+
+const PanelContextmenu = () => {
+  const { t } = useTranslation()
+  const { notify } = useToastContext()
+  const ref = useRef(null)
+  const panelMenu = useStore(s => s.panelMenu)
+  const clipboardElements = useStore(s => s.clipboardElements)
+  const appDetail = useAppStore(s => s.appDetail)
+  const { handleNodesPaste } = useNodesInteractions()
+  const { handlePaneContextmenuCancel } = usePanelInteractions()
+  const { handleStartWorkflowRun } = useWorkflowStartRun()
+
+  useClickAway(() => {
+    handlePaneContextmenuCancel()
+  }, ref)
+
+  const onExport = async () => {
+    if (!appDetail)
+      return
+    try {
+      const { data } = await exportAppConfig(appDetail.id)
+      const a = document.createElement('a')
+      const file = new Blob([data], { type: 'application/yaml' })
+      a.href = URL.createObjectURL(file)
+      a.download = `${appDetail.name}.yml`
+      a.click()
+    }
+    catch (e) {
+      notify({ type: 'error', message: t('app.exportFailed') })
+    }
+  }
+
+  const renderTrigger = () => {
+    return (
+      <div
+        className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
+      >
+        {t('workflow.common.addBlock')}
+      </div>
+    )
+  }
+
+  if (!panelMenu)
+    return null
+
+  return (
+    <div
+      className='absolute w-[200px] rounded-lg border-[0.5px] border-gray-200 bg-white shadow-xl z-[9]'
+      style={{
+        left: panelMenu.left,
+        top: panelMenu.top,
+      }}
+      ref={ref}
+    >
+      <div className='p-1'>
+        <AddBlock
+          renderTrigger={renderTrigger}
+          offset={{
+            mainAxis: -36,
+            crossAxis: -4,
+          }}
+        />
+        <div
+          className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
+          onClick={() => {
+            handleStartWorkflowRun()
+            handlePaneContextmenuCancel()
+          }}
+        >
+          {t('workflow.common.run')}
+          <ShortcutsName keys={['alt', 'r']} />
+        </div>
+      </div>
+      <div className='h-[1px] bg-gray-100'></div>
+      <div className='p-1'>
+        <div
+          className={cn(
+            'flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer',
+            !clipboardElements.length ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50',
+          )}
+          onClick={() => {
+            if (clipboardElements.length) {
+              handleNodesPaste()
+              handlePaneContextmenuCancel()
+            }
+          }}
+        >
+          {t('workflow.common.pasteHere')}
+          <ShortcutsName keys={['ctrl', 'v']} />
+        </div>
+      </div>
+      <div className='h-[1px] bg-gray-100'></div>
+      <div className='p-1'>
+        <div
+          className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
+          onClick={() => onExport()}
+        >
+          {t('app.export')}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default memo(PanelContextmenu)

+ 32 - 0
web/app/components/workflow/shortcuts-name.tsx

@@ -0,0 +1,32 @@
+import { memo } from 'react'
+import cn from 'classnames'
+import { getKeyboardKeyNameBySystem } from './utils'
+
+type ShortcutsNameProps = {
+  keys: string[]
+  className?: string
+}
+const ShortcutsName = ({
+  keys,
+  className,
+}: ShortcutsNameProps) => {
+  return (
+    <div className={cn(
+      'flex items-center gap-0.5 h-4 text-xs text-gray-400',
+      className,
+    )}>
+      {
+        keys.map(key => (
+          <div
+            key={key}
+            className='capitalize'
+          >
+            {getKeyboardKeyNameBySystem(key)}
+          </div>
+        ))
+      }
+    </div>
+  )
+}
+
+export default memo(ShortcutsName)

+ 38 - 0
web/app/components/workflow/store.ts

@@ -75,6 +75,27 @@ type Shape = {
   setShortcutsDisabled: (shortcutsDisabled: boolean) => void
   showDebugAndPreviewPanel: boolean
   setShowDebugAndPreviewPanel: (showDebugAndPreviewPanel: boolean) => void
+  selection: null | { x1: number; y1: number; x2: number; y2: number }
+  setSelection: (selection: Shape['selection']) => void
+  bundleNodeSize: { width: number; height: number } | null
+  setBundleNodeSize: (bundleNodeSize: Shape['bundleNodeSize']) => void
+  controlMode: 'pointer' | 'hand'
+  setControlMode: (controlMode: Shape['controlMode']) => void
+  candidateNode?: Node
+  setCandidateNode: (candidateNode?: Node) => void
+  panelMenu?: {
+    top: number
+    left: number
+  }
+  setPanelMenu: (panelMenu: Shape['panelMenu']) => void
+  nodeMenu?: {
+    top: number
+    left: number
+    nodeId: string
+  }
+  setNodeMenu: (nodeMenu: Shape['nodeMenu']) => void
+  mousePosition: { pageX: number; pageY: number; elementX: number; elementY: number }
+  setMousePosition: (mousePosition: Shape['mousePosition']) => void
 }
 
 export const createWorkflowStore = () => {
@@ -126,6 +147,23 @@ export const createWorkflowStore = () => {
     setShortcutsDisabled: shortcutsDisabled => set(() => ({ shortcutsDisabled })),
     showDebugAndPreviewPanel: false,
     setShowDebugAndPreviewPanel: showDebugAndPreviewPanel => set(() => ({ showDebugAndPreviewPanel })),
+    selection: null,
+    setSelection: selection => set(() => ({ selection })),
+    bundleNodeSize: null,
+    setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })),
+    controlMode: localStorage.getItem('workflow-operation-mode') === 'pointer' ? 'pointer' : 'hand',
+    setControlMode: (controlMode) => {
+      set(() => ({ controlMode }))
+      localStorage.setItem('workflow-operation-mode', controlMode)
+    },
+    candidateNode: undefined,
+    setCandidateNode: candidateNode => set(() => ({ candidateNode })),
+    panelMenu: undefined,
+    setPanelMenu: panelMenu => set(() => ({ panelMenu })),
+    nodeMenu: undefined,
+    setNodeMenu: nodeMenu => set(() => ({ nodeMenu })),
+    mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 },
+    setMousePosition: mousePosition => set(() => ({ mousePosition })),
   }))
 }
 

+ 11 - 0
web/app/components/workflow/style.css

@@ -4,4 +4,15 @@
 
 .workflow-node-animation .react-flow__node {
   transition: transform 0.2s ease-in-out;
+}
+
+#workflow-container .react-flow__nodesselection-rect {
+  border: 1px solid #528BFF;
+  background: rgba(21, 94, 239, 0.05);
+  cursor: move;
+}
+
+#workflow-container .react-flow__selection {
+  border: 1px solid #528BFF;
+  background: rgba(21, 94, 239, 0.05);
 }

+ 3 - 0
web/app/components/workflow/types.ts

@@ -37,6 +37,8 @@ export type CommonNodeType<T = {}> = {
   _isSingleRun?: boolean
   _runningStatus?: NodeRunningStatus
   _singleRunningStatus?: NodeRunningStatus
+  _isCandidate?: boolean
+  _isBundled?: boolean
   selected?: boolean
   title: string
   desc: string
@@ -48,6 +50,7 @@ export type CommonEdgeType = {
   _connectedNodeIsHovering?: boolean
   _connectedNodeIsSelected?: boolean
   _runned?: boolean
+  _isBundled?: boolean
   sourceType: BlockEnum
   targetType: BlockEnum
 }

+ 45 - 0
web/app/components/workflow/utils.ts

@@ -361,3 +361,48 @@ export const changeNodesAndEdgesId = (nodes: Node[], edges: Edge[]) => {
 
   return [newNodes, newEdges] as [Node[], Edge[]]
 }
+
+export const isMac = () => {
+  return navigator.userAgent.toUpperCase().includes('MAC')
+}
+
+const specialKeysNameMap: Record<string, string | undefined> = {
+  ctrl: '⌘',
+  alt: '⌥',
+}
+
+export const getKeyboardKeyNameBySystem = (key: string) => {
+  if (isMac())
+    return specialKeysNameMap[key] || key
+
+  return key
+}
+
+const specialKeysCodeMap: Record<string, string | undefined> = {
+  ctrl: 'meta',
+}
+
+export const getKeyboardKeyCodeBySystem = (key: string) => {
+  if (isMac())
+    return specialKeysCodeMap[key] || key
+
+  return key
+}
+
+export const getTopLeftNodePosition = (nodes: Node[]) => {
+  let minX = Infinity
+  let minY = Infinity
+
+  nodes.forEach((node) => {
+    if (node.position.x < minX)
+      minX = node.position.x
+
+    if (node.position.y < minY)
+      minY = node.position.y
+  })
+
+  return {
+    x: minX,
+    y: minY,
+  }
+}

+ 6 - 0
web/i18n/en-US/workflow.ts

@@ -52,6 +52,12 @@ const translation = {
     jinjaEditorPlaceholder: 'Type \'/\' or \'{\' to insert variable',
     viewOnly: 'View Only',
     showRunHistory: 'Show Run History',
+    copy: 'Copy',
+    duplicate: 'Duplicate',
+    addBlock: 'Add Block',
+    pasteHere: 'Paste Here',
+    pointerMode: 'Pointer Mode',
+    handMode: 'Hand Mode',
   },
   errorMsg: {
     fieldRequired: '{{field}} is required',

+ 6 - 0
web/i18n/zh-Hans/workflow.ts

@@ -52,6 +52,12 @@ const translation = {
     jinjaEditorPlaceholder: '输入 “/” 或 “{” 插入变量',
     viewOnly: '只读',
     showRunHistory: '显示运行历史',
+    copy: '拷贝',
+    duplicate: '复制',
+    addBlock: '添加节点',
+    pasteHere: '粘贴到这里',
+    pointerMode: '指针模式',
+    handMode: '手模式',
   },
   errorMsg: {
     fieldRequired: '{{field}} 不能为空',