Feat: Keep connection status during generating agent by drag and drop … (#10141)

### What problem does this PR solve?

About issue #10140

In version 0.20.1, we implemented the generation of new node through
mouse drag and drop. If we could create a workflow module like in Coze,
where there is not only a dropdown menu but also an intermediate node
(placeholder node) after the drag and drop is completed, this could
improve the user experience.

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
FatMii
2025-09-29 10:28:19 +08:00
committed by GitHub
parent 0b759f559c
commit 8426cbbd02
8 changed files with 560 additions and 62 deletions

View File

@ -7,7 +7,6 @@ import {
import { useSetModalState } from '@/hooks/common-hooks';
import { cn } from '@/lib/utils';
import {
Connection,
ConnectionMode,
ControlButton,
Controls,
@ -17,7 +16,7 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { NotebookPen } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChatSheet } from '../chat/chat-sheet';
import { AgentBackground } from '../components/background';
@ -37,7 +36,10 @@ import {
import { useAddNode } from '../hooks/use-add-node';
import { useBeforeDelete } from '../hooks/use-before-delete';
import { useCacheChatLog } from '../hooks/use-cache-chat-log';
import { useConnectionDrag } from '../hooks/use-connection-drag';
import { useDropdownPosition } from '../hooks/use-dropdown-position';
import { useMoveNote } from '../hooks/use-move-note';
import { usePlaceholderManager } from '../hooks/use-placeholder-manager';
import { useDropdownManager } from './context';
import Spotlight from '@/components/spotlight';
@ -62,6 +64,7 @@ import { KeywordNode } from './node/keyword-node';
import { LogicNode } from './node/logic-node';
import { MessageNode } from './node/message-node';
import NoteNode from './node/note-node';
import { PlaceholderNode } from './node/placeholder-node';
import { RelevantNode } from './node/relevant-node';
import { RetrievalNode } from './node/retrieval-node';
import { RewriteNode } from './node/rewrite-node';
@ -73,6 +76,7 @@ export const nodeTypes: NodeTypes = {
ragNode: RagNode,
categorizeNode: CategorizeNode,
beginNode: BeginNode,
placeholderNode: PlaceholderNode,
relevantNode: RelevantNode,
logicNode: LogicNode,
noteNode: NoteNode,
@ -176,19 +180,36 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
const { visible, hideModal, showModal } = useSetModalState();
const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 });
const isConnectedRef = useRef(false);
const connectionStartRef = useRef<{
nodeId: string;
handleId: string;
} | null>(null);
const { clearActiveDropdown } = useDropdownManager();
const preventCloseRef = useRef(false);
const { removePlaceholderNode, onNodeCreated, setCreatedPlaceholderRef } =
usePlaceholderManager(reactFlowInstance);
const { setActiveDropdown, clearActiveDropdown } = useDropdownManager();
const { calculateDropdownPosition } = useDropdownPosition(reactFlowInstance);
const {
onConnectStart,
onConnectEnd,
handleConnect,
getConnectionStartContext,
shouldPreventClose,
onMove,
} = useConnectionDrag(
reactFlowInstance,
originalOnConnect,
showModal,
hideModal,
setDropdownPosition,
setCreatedPlaceholderRef,
calculateDropdownPosition,
removePlaceholderNode,
clearActiveDropdown,
);
const onPaneClick = useCallback(() => {
hideFormDrawer();
if (visible && !preventCloseRef.current) {
if (visible && !shouldPreventClose()) {
removePlaceholderNode();
hideModal();
clearActiveDropdown();
}
@ -199,55 +220,16 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
}, [
hideFormDrawer,
visible,
shouldPreventClose,
hideModal,
imgVisible,
addNoteNode,
mouse,
hideImage,
clearActiveDropdown,
removePlaceholderNode,
]);
const onConnect = (connection: Connection) => {
originalOnConnect(connection);
isConnectedRef.current = true;
};
const OnConnectStart = (event: any, params: any) => {
isConnectedRef.current = false;
if (params && params.nodeId && params.handleId) {
connectionStartRef.current = {
nodeId: params.nodeId,
handleId: params.handleId,
};
} else {
connectionStartRef.current = null;
}
};
const OnConnectEnd = (event: MouseEvent | TouchEvent) => {
const target = event.target as HTMLElement;
// Clicking Handle will also trigger OnConnectEnd.
// To solve the problem that the operator on the right side added by clicking Handle will overlap with the original operator, this event is blocked here.
// TODO: However, a better way is to add both operators in the same way as OnConnectEnd.
if (target?.classList.contains('react-flow__handle')) {
return;
}
if ('clientX' in event && 'clientY' in event) {
const { clientX, clientY } = event;
setDropdownPosition({ x: clientX, y: clientY });
if (!isConnectedRef.current) {
setActiveDropdown('drag');
showModal();
preventCloseRef.current = true;
setTimeout(() => {
preventCloseRef.current = false;
}, 300);
}
}
};
return (
<div className={styles.canvasWrapper}>
<svg
@ -278,12 +260,13 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
edges={edges}
onEdgesChange={onEdgesChange}
fitView
onConnect={onConnect}
onConnect={handleConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onDrop={onDrop}
onConnectStart={OnConnectStart}
onConnectEnd={OnConnectEnd}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onMove={onMove}
onDragOver={onDragOver}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
@ -324,20 +307,24 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
</ReactFlow>
{visible && (
<HandleContext.Provider
value={{
nodeId: connectionStartRef.current?.nodeId || '',
id: connectionStartRef.current?.handleId || '',
value={
getConnectionStartContext() || {
nodeId: '',
id: '',
type: 'source',
position: Position.Right,
isFromConnectionDrag: true,
}}
}
}
>
<InnerNextStepDropdown
hideModal={() => {
removePlaceholderNode();
hideModal();
clearActiveDropdown();
}}
position={dropdownPosition}
onNodeCreated={onNodeCreated}
>
<span></span>
</InnerNextStepDropdown>

View File

@ -0,0 +1,47 @@
import { cn } from '@/lib/utils';
import { NodeProps, Position } from '@xyflow/react';
import { Skeleton } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { NodeHandleId, Operator } from '../../constant';
import OperatorIcon from '../../operator-icon';
import { CommonHandle } from './handle';
import { LeftHandleStyle } from './handle-icon';
import styles from './index.less';
import { NodeWrapper } from './node-wrapper';
function InnerPlaceholderNode({ data, id, selected }: NodeProps) {
const { t } = useTranslation();
return (
<NodeWrapper selected={selected}>
<CommonHandle
type="target"
position={Position.Left}
isConnectable
style={LeftHandleStyle}
nodeId={id}
id={NodeHandleId.End}
></CommonHandle>
<section className="flex items-center gap-2">
<OperatorIcon name={data.label as Operator}></OperatorIcon>
<div className="truncate text-center font-semibold text-sm">
{t(`flow.placeholder`, 'Placeholder')}
</div>
</section>
<section
className={cn(styles.generateParameters, 'flex gap-2 flex-col mt-2')}
>
<Skeleton active paragraph={{ rows: 2 }} title={false} />
<div className="flex gap-2">
<Skeleton.Button active size="small" />
<Skeleton.Button active size="small" />
</div>
</section>
</NodeWrapper>
);
}
export const PlaceholderNode = memo(InnerPlaceholderNode);

View File

@ -90,6 +90,7 @@ export enum Operator {
UserFillUp = 'UserFillUp',
StringTransform = 'StringTransform',
SearXNG = 'SearXNG',
Placeholder = 'Placeholder',
}
export const SwitchLogicOperatorOptions = ['and', 'or'];
@ -780,6 +781,11 @@ export const initialTavilyExtractValues = {
},
};
export const initialPlaceholderValues = {
// Placeholder node doesn't need any specific form values
// It's just a visual placeholder
};
export const CategorizeAnchorPointPositions = [
{ top: 1, right: 34 },
{ top: 8, right: 18 },
@ -900,6 +906,7 @@ export const NodeMap = {
[Operator.UserFillUp]: 'ragNode',
[Operator.StringTransform]: 'ragNode',
[Operator.TavilyExtract]: 'ragNode',
[Operator.Placeholder]: 'placeholderNode',
};
export enum BeginQueryType {
@ -950,3 +957,12 @@ export enum AgentExceptionMethod {
Comment = 'comment',
Goto = 'goto',
}
export const PLACEHOLDER_NODE_WIDTH = 200;
export const PLACEHOLDER_NODE_HEIGHT = 60;
export const DROPDOWN_SPACING = 25;
export const DROPDOWN_ADDITIONAL_OFFSET = 50;
export const HALF_PLACEHOLDER_NODE_WIDTH = PLACEHOLDER_NODE_WIDTH / 2;
export const HALF_PLACEHOLDER_NODE_HEIGHT =
PLACEHOLDER_NODE_HEIGHT + DROPDOWN_SPACING + DROPDOWN_ADDITIONAL_OFFSET;
export const PREVENT_CLOSE_DELAY = 300;

View File

@ -336,6 +336,7 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
x: 0,
y: 0,
},
draggable: type === Operator.Placeholder ? false : undefined,
data: {
label: `${type}`,
name: generateNodeNamesWithIncreasingIndex(

View File

@ -0,0 +1,200 @@
import { Connection, Position } from '@xyflow/react';
import { useCallback, useRef } from 'react';
import { useDropdownManager } from '../canvas/context';
import { Operator, PREVENT_CLOSE_DELAY } from '../constant';
import { useAddNode } from './use-add-node';
interface ConnectionStartParams {
nodeId: string;
handleId: string;
}
/**
* Connection drag management Hook
* Responsible for handling connection drag start and end logic
*/
export const useConnectionDrag = (
reactFlowInstance: any,
onConnect: (connection: Connection) => void,
showModal: () => void,
hideModal: () => void,
setDropdownPosition: (position: { x: number; y: number }) => void,
setCreatedPlaceholderRef: (nodeId: string | null) => void,
calculateDropdownPosition: (
clientX: number,
clientY: number,
) => { x: number; y: number },
removePlaceholderNode: () => void,
clearActiveDropdown: () => void,
) => {
// Reference for whether connection is established
const isConnectedRef = useRef(false);
// Reference for connection start parameters
const connectionStartRef = useRef<ConnectionStartParams | null>(null);
// Reference to prevent immediate close
const preventCloseRef = useRef(false);
// Reference to track mouse position for click detection
const mouseStartPosRef = useRef<{ x: number; y: number } | null>(null);
const { addCanvasNode } = useAddNode(reactFlowInstance);
const { setActiveDropdown } = useDropdownManager();
/**
* Connection start handler function
*/
const onConnectStart = useCallback((event: any, params: any) => {
isConnectedRef.current = false;
// Record mouse start position to detect click vs drag
if ('clientX' in event && 'clientY' in event) {
mouseStartPosRef.current = { x: event.clientX, y: event.clientY };
}
if (params && params.nodeId && params.handleId) {
connectionStartRef.current = {
nodeId: params.nodeId,
handleId: params.handleId,
};
} else {
connectionStartRef.current = null;
}
}, []);
/**
* Connection end handler function
*/
const onConnectEnd = useCallback(
(event: MouseEvent | TouchEvent) => {
if ('clientX' in event && 'clientY' in event) {
const { clientX, clientY } = event;
setDropdownPosition({ x: clientX, y: clientY });
if (!isConnectedRef.current && connectionStartRef.current) {
// Check mouse movement distance to distinguish click from drag
let isHandleClick = false;
if (mouseStartPosRef.current) {
const movementDistance = Math.sqrt(
Math.pow(clientX - mouseStartPosRef.current.x, 2) +
Math.pow(clientY - mouseStartPosRef.current.y, 2),
);
isHandleClick = movementDistance < 5; // Consider clicks within 5px as handle clicks
}
if (isHandleClick) {
connectionStartRef.current = null;
mouseStartPosRef.current = null;
return;
}
// Create placeholder node and establish connection
const mockEvent = { clientX, clientY };
const contextData = {
nodeId: connectionStartRef.current.nodeId,
id: connectionStartRef.current.handleId,
type: 'source' as const,
position: Position.Right,
isFromConnectionDrag: true,
};
// Use Placeholder operator to create node
const newNodeId = addCanvasNode(
Operator.Placeholder,
contextData,
)(mockEvent);
// Record the created placeholder node ID
if (newNodeId) {
setCreatedPlaceholderRef(newNodeId);
}
// Calculate placeholder node position and display dropdown menu
if (newNodeId && reactFlowInstance) {
const dropdownScreenPosition = calculateDropdownPosition(
clientX,
clientY,
);
setDropdownPosition({
x: dropdownScreenPosition.x,
y: dropdownScreenPosition.y,
});
setActiveDropdown('drag');
showModal();
preventCloseRef.current = true;
setTimeout(() => {
preventCloseRef.current = false;
}, PREVENT_CLOSE_DELAY);
}
// Reset connection state
connectionStartRef.current = null;
mouseStartPosRef.current = null;
}
}
},
[
setDropdownPosition,
addCanvasNode,
setCreatedPlaceholderRef,
reactFlowInstance,
calculateDropdownPosition,
setActiveDropdown,
showModal,
],
);
/**
* Connection establishment handler function
*/
const handleConnect = useCallback(
(connection: Connection) => {
onConnect(connection);
isConnectedRef.current = true;
},
[onConnect],
);
/**
* Get connection start context data
*/
const getConnectionStartContext = useCallback(() => {
if (!connectionStartRef.current) {
return null;
}
return {
nodeId: connectionStartRef.current.nodeId,
id: connectionStartRef.current.handleId,
type: 'source' as const,
position: Position.Right,
isFromConnectionDrag: true,
};
}, []);
/**
* Check if close should be prevented
*/
const shouldPreventClose = useCallback(() => {
return preventCloseRef.current;
}, []);
/**
* Handle canvas move/zoom events
* Hide dropdown and remove placeholder when user scrolls or moves canvas
*/
const onMove = useCallback(() => {
// Clean up placeholder and dropdown when canvas moves/zooms
removePlaceholderNode();
hideModal();
clearActiveDropdown();
}, [removePlaceholderNode, hideModal, clearActiveDropdown]);
return {
onConnectStart,
onConnectEnd,
handleConnect,
getConnectionStartContext,
shouldPreventClose,
onMove,
};
};

View File

@ -0,0 +1,106 @@
import { useCallback } from 'react';
import {
HALF_PLACEHOLDER_NODE_HEIGHT,
HALF_PLACEHOLDER_NODE_WIDTH,
} from '../constant';
/**
* Dropdown position calculation Hook
* Responsible for calculating dropdown menu position relative to placeholder node
*/
export const useDropdownPosition = (reactFlowInstance: any) => {
/**
* Calculate dropdown menu position
* @param clientX Mouse click screen X coordinate
* @param clientY Mouse click screen Y coordinate
* @returns Dropdown menu screen coordinates
*/
const calculateDropdownPosition = useCallback(
(clientX: number, clientY: number) => {
if (!reactFlowInstance) {
return { x: clientX, y: clientY };
}
// Convert screen coordinates to flow coordinates
const placeholderNodePosition = reactFlowInstance.screenToFlowPosition({
x: clientX,
y: clientY,
});
// Calculate dropdown position in flow coordinate system
const dropdownFlowPosition = {
x: placeholderNodePosition.x - HALF_PLACEHOLDER_NODE_WIDTH, // Placeholder node left-aligned offset
y: placeholderNodePosition.y + HALF_PLACEHOLDER_NODE_HEIGHT, // Placeholder node height plus spacing
};
// Convert flow coordinates back to screen coordinates
const dropdownScreenPosition =
reactFlowInstance.flowToScreenPosition(dropdownFlowPosition);
return {
x: dropdownScreenPosition.x,
y: dropdownScreenPosition.y,
};
},
[reactFlowInstance],
);
/**
* Calculate placeholder node flow coordinate position
* @param clientX Mouse click screen X coordinate
* @param clientY Mouse click screen Y coordinate
* @returns Placeholder node flow coordinates
*/
const getPlaceholderNodePosition = useCallback(
(clientX: number, clientY: number) => {
if (!reactFlowInstance) {
return { x: clientX, y: clientY };
}
return reactFlowInstance.screenToFlowPosition({
x: clientX,
y: clientY,
});
},
[reactFlowInstance],
);
/**
* Convert flow coordinates to screen coordinates
* @param flowPosition Flow coordinates
* @returns Screen coordinates
*/
const flowToScreenPosition = useCallback(
(flowPosition: { x: number; y: number }) => {
if (!reactFlowInstance) {
return flowPosition;
}
return reactFlowInstance.flowToScreenPosition(flowPosition);
},
[reactFlowInstance],
);
/**
* Convert screen coordinates to flow coordinates
* @param screenPosition Screen coordinates
* @returns Flow coordinates
*/
const screenToFlowPosition = useCallback(
(screenPosition: { x: number; y: number }) => {
if (!reactFlowInstance) {
return screenPosition;
}
return reactFlowInstance.screenToFlowPosition(screenPosition);
},
[reactFlowInstance],
);
return {
calculateDropdownPosition,
getPlaceholderNodePosition,
flowToScreenPosition,
screenToFlowPosition,
};
};

View File

@ -0,0 +1,141 @@
import { useCallback, useRef } from 'react';
import useGraphStore from '../store';
/**
* Placeholder node management Hook
* Responsible for managing placeholder node creation, deletion, and state tracking
*/
export const usePlaceholderManager = (reactFlowInstance: any) => {
// Reference to the created placeholder node ID
const createdPlaceholderRef = useRef<string | null>(null);
// Flag indicating whether user has selected a node
const userSelectedNodeRef = useRef(false);
/**
* Function to remove placeholder node
* Called when user clicks blank area or cancels operation
*/
const removePlaceholderNode = useCallback(() => {
if (
createdPlaceholderRef.current &&
reactFlowInstance &&
!userSelectedNodeRef.current
) {
const { nodes, edges } = useGraphStore.getState();
// Remove edges related to placeholder
const edgesToRemove = edges.filter(
(edge) =>
edge.target === createdPlaceholderRef.current ||
edge.source === createdPlaceholderRef.current,
);
// Remove placeholder node
const nodesToRemove = nodes.filter(
(node) => node.id === createdPlaceholderRef.current,
);
if (nodesToRemove.length > 0 || edgesToRemove.length > 0) {
reactFlowInstance.deleteElements({
nodes: nodesToRemove,
edges: edgesToRemove,
});
}
createdPlaceholderRef.current = null;
}
// Reset user selection flag
userSelectedNodeRef.current = false;
}, [reactFlowInstance]);
/**
* User node selection callback
* Called when user selects a node type from dropdown menu
*/
const onNodeCreated = useCallback(
(newNodeId: string) => {
// First establish connection between new node and source, then delete placeholder
if (createdPlaceholderRef.current && reactFlowInstance) {
const { nodes, edges, addEdge, updateNode } = useGraphStore.getState();
// Find placeholder node to get its position
const placeholderNode = nodes.find(
(node) => node.id === createdPlaceholderRef.current,
);
// Find placeholder-related connection and get source node info
const placeholderEdge = edges.find(
(edge) => edge.target === createdPlaceholderRef.current,
);
// Update new node position to match placeholder position
if (placeholderNode) {
const newNode = nodes.find((node) => node.id === newNodeId);
if (newNode) {
updateNode({
...newNode,
position: placeholderNode.position,
});
}
}
if (placeholderEdge) {
// Establish connection between new node and source node
addEdge({
source: placeholderEdge.source,
target: newNodeId,
sourceHandle: placeholderEdge.sourceHandle || null,
targetHandle: placeholderEdge.targetHandle || null,
});
}
// Remove placeholder node and related connections
const edgesToRemove = edges.filter(
(edge) =>
edge.target === createdPlaceholderRef.current ||
edge.source === createdPlaceholderRef.current,
);
const nodesToRemove = nodes.filter(
(node) => node.id === createdPlaceholderRef.current,
);
if (nodesToRemove.length > 0 || edgesToRemove.length > 0) {
reactFlowInstance.deleteElements({
nodes: nodesToRemove,
edges: edgesToRemove,
});
}
}
// Mark that user has selected a node
userSelectedNodeRef.current = true;
createdPlaceholderRef.current = null;
},
[reactFlowInstance],
);
/**
* Set the created placeholder node ID
*/
const setCreatedPlaceholderRef = useCallback((nodeId: string | null) => {
createdPlaceholderRef.current = nodeId;
}, []);
/**
* Reset user selection flag
*/
const resetUserSelectedFlag = useCallback(() => {
userSelectedNodeRef.current = false;
}, []);
return {
removePlaceholderNode,
onNodeCreated,
setCreatedPlaceholderRef,
resetUserSelectedFlag,
createdPlaceholderRef: createdPlaceholderRef.current,
userSelectedNodeRef: userSelectedNodeRef.current,
};
};

View File

@ -61,7 +61,7 @@ export const useShowSingleDebugDrawer = () => {
};
};
const ExcludedNodes = [Operator.Note];
const ExcludedNodes = [Operator.Note, Operator.Placeholder];
export function useShowDrawer({
drawerVisible,