diff --git a/web/app/components/workflow/hooks/use-helpline.ts b/web/app/components/workflow/hooks/use-helpline.ts index 55979904fb54fe..b007b0e450becf 100644 --- a/web/app/components/workflow/hooks/use-helpline.ts +++ b/web/app/components/workflow/hooks/use-helpline.ts @@ -4,6 +4,11 @@ import type { Node } from '../types' import { BlockEnum, isTriggerNode } from '../types' import { useWorkflowStore } from '../store' +type HelplineOptions = { + nodes?: Node[]; + visibleNodeIds?: Set; +} + // Entry node (Start/Trigger) wrapper offsets // The EntryNodeContainer adds a wrapper with status indicator above the actual node // These offsets ensure alignment happens on the inner node, not the wrapper @@ -29,28 +34,22 @@ export const useHelpline = () => { y: node.position.y + ENTRY_NODE_WRAPPER_OFFSET.y, } } + return { x: node.position.x, y: node.position.y, } }, [isEntryNode]) - const handleSetHelpline = useCallback((node: Node) => { - const { getNodes } = store.getState() - const nodes = getNodes() + const handleSetHelpline = useCallback((node: Node, options?: HelplineOptions) => { + const nodes = options?.nodes ?? store.getState().getNodes() + const visibleNodeIds = options?.visibleNodeIds const { setHelpLineHorizontal, setHelpLineVertical, } = workflowStore.getState() - if (node.data.isInIteration) { - return { - showHorizontalHelpLineNodes: [], - showVerticalHelpLineNodes: [], - } - } - - if (node.data.isInLoop) { + if (node.data.isInIteration || node.data.isInLoop) { return { showHorizontalHelpLineNodes: [], showVerticalHelpLineNodes: [], @@ -61,6 +60,10 @@ export const useHelpline = () => { const nodeAlignPos = getNodeAlignPosition(node) const showHorizontalHelpLineNodes = nodes.filter((n) => { + if (visibleNodeIds && !visibleNodeIds.has(n.id)) + return false + if (n.hidden) + return false if (n.id === node.id) return false @@ -124,6 +127,10 @@ export const useHelpline = () => { } const showVerticalHelpLineNodes = nodes.filter((n) => { + if (visibleNodeIds && !visibleNodeIds.has(n.id)) + return false + if (n.hidden) + return false if (n.id === node.id) return false if (n.data.isInIteration) @@ -188,7 +195,7 @@ export const useHelpline = () => { showHorizontalHelpLineNodes, showVerticalHelpLineNodes, } - }, [store, workflowStore, getNodeAlignPosition]) + }, [store, workflowStore, getNodeAlignPosition, isEntryNode]) return { handleSetHelpline, diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index d56b85893e988c..31c52c37ae9bdd 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1,5 +1,10 @@ import type { MouseEvent } from 'react' -import { useCallback, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useRef, + useState, +} from 'react' import { useTranslation } from 'react-i18next' import { produce } from 'immer' import type { @@ -64,6 +69,10 @@ import type { RAGPipelineVariables } from '@/models/pipeline' import useInspectVarsCrud from './use-inspect-vars-crud' import { getNodeUsedVars } from '../nodes/_base/components/variable/utils' +type UseNodesInteractionsOptions = { + getVisibleNodeIds?: () => Set | undefined; +} + // Entry node deletion restriction has been removed to allow empty workflows // Entry node (Start/Trigger) wrapper offsets for alignment @@ -73,7 +82,7 @@ const ENTRY_NODE_WRAPPER_OFFSET = { y: 21, // Adjusted based on visual testing feedback } as const -export const useNodesInteractions = () => { +export const useNodesInteractions = (options?: UseNodesInteractionsOptions) => { const { t } = useTranslation() const store = useStoreApi() const workflowStore = useWorkflowStore() @@ -92,6 +101,9 @@ export const useNodesInteractions = () => { x: number; y: number; }) + const dragAnimationFrameRef = useRef(null) + const pendingDragNodesRef = useRef>(new Map()) + const draggingNodeIdRef = useRef(null) const { nodesMap: nodesMetaDataMap } = useNodesMetaData() const { saveStateToHistory, undo, redo } = useWorkflowHistory() @@ -101,28 +113,207 @@ export const useNodesInteractions = () => { (_, node) => { workflowStore.setState({ nodeAnimation: false }) - if (getNodesReadOnly()) return - - if ( - node.type === CUSTOM_ITERATION_START_NODE - || node.type === CUSTOM_NOTE_NODE - ) + if (getNodesReadOnly()) { + draggingNodeIdRef.current = null return + } if ( - node.type === CUSTOM_LOOP_START_NODE + node.type === CUSTOM_ITERATION_START_NODE + || node.type === CUSTOM_LOOP_START_NODE || node.type === CUSTOM_NOTE_NODE - ) + ) { + draggingNodeIdRef.current = null return + } dragNodeStartPosition.current = { x: node.position.x, y: node.position.y, } + draggingNodeIdRef.current = node.id }, [workflowStore, getNodesReadOnly], ) + useEffect(() => { + return () => { + if (dragAnimationFrameRef.current !== null) + cancelAnimationFrame(dragAnimationFrameRef.current) + pendingDragNodesRef.current.clear() + draggingNodeIdRef.current = null + } + }, []) + + const applyNodeDragPosition = useCallback((pendingNodes: Node[]) => { + if (!pendingNodes.length) + return + + const { getNodes, setNodes } = store.getState() + const nodes = getNodes() + if (!nodes.length) + return + + const nodeMap = new Map(nodes.map(node => [node.id, node])) + const draggingNodeId = draggingNodeIdRef.current + + const primaryPendingNode = draggingNodeId + ? pendingNodes.find(node => node.id === draggingNodeId) + : pendingNodes[0] + + if (!primaryPendingNode) + return + + const primaryCurrentNode = nodeMap.get(primaryPendingNode.id) + if (!primaryCurrentNode) + return + + const getEntryOffset = (node: Node | undefined, axis: 'x' | 'y') => { + if (!node) + return 0 + return (isTriggerNode(node.data.type as any) || node.data.type === BlockEnum.Start) + ? ENTRY_NODE_WRAPPER_OFFSET[axis] + : 0 + } + + const resolveAxisPosition = ( + axis: 'x' | 'y', + candidate: number, + restrict?: number, + loopRestrict?: number, + helplineNodes?: Node[], + nodeForOffset?: Node, + ) => { + if (helplineNodes && helplineNodes.length > 0) { + const helplineNode = helplineNodes[0] + const targetPosition = axis === 'x' + ? helplineNode.position.x + : helplineNode.position.y + const targetOffset = getEntryOffset(helplineNode, axis) + const currentOffset = getEntryOffset(nodeForOffset, axis) + return targetPosition + targetOffset - currentOffset + } + + if (restrict !== undefined) + return restrict + + if (loopRestrict !== undefined) + return loopRestrict + + return candidate + } + + const primaryCandidateNode: Node = { + ...primaryCurrentNode, + position: { + x: primaryPendingNode.position.x, + y: primaryPendingNode.position.y, + }, + width: primaryPendingNode.width ?? primaryCurrentNode.width, + height: primaryPendingNode.height ?? primaryCurrentNode.height, + } + + const visibleNodeIds = options?.getVisibleNodeIds?.() + + const { restrictPosition } = handleNodeIterationChildDrag(primaryCandidateNode) + const { restrictPosition: restrictLoopPosition } + = handleNodeLoopChildDrag(primaryCandidateNode) + const { showHorizontalHelpLineNodes, showVerticalHelpLineNodes } + = handleSetHelpline(primaryCandidateNode, { nodes, visibleNodeIds }) + + const nextPrimaryX = resolveAxisPosition( + 'x', + primaryCandidateNode.position.x, + restrictPosition.x, + restrictLoopPosition.x, + showVerticalHelpLineNodes, + primaryCurrentNode, + ) + const nextPrimaryY = resolveAxisPosition( + 'y', + primaryCandidateNode.position.y, + restrictPosition.y, + restrictLoopPosition.y, + showHorizontalHelpLineNodes, + primaryCurrentNode, + ) + + const correctedDelta = { + x: nextPrimaryX - primaryCurrentNode.position.x, + y: nextPrimaryY - primaryCurrentNode.position.y, + } + + const pendingNodeIds = new Set(pendingNodes.map(node => node.id)) + const nodeIdsToMove = new Set(pendingNodeIds) + nodeIdsToMove.add(primaryCurrentNode.id) + + const nextPositions = new Map() + nextPositions.set(primaryCurrentNode.id, { + x: nextPrimaryX, + y: nextPrimaryY, + }) + + nodeIdsToMove.forEach((nodeId) => { + if (nodeId === primaryCurrentNode.id) + return + + const targetNode = nodeMap.get(nodeId) + if (!targetNode) + return + + const candidatePosition = { + x: targetNode.position.x + correctedDelta.x, + y: targetNode.position.y + correctedDelta.y, + } + const candidateNode: Node = { + ...targetNode, + position: candidatePosition, + } + + const { restrictPosition: childRestrictPosition } + = handleNodeIterationChildDrag(candidateNode) + const { restrictPosition: childRestrictLoopPosition } + = handleNodeLoopChildDrag(candidateNode) + + const nextX = resolveAxisPosition( + 'x', + candidatePosition.x, + childRestrictPosition.x, + childRestrictLoopPosition.x, + ) + const nextY = resolveAxisPosition( + 'y', + candidatePosition.y, + childRestrictPosition.y, + childRestrictLoopPosition.y, + ) + + nextPositions.set(nodeId, { + x: nextX, + y: nextY, + }) + }) + + const newNodes = produce(nodes, (draft) => { + draft.forEach((draftNode) => { + const nextPosition = nextPositions.get(draftNode.id) + if (!nextPosition) + return + + draftNode.position.x = nextPosition.x + draftNode.position.y = nextPosition.y + }) + }) + + setNodes(newNodes) + }, [ + store, + handleNodeIterationChildDrag, + handleNodeLoopChildDrag, + handleSetHelpline, + options?.getVisibleNodeIds, + ]) + const handleNodeDrag = useCallback( (e, node: Node) => { if (getNodesReadOnly()) return @@ -131,78 +322,26 @@ export const useNodesInteractions = () => { if (node.type === CUSTOM_LOOP_START_NODE) return - const { getNodes, setNodes } = store.getState() e.stopPropagation() - const nodes = getNodes() - - const { restrictPosition } = handleNodeIterationChildDrag(node) - const { restrictPosition: restrictLoopPosition } - = handleNodeLoopChildDrag(node) - - const { showHorizontalHelpLineNodes, showVerticalHelpLineNodes } - = handleSetHelpline(node) - const showHorizontalHelpLineNodesLength - = showHorizontalHelpLineNodes.length - const showVerticalHelpLineNodesLength = showVerticalHelpLineNodes.length + pendingDragNodesRef.current.set(node.id, node) - const newNodes = produce(nodes, (draft) => { - const currentNode = draft.find(n => n.id === node.id)! - - // Check if current dragging node is an entry node - const isCurrentEntryNode = isTriggerNode(node.data.type as any) || node.data.type === BlockEnum.Start - - // X-axis alignment with offset consideration - if (showVerticalHelpLineNodesLength > 0) { - const targetNode = showVerticalHelpLineNodes[0] - const isTargetEntryNode = isTriggerNode(targetNode.data.type as any) || targetNode.data.type === BlockEnum.Start - - // Calculate the wrapper position needed to align the inner nodes - // Target inner position = target.position + target.offset - // Current inner position should equal target inner position - // So: current.position + current.offset = target.position + target.offset - // Therefore: current.position = target.position + target.offset - current.offset - const targetOffset = isTargetEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.x : 0 - const currentOffset = isCurrentEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.x : 0 - currentNode.position.x = targetNode.position.x + targetOffset - currentOffset - } - else if (restrictPosition.x !== undefined) { - currentNode.position.x = restrictPosition.x - } - else if (restrictLoopPosition.x !== undefined) { - currentNode.position.x = restrictLoopPosition.x - } - else { - currentNode.position.x = node.position.x - } + if (dragAnimationFrameRef.current !== null) + return - // Y-axis alignment with offset consideration - if (showHorizontalHelpLineNodesLength > 0) { - const targetNode = showHorizontalHelpLineNodes[0] - const isTargetEntryNode = isTriggerNode(targetNode.data.type as any) || targetNode.data.type === BlockEnum.Start + dragAnimationFrameRef.current = requestAnimationFrame(() => { + dragAnimationFrameRef.current = null + if (!pendingDragNodesRef.current.size) + return - const targetOffset = isTargetEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.y : 0 - const currentOffset = isCurrentEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.y : 0 - currentNode.position.y = targetNode.position.y + targetOffset - currentOffset - } - else if (restrictPosition.y !== undefined) { - currentNode.position.y = restrictPosition.y - } - else if (restrictLoopPosition.y !== undefined) { - currentNode.position.y = restrictLoopPosition.y - } - else { - currentNode.position.y = node.position.y - } + const pendingNodes = Array.from(pendingDragNodesRef.current.values()) + pendingDragNodesRef.current.clear() + applyNodeDragPosition(pendingNodes) }) - setNodes(newNodes) }, [ getNodesReadOnly, - store, - handleNodeIterationChildDrag, - handleNodeLoopChildDrag, - handleSetHelpline, + applyNodeDragPosition, ], ) @@ -211,20 +350,41 @@ export const useNodesInteractions = () => { const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState() - if (getNodesReadOnly()) return + if (getNodesReadOnly()) { + draggingNodeIdRef.current = null + return + } + if (dragAnimationFrameRef.current !== null) { + cancelAnimationFrame(dragAnimationFrameRef.current) + dragAnimationFrameRef.current = null + } + const pendingNodes = pendingDragNodesRef.current.size + ? Array.from(pendingDragNodesRef.current.values()) + : [] + pendingDragNodesRef.current.clear() const { x, y } = dragNodeStartPosition.current - if (!(x === node.position.x && y === node.position.y)) { + const hasMoved = !(x === node.position.x && y === node.position.y) + if (!hasMoved) { + draggingNodeIdRef.current = null setHelpLineHorizontal() setHelpLineVertical() - handleSyncWorkflowDraft() + return + } + if (!pendingNodes.some(pendingNode => pendingNode.id === node.id)) + pendingNodes.push(node) + applyNodeDragPosition(pendingNodes) - if (x !== 0 && y !== 0) { - // selecting a note will trigger a drag stop event with x and y as 0 - saveStateToHistory(WorkflowHistoryEvent.NodeDragStop, { - nodeId: node.id, - }) - } + draggingNodeIdRef.current = null + setHelpLineHorizontal() + setHelpLineVertical() + handleSyncWorkflowDraft() + + if (x !== 0 && y !== 0) { + // selecting a note will trigger a drag stop event with x and y as 0 + saveStateToHistory(WorkflowHistoryEvent.NodeDragStop, { + nodeId: node.id, + }) } }, [ @@ -232,6 +392,7 @@ export const useNodesInteractions = () => { getNodesReadOnly, saveStateToHistory, handleSyncWorkflowDraft, + applyNodeDragPosition, ], ) diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 880f6520266912..a4d3daf4c85620 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { FC } from 'react' +import type { CSSProperties, FC } from 'react' import { memo, useCallback, @@ -24,6 +24,7 @@ import ReactFlow, { useReactFlow, useStoreApi, } from 'reactflow' +import type { NodeDragHandler } from 'reactflow' import type { Viewport, } from 'reactflow' @@ -97,6 +98,7 @@ import { useAllCustomTools, useAllMCPTools, useAllWorkflowTools, + useFetchToolsData, } from '@/service/use-tools' import { isEqual } from 'lodash-es' @@ -116,6 +118,13 @@ const edgeTypes = { [CUSTOM_EDGE]: CustomEdge, } +const INITIAL_RENDER_NODE_LIMIT = 200 +const VIEWPORT_NODE_BUFFER = 600 +const BACKGROUND_GAP: [number, number] = [14, 14] +const CONNECTION_LINE_CONTAINER_STYLE: CSSProperties = { + zIndex: ITERATION_CHILDREN_Z_INDEX, +} + export type WorkflowProps = { nodes: Node[] edges: Edge[] @@ -132,6 +141,9 @@ export const Workflow: FC = memo(({ }) => { const workflowContainerRef = useRef(null) const workflowStore = useWorkflowStore() + const mousePositionRafRef = useRef(null) + const lastMouseEventRef = useRef<{ clientX: number; clientY: number } | null>(null) + const viewportUpdateRafRef = useRef(null) const reactflow = useReactFlow() const [nodes, setNodes] = useNodesState(originalNodes) const [edges, setEdges] = useEdgesState(originalEdges) @@ -147,6 +159,60 @@ export const Workflow: FC = memo(({ return '100%' return workflowCanvasHeight - bottomPanelHeight }, [workflowCanvasHeight, bottomPanelHeight]) + const initialVisibleNodeIds = useMemo(() => { + if (!originalNodes || !originalNodes.length) + return [] + return originalNodes.slice(0, INITIAL_RENDER_NODE_LIMIT).map(node => node.id) + }, [originalNodes]) + const visibleNodeIdSetRef = useRef>(new Set(initialVisibleNodeIds)) + const isDraggingNodeRef = useRef(false) + const [isDraggingNode, setIsDraggingNode] = useState(false) + + const setVisibleNodeIds = useCallback((candidateNodeIds: string[]) => { + if (!candidateNodeIds) + return + + const nextSet = new Set(candidateNodeIds) + visibleNodeIdSetRef.current = nextSet + }, []) + + const updateVisibleNodesByViewport = useCallback(() => { + if (!workflowContainerRef.current || typeof reactflow.screenToFlowPosition !== 'function') + return + + const rect = workflowContainerRef.current.getBoundingClientRect() + const { + width, + height, + left, + top, + right, + bottom, + } = rect + if (!width || !height) + return + + const topLeft = reactflow.screenToFlowPosition({ x: left, y: top }) + const bottomRight = reactflow.screenToFlowPosition({ x: right, y: bottom }) + + const minX = Math.min(topLeft.x, bottomRight.x) - VIEWPORT_NODE_BUFFER + const maxX = Math.max(topLeft.x, bottomRight.x) + VIEWPORT_NODE_BUFFER + const minY = Math.min(topLeft.y, bottomRight.y) - VIEWPORT_NODE_BUFFER + const maxY = Math.max(topLeft.y, bottomRight.y) + VIEWPORT_NODE_BUFFER + + const nodesInViewport = nodes + .filter((node) => { + const { position, positionAbsolute } = node + const referencePosition = positionAbsolute ?? position + if (!referencePosition) + return false + const { x, y } = referencePosition + return x >= minX && x <= maxX && y >= minY && y <= maxY + }) + .map(node => node.id) + + setVisibleNodeIds(nodesInViewport) + }, [nodes, reactflow, setVisibleNodeIds]) // update workflow Canvas width and height useEffect(() => { @@ -212,6 +278,44 @@ export const Workflow: FC = memo(({ } }) + useEffect(() => { + if (!originalNodes || !originalNodes.length) + return + const initialIds = originalNodes.slice(0, INITIAL_RENDER_NODE_LIMIT).map(node => node.id) + visibleNodeIdSetRef.current = new Set(initialIds) + setVisibleNodeIds(initialIds) + }, [originalNodes, setVisibleNodeIds]) + + useEffect(() => { + setNodes((prevNodes) => { + let changed = false + const nextNodes = prevNodes.map((node) => { + if (!node.hidden) + return node + changed = true + return { + ...node, + hidden: false, + } + }) + return changed ? nextNodes : prevNodes + }) + + setEdges((prevEdges) => { + let changed = false + const nextEdges = prevEdges.map((edge) => { + if (!edge.hidden) + return edge + changed = true + return { + ...edge, + hidden: false, + } + }) + return changed ? nextEdges : prevEdges + }) + }, [setEdges, setNodes]) + useEffect(() => { setAutoFreeze(false) @@ -253,19 +357,60 @@ export const Workflow: FC = memo(({ e.preventDefault() }) useEventListener('mousemove', (e) => { - const containerClientRect = workflowContainerRef.current?.getBoundingClientRect() + lastMouseEventRef.current = { + clientX: e.clientX, + clientY: e.clientY, + } + + if (mousePositionRafRef.current !== null) + return + + mousePositionRafRef.current = requestAnimationFrame(() => { + mousePositionRafRef.current = null + const latestMousePosition = lastMouseEventRef.current + const containerClientRect = workflowContainerRef.current?.getBoundingClientRect() + + if (!latestMousePosition || !containerClientRect) + return - if (containerClientRect) { workflowStore.setState({ mousePosition: { - pageX: e.clientX, - pageY: e.clientY, - elementX: e.clientX - containerClientRect.left, - elementY: e.clientY - containerClientRect.top, + pageX: latestMousePosition.clientX, + pageY: latestMousePosition.clientY, + elementX: latestMousePosition.clientX - containerClientRect.left, + elementY: latestMousePosition.clientY - containerClientRect.top, }, }) - } + }) }) + useEffect(() => { + return () => { + if (viewportUpdateRafRef.current !== null) + cancelAnimationFrame(viewportUpdateRafRef.current) + if (mousePositionRafRef.current !== null) + cancelAnimationFrame(mousePositionRafRef.current) + } + }, []) + const { handleFetchAllTools } = useFetchToolsData() + useEffect(() => { + if (typeof window === 'undefined') + return + + const fetchAllTools = () => { + handleFetchAllTools('builtin') + handleFetchAllTools('custom') + handleFetchAllTools('workflow') + handleFetchAllTools('mcp') + } + + const timeoutId = window.setTimeout(fetchAllTools, 300) + + return () => { + window.clearTimeout(timeoutId) + } + }, [handleFetchAllTools]) + + const getVisibleNodeIds = useCallback(() => visibleNodeIdSetRef.current, []) const { handleNodeDragStart, @@ -280,7 +425,7 @@ export const Workflow: FC = memo(({ handleNodeContextMenu, handleHistoryBack, handleHistoryForward, - } = useNodesInteractions() + } = useNodesInteractions({ getVisibleNodeIds }) const { handleEdgeEnter, handleEdgeLeave, @@ -300,8 +445,21 @@ export const Workflow: FC = memo(({ } = useWorkflow() useOnViewportChange({ + onChange: () => { + if (isDraggingNodeRef.current) + return + + if (viewportUpdateRafRef.current !== null) + return + viewportUpdateRafRef.current = requestAnimationFrame(() => { + viewportUpdateRafRef.current = null + updateVisibleNodesByViewport() + }) + }, onEnd: () => { handleSyncWorkflowDraft() + if (!isDraggingNodeRef.current) + updateVisibleNodesByViewport() }, }) @@ -360,6 +518,54 @@ export const Workflow: FC = memo(({ } } + useEffect(() => { + if (isDraggingNodeRef.current) + return + updateVisibleNodesByViewport() + }, [nodes, updateVisibleNodesByViewport]) + + useEffect(() => { + if (isDraggingNodeRef.current) + return + + const currentNodeIds = new Set(nodes.map(node => node.id)) + const nextSet = new Set() + visibleNodeIdSetRef.current.forEach((nodeId) => { + if (currentNodeIds.has(nodeId)) + nextSet.add(nodeId) + }) + + setVisibleNodeIds(Array.from(nextSet)) + }, [nodes, setVisibleNodeIds]) + + const setDraggingState = useCallback((value: boolean) => { + isDraggingNodeRef.current = value + setIsDraggingNode(value) + }, []) + + const handleNodeDragStartWithVisibility = useCallback((event, node, nodesParam) => { + handleNodeDragStart(event, node, nodesParam) + + if (nodesReadOnly) + return + + if ( + node.type === CUSTOM_ITERATION_START_NODE + || node.type === CUSTOM_LOOP_START_NODE + || node.type === CUSTOM_NOTE_NODE + ) + return + + if (!isDraggingNodeRef.current) + setDraggingState(true) + }, [handleNodeDragStart, nodesReadOnly, setDraggingState]) + + const handleNodeDragStopWithVisibility = useCallback((event, node, nodesParam) => { + setDraggingState(false) + handleNodeDragStop(event, node, nodesParam) + updateVisibleNodesByViewport() + }, [handleNodeDragStop, setDraggingState, updateVisibleNodesByViewport]) + return (
= memo(({ 'relative h-full w-full min-w-[960px]', workflowReadOnly && 'workflow-panel-animation', nodeAnimation && 'workflow-node-animation', + isDraggingNode && 'workflow-dragging', )} ref={workflowContainerRef} > @@ -379,9 +586,9 @@ export const Workflow: FC = memo(({
- - - + { !isDraggingNode && } + { !isDraggingNode && } + { !isDraggingNode && } { !!showConfirm && ( @@ -400,9 +607,9 @@ export const Workflow: FC = memo(({ edgeTypes={edgeTypes} nodes={nodes} edges={edges} - onNodeDragStart={handleNodeDragStart} + onNodeDragStart={handleNodeDragStartWithVisibility} onNodeDrag={handleNodeDrag} - onNodeDragStop={handleNodeDragStop} + onNodeDragStop={handleNodeDragStopWithVisibility} onNodeMouseEnter={handleNodeEnter} onNodeMouseLeave={handleNodeLeave} onNodeClick={handleNodeClick} @@ -419,8 +626,8 @@ export const Workflow: FC = memo(({ onPaneContextMenu={handlePaneContextMenu} onSelectionContextMenu={handleSelectionContextMenu} connectionLineComponent={CustomConnectionLine} - // TODO: For LOOP node, how to distinguish between ITERATION and LOOP here? Maybe both are the same? - connectionLineContainerStyle={{ zIndex: ITERATION_CHILDREN_Z_INDEX }} + // NOTE: LOOP and ITERATION nodes currently share the same z-index styling. + connectionLineContainerStyle={CONNECTION_LINE_CONTAINER_STYLE} defaultViewport={viewport} multiSelectionKeyCode={null} deleteKeyCode={null} @@ -437,10 +644,11 @@ export const Workflow: FC = memo(({ selectionKeyCode={null} selectionMode={SelectionMode.Partial} selectionOnDrag={controlMode === ControlMode.Pointer && !workflowReadOnly} + onlyRenderVisibleElements minZoom={0.25} > { } CustomNode.displayName = 'CustomNode' +const areNodePropsEqual = (prev: NodeProps, next: NodeProps) => { + return prev.id === next.id + && prev.type === next.type + && prev.data === next.data + && prev.isConnectable === next.isConnectable + && prev.selected === next.selected +} + export type PanelProps = { type: Node['type'] id: Node['id'] @@ -61,4 +69,4 @@ export const Panel = memo((props: PanelProps) => { Panel.displayName = 'Panel' -export default memo(CustomNode) +export default memo(CustomNode, areNodePropsEqual) diff --git a/web/app/components/workflow/style.css b/web/app/components/workflow/style.css index 9d88ac2644c89d..99d2b3caec5183 100644 --- a/web/app/components/workflow/style.css +++ b/web/app/components/workflow/style.css @@ -24,3 +24,26 @@ #workflow-container .react-flow__attribution { background: none !important; } + +#workflow-container.workflow-dragging .react-flow__node { + transition: none !important; + filter: none !important; +} + +#workflow-container.workflow-dragging .react-flow__node .shadow-xs, +#workflow-container.workflow-dragging .react-flow__node .shadow-sm, +#workflow-container.workflow-dragging .react-flow__node .shadow, +#workflow-container.workflow-dragging .react-flow__node .shadow-md, +#workflow-container.workflow-dragging .react-flow__node .shadow-lg, +#workflow-container.workflow-dragging .react-flow__node .shadow-xl { + box-shadow: none !important; +} + +#workflow-container.workflow-dragging .react-flow__node [class*='backdrop-blur'] { + backdrop-filter: none !important; +} + +#workflow-container.workflow-dragging .react-flow__edge-path, +#workflow-container.workflow-dragging .react-flow__connection-path { + filter: none !important; +} diff --git a/web/service/use-tools.ts b/web/service/use-tools.ts index ad483bea110996..c8cbd07ad78612 100644 --- a/web/service/use-tools.ts +++ b/web/service/use-tools.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react' import { del, get, post, put } from './base' import type { Collection, @@ -17,6 +18,10 @@ import { const NAME_SPACE = 'tools' +const fetchAllBuiltInTools = () => { + return get('/workspaces/current/tools/builtin') +} + const useAllToolProvidersKey = [NAME_SPACE, 'allToolProviders'] export const useAllToolProviders = (enabled = true) => { return useQuery({ @@ -34,7 +39,7 @@ const useAllBuiltInToolsKey = [NAME_SPACE, 'builtIn'] export const useAllBuiltInTools = () => { return useQuery({ queryKey: useAllBuiltInToolsKey, - queryFn: () => get('/workspaces/current/tools/builtin'), + queryFn: fetchAllBuiltInTools, }) } @@ -42,11 +47,15 @@ export const useInvalidateAllBuiltInTools = () => { return useInvalid(useAllBuiltInToolsKey) } +const fetchAllCustomTools = () => { + return get('/workspaces/current/tools/api') +} + const useAllCustomToolsKey = [NAME_SPACE, 'customTools'] export const useAllCustomTools = () => { return useQuery({ queryKey: useAllCustomToolsKey, - queryFn: () => get('/workspaces/current/tools/api'), + queryFn: fetchAllCustomTools, }) } @@ -54,11 +63,15 @@ export const useInvalidateAllCustomTools = () => { return useInvalid(useAllCustomToolsKey) } +const fetchAllWorkflowTools = () => { + return get('/workspaces/current/tools/workflow') +} + const useAllWorkflowToolsKey = [NAME_SPACE, 'workflowTools'] export const useAllWorkflowTools = () => { return useQuery({ queryKey: useAllWorkflowToolsKey, - queryFn: () => get('/workspaces/current/tools/workflow'), + queryFn: fetchAllWorkflowTools, }) } @@ -66,11 +79,15 @@ export const useInvalidateAllWorkflowTools = () => { return useInvalid(useAllWorkflowToolsKey) } +const fetchAllMCPTools = () => { + return get('/workspaces/current/tools/mcp') +} + const useAllMCPToolsKey = [NAME_SPACE, 'MCPTools'] export const useAllMCPTools = () => { return useQuery({ queryKey: useAllMCPToolsKey, - queryFn: () => get('/workspaces/current/tools/mcp'), + queryFn: fetchAllMCPTools, }) } @@ -89,6 +106,43 @@ export const useInvalidToolsByType = (type?: CollectionType | string) => { return useInvalid(queryKey) } +const toolsQueryConfig: Record<'builtin' | 'custom' | 'workflow' | 'mcp', { key: QueryKey; fetcher: () => Promise }> = { + builtin: { + key: useAllBuiltInToolsKey, + fetcher: fetchAllBuiltInTools, + }, + custom: { + key: useAllCustomToolsKey, + fetcher: fetchAllCustomTools, + }, + workflow: { + key: useAllWorkflowToolsKey, + fetcher: fetchAllWorkflowTools, + }, + mcp: { + key: useAllMCPToolsKey, + fetcher: fetchAllMCPTools, + }, +} + +export type ToolCollectionFetchType = keyof typeof toolsQueryConfig + +export const useFetchToolsData = () => { + const queryClient = useQueryClient() + + const handleFetchAllTools = useCallback((type: ToolCollectionFetchType) => { + const config = toolsQueryConfig[type] + return queryClient.prefetchQuery({ + queryKey: config.key, + queryFn: config.fetcher, + }) + }, [queryClient]) + + return { + handleFetchAllTools, + } +} + export const useCreateMCP = () => { return useMutation({ mutationKey: [NAME_SPACE, 'create-mcp'],