diff --git a/web/src/pages/agent/canvas/index.tsx b/web/src/pages/agent/canvas/index.tsx index 74e6d146a7b..712696b44c1 100644 --- a/web/src/pages/agent/canvas/index.tsx +++ b/web/src/pages/agent/canvas/index.tsx @@ -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 (
{visible && ( { + removePlaceholderNode(); hideModal(); clearActiveDropdown(); }} position={dropdownPosition} + onNodeCreated={onNodeCreated} > diff --git a/web/src/pages/agent/canvas/node/placeholder-node.tsx b/web/src/pages/agent/canvas/node/placeholder-node.tsx new file mode 100644 index 00000000000..b828a0ab6b6 --- /dev/null +++ b/web/src/pages/agent/canvas/node/placeholder-node.tsx @@ -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 ( + + + +
+ +
+ {t(`flow.placeholder`, 'Placeholder')} +
+
+ +
+ +
+ + +
+
+
+ ); +} + +export const PlaceholderNode = memo(InnerPlaceholderNode); diff --git a/web/src/pages/agent/constant.tsx b/web/src/pages/agent/constant.tsx index 5abad0a03a7..de21ae9e989 100644 --- a/web/src/pages/agent/constant.tsx +++ b/web/src/pages/agent/constant.tsx @@ -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; diff --git a/web/src/pages/agent/hooks/use-add-node.ts b/web/src/pages/agent/hooks/use-add-node.ts index 625eeb5cded..e679c417197 100644 --- a/web/src/pages/agent/hooks/use-add-node.ts +++ b/web/src/pages/agent/hooks/use-add-node.ts @@ -336,6 +336,7 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance) { x: 0, y: 0, }, + draggable: type === Operator.Placeholder ? false : undefined, data: { label: `${type}`, name: generateNodeNamesWithIncreasingIndex( diff --git a/web/src/pages/agent/hooks/use-connection-drag.ts b/web/src/pages/agent/hooks/use-connection-drag.ts new file mode 100644 index 00000000000..19e388c64a3 --- /dev/null +++ b/web/src/pages/agent/hooks/use-connection-drag.ts @@ -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(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, + }; +}; diff --git a/web/src/pages/agent/hooks/use-dropdown-position.ts b/web/src/pages/agent/hooks/use-dropdown-position.ts new file mode 100644 index 00000000000..38f4d5cea50 --- /dev/null +++ b/web/src/pages/agent/hooks/use-dropdown-position.ts @@ -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, + }; +}; diff --git a/web/src/pages/agent/hooks/use-placeholder-manager.ts b/web/src/pages/agent/hooks/use-placeholder-manager.ts new file mode 100644 index 00000000000..db852785026 --- /dev/null +++ b/web/src/pages/agent/hooks/use-placeholder-manager.ts @@ -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(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, + }; +}; diff --git a/web/src/pages/agent/hooks/use-show-drawer.tsx b/web/src/pages/agent/hooks/use-show-drawer.tsx index 6789e86ba6f..f17209b80fe 100644 --- a/web/src/pages/agent/hooks/use-show-drawer.tsx +++ b/web/src/pages/agent/hooks/use-show-drawer.tsx @@ -61,7 +61,7 @@ export const useShowSingleDebugDrawer = () => { }; }; -const ExcludedNodes = [Operator.Note]; +const ExcludedNodes = [Operator.Note, Operator.Placeholder]; export function useShowDrawer({ drawerVisible,