diff --git a/web/src/views/Workflow/components/Nodes/AddNode.tsx b/web/src/views/Workflow/components/Nodes/AddNode.tsx
index 9b9d2236..dd0ab23d 100644
--- a/web/src/views/Workflow/components/Nodes/AddNode.tsx
+++ b/web/src/views/Workflow/components/Nodes/AddNode.tsx
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-09 18:31:30
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-06 11:43:58
+ * @Last Modified time: 2026-03-30 11:55:10
*/
import { useState } from 'react';
import { Popover, Flex } from 'antd';
@@ -173,7 +173,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
align="center"
justify="center"
gap={4}
- className={clsx('rb:text-[#212332] rb:font-medium rb:text-[12px] rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:border rb:rounded-lg rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:border-[#DFE4ED] rb:flex rb:items-center rb:justify-center', {
+ className={clsx('rb:text-[#212332] rb:font-medium rb:text-[12px] rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:border rb:rounded-lg rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:border-[#FCFCFD] rb:flex rb:items-center rb:justify-center', {
'rb:border-orange-500 rb:border-[3px] rb:bg-[#FCFCFD] rb:text-[#475467]': data.isSelected,
'rb:border-[#d1d5db] rb:bg-[#FCFCFD] rb:text-[#374151]': !data.isSelected
})}
diff --git a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx
index 12ae6ca0..516b5125 100644
--- a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx
+++ b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx
@@ -48,7 +48,7 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
return (
diff --git a/web/src/views/Workflow/components/Nodes/GroupStartNode.tsx b/web/src/views/Workflow/components/Nodes/GroupStartNode.tsx
index 0f963adc..4a29531f 100644
--- a/web/src/views/Workflow/components/Nodes/GroupStartNode.tsx
+++ b/web/src/views/Workflow/components/Nodes/GroupStartNode.tsx
@@ -3,7 +3,7 @@ import type { ReactShapeConfig } from '@antv/x6-react-shape';
const GroupStartNode: ReactShapeConfig['component'] = () => {
return (
-
+
);
diff --git a/web/src/views/Workflow/components/Nodes/LoopNode.tsx b/web/src/views/Workflow/components/Nodes/LoopNode.tsx
index b8c2ea0c..29c683cc 100644
--- a/web/src/views/Workflow/components/Nodes/LoopNode.tsx
+++ b/web/src/views/Workflow/components/Nodes/LoopNode.tsx
@@ -122,7 +122,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
return (
diff --git a/web/src/views/Workflow/components/Nodes/NormalNode.tsx b/web/src/views/Workflow/components/Nodes/NormalNode.tsx
index 340e95dc..12e89cca 100644
--- a/web/src/views/Workflow/components/Nodes/NormalNode.tsx
+++ b/web/src/views/Workflow/components/Nodes/NormalNode.tsx
@@ -12,7 +12,7 @@ const NormalNode: ReactShapeConfig['component'] = ({ node }) => {
return (
diff --git a/web/src/views/Workflow/components/PortClickHandler.tsx b/web/src/views/Workflow/components/PortClickHandler.tsx
index 2cc0c3c5..13ad6b98 100644
--- a/web/src/views/Workflow/components/PortClickHandler.tsx
+++ b/web/src/views/Workflow/components/PortClickHandler.tsx
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-09 18:30:28
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-24 11:11:56
+ * @Last Modified time: 2026-03-30 15:14:02
*/
import { useEffect, useState } from 'react';
import { Popover } from 'antd';
@@ -20,13 +20,15 @@ const PortClickHandler: React.FC = ({ graph }) => {
const [sourceNode, setSourceNode] = useState(null);
const [sourcePort, setSourcePort] = useState('');
const [tempElement, setTempElement] = useState(null);
+ const [edgeInsertion, setEdgeInsertion] = useState(null);
useEffect(() => {
const handlePortClick = (event: CustomEvent) => {
- const { node, port, element, rect } = event.detail;
+ const { node, port, element, rect, edgeInsertion } = event.detail;
setSourceNode(node);
setSourcePort(port);
setTempElement(element);
+ setEdgeInsertion(edgeInsertion || null);
setPopoverPosition({ x: rect.left, y: rect.top });
setPopoverVisible(true);
};
@@ -72,15 +74,47 @@ const PortClickHandler: React.FC = ({ graph }) => {
const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort);
const sourcePortGroup = sourcePortInfo?.group || sourcePort;
- // If add-node position exists, use it; otherwise calculate new position
+ // Calculate new node position
let newX, newY;
- if (addNodePosition) {
+ if (edgeInsertion) {
+ // Edge insertion: place new node on the same row as target, between source and target
+ const targetBBox = edgeInsertion.targetCell.getBBox();
+ const gap = targetBBox.x - (sourceBBox.x + sourceBBox.width);
+ const requiredSpace = nodeWidth + horizontalSpacing * 4;
+
+ // New node x: right after source + spacing
+ newX = sourceBBox.x + sourceBBox.width + horizontalSpacing;
+ // Same row as target node
+ newY = targetBBox.y + (targetBBox.height - nodeHeight) / 2;
+
+ // If not enough space, shift target and all downstream nodes to the right
+ if (gap < requiredSpace) {
+ const shiftX = requiredSpace - gap;
+ const visited = new Set();
+ const shiftDownstream = (cell: any) => {
+ const cellId = cell.id;
+ if (visited.has(cellId)) return;
+ visited.add(cellId);
+ const pos = cell.getPosition();
+ cell.setPosition(pos.x + shiftX, pos.y);
+ // Recursively shift nodes connected from right ports
+ graph.getConnectedEdges(cell, { outgoing: true }).forEach((e: any) => {
+ const tId = e.getTargetCellId();
+ if (tId && !visited.has(tId)) {
+ const tCell = graph.getCellById(tId);
+ if (tCell?.isNode()) shiftDownstream(tCell);
+ }
+ });
+ };
+ shiftDownstream(edgeInsertion.targetCell);
+ }
+ } else if (addNodePosition) {
newX = addNodePosition.x;
newY = addNodePosition.y;
} else {
// Determine node placement direction based on port position
if (sourcePortGroup === 'left') {
- // Left port: add node to the left
+ // Left port: add node to the left
newX = sourceBBox.x - nodeWidth*2 - horizontalSpacing;
newY = sourceBBox.y;
} else {
@@ -91,7 +125,7 @@ const PortClickHandler: React.FC = ({ graph }) => {
// Check if position overlaps with existing nodes (only consider connected nodes)
const checkOverlap = (x: number, y: number) => {
- // Get nodes connected to the source node
+ // Get nodes connected to the source node
const connectedNodes = new Set();
graph.getConnectedEdges(sourceNode).forEach((edge: any) => {
const sourceId = edge.getSourceCellId();
@@ -108,7 +142,7 @@ const PortClickHandler: React.FC = ({ graph }) => {
y + nodeHeight < bbox.y || y > bbox.y + bbox.height);
});
};
-
+
// If position is occupied, search downward for empty space
while (checkOverlap(newX, newY)) {
newY += nodeHeight + verticalSpacing;
@@ -140,28 +174,51 @@ const PortClickHandler: React.FC = ({ graph }) => {
}
}
+ // Edge insertion: remove old edge immediately before creating new edges
+ if (edgeInsertion) {
+ const { edge: oldEdge } = edgeInsertion;
+ if (oldEdge.id && graph.getCellById(oldEdge.id)) {
+ graph.removeCell(oldEdge.id);
+ } else {
+ graph.removeEdge(oldEdge);
+ }
+ }
+
// Create edge connection
setTimeout(() => {
- const targetPorts = newNode.getPorts();
- let targetPort;
-
- if (sourcePortGroup === 'left') {
+ const newPorts = newNode.getPorts();
+
+ if (edgeInsertion) {
+ // Edge insertion: create source→new and new→target edges
+ const { targetCell, targetPort: origTargetPort } = edgeInsertion;
+ const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left';
+ const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right';
+ graph.addEdge({
+ source: { cell: sourceNode.id, port: sourcePort },
+ target: { cell: newNode.id, port: newLeftPort },
+ ...edgeAttrs
+ });
+ graph.addEdge({
+ source: { cell: newNode.id, port: newRightPort },
+ target: { cell: targetCell.id, port: origTargetPort },
+ ...edgeAttrs
+ });
+ setEdgeInsertion(null);
+ } else if (sourcePortGroup === 'left') {
// Connect from left port to new node's right side
- targetPort = targetPorts.find((port: any) => port.group === 'right')?.id || 'right';
+ const targetPort = newPorts.find((port: any) => port.group === 'right')?.id || 'right';
graph.addEdge({
source: { cell: newNode.id, port: targetPort },
target: { cell: sourceNode.id, port: sourcePort },
...edgeAttrs
- // zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
});
} else {
// Connect from right port to new node's left side
- targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left';
+ const targetPort = newPorts.find((port: any) => port.group === 'left')?.id || 'left';
graph.addEdge({
source: { cell: sourceNode.id, port: sourcePort },
target: { cell: newNode.id, port: targetPort },
...edgeAttrs
- // zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
});
}
diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts
index d62ef06f..50b92696 100644
--- a/web/src/views/Workflow/constant.ts
+++ b/web/src/views/Workflow/constant.ts
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:06:18
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-27 18:30:52
+ * @Last Modified time: 2026-03-30 15:11:56
*/
import LoopNode from './components/Nodes/LoopNode';
import NormalNode from './components/Nodes/NormalNode';
@@ -642,6 +642,8 @@ interface NodeConfig {
/** Edge color for normal state */
export const edge_color = '#D4D5D9';
+/** Edge color for hover state */
+export const edge_hover_color = '#2E90FA';
/** Edge color for selected state */
export const edge_selected_color = '#171719'
export const edge_width = 2;
@@ -884,4 +886,70 @@ export const edgeAttrs = {
},
},
},
+}
+
+/**
+ * Edge hover tool: circular "+" button shown at midpoint on hover
+ */
+export const edgeHoverTool = {
+ name: 'button',
+ args: {
+ markup: [
+ {
+ tagName: 'circle',
+ selector: 'button',
+ attrs: {
+ r: 6,
+ stroke: port_color,
+ strokeWidth: edge_width,
+ fill: port_color,
+ cursor: 'pointer',
+ },
+ },
+ {
+ tagName: 'text',
+ textContent: '+',
+ selector: 'icon',
+ attrs: {
+ fontSize: 12,
+ fontWeight: 'bold',
+ fill: '#FFFFFF',
+ textAnchor: 'middle',
+ textVerticalAnchor: 'middle',
+ pointerEvents: 'none',
+ y: '0.3em',
+ },
+ },
+ ],
+ distance: 0.5,
+ offset: { x: 0, y: 0 },
+ onClick({ e, cell: edge }: any) {
+ e.stopPropagation();
+ const graph = edge.model?.graph;
+ if (!graph) return;
+ const sourceCell = graph.getCellById(edge.getSourceCellId());
+ const targetCell = graph.getCellById(edge.getTargetCellId());
+ const sourcePort = edge.getSourcePortId();
+ const targetPort = edge.getTargetPortId();
+ if (!sourceCell || !targetCell) return;
+ const rect = (e.target as HTMLElement).getBoundingClientRect();
+ const tempDiv = document.createElement('div');
+ tempDiv.style.position = 'fixed';
+ tempDiv.style.left = rect.left + 'px';
+ tempDiv.style.top = rect.top + 'px';
+ tempDiv.style.width = '1px';
+ tempDiv.style.height = '1px';
+ tempDiv.style.zIndex = '9999';
+ document.body.appendChild(tempDiv);
+ window.dispatchEvent(new CustomEvent('port:click', {
+ detail: {
+ node: sourceCell,
+ port: sourcePort,
+ element: tempDiv,
+ rect,
+ edgeInsertion: { edge, sourceCell, targetCell, sourcePort, targetPort }
+ }
+ }));
+ },
+ },
}
\ No newline at end of file
diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts
index 626165da..4059c264 100644
--- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts
+++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:17:48
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-27 18:14:38
+ * @Last Modified time: 2026-03-30 15:08:14
*/
import { useRef, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
@@ -12,7 +12,7 @@ import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '
import { register } from '@antv/x6-react-shape';
import type { PortMetadata } from '@antv/x6/lib/model/port';
-import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, defaultPortItems, portItemArgsY, edge_width, conditionNodePortItemArgsY, conditionNodeItemHeight, conditionNodeHeight, notesConfig } from '../constant';
+import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edgeHoverTool, edge_color, edge_hover_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, defaultPortItems, portItemArgsY, edge_width, conditionNodePortItemArgsY, conditionNodeItemHeight, conditionNodeHeight, notesConfig } from '../constant';
import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types';
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
import { useUser } from '@/store/user';
@@ -881,12 +881,21 @@ export const useWorkflowGraph = ({
});
// Use plugins
setupPlugins();
- // Listen to edge mouseleave event
+ // Listen to edge mouseenter event: show hover style and add button
+ graphRef.current.on('edge:mouseenter', ({ edge }: { edge: Edge }) => {
+ if (edge.getAttrByPath('line/stroke') !== edge_selected_color) {
+ edge.setAttrByPath('line/stroke', edge_hover_color);
+ edge.setAttrByPath('line/strokeWidth', edge_width);
+ }
+ edge.addTools([edgeHoverTool]);
+ });
+ // Listen to edge mouseleave event: revert style and remove add button
graphRef.current.on('edge:mouseleave', ({ edge }: { edge: Edge }) => {
if (edge.getAttrByPath('line/stroke') !== edge_selected_color) {
edge.setAttrByPath('line/stroke', edge_color);
edge.setAttrByPath('line/strokeWidth', edge_width);
}
+ edge.removeTools();
});
// Listen to node selection event
graphRef.current.on('node:click', nodeClick);