diff --git a/web/src/pages/agent/canvas/context.tsx b/web/src/pages/agent/canvas/context.tsx new file mode 100644 index 000000000..203c37e99 --- /dev/null +++ b/web/src/pages/agent/canvas/context.tsx @@ -0,0 +1,56 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useRef, +} from 'react'; + +interface DropdownContextType { + canShowDropdown: () => boolean; + setActiveDropdown: (type: 'handle' | 'drag') => void; + clearActiveDropdown: () => void; +} + +const DropdownContext = createContext(null); + +export const useDropdownManager = () => { + const context = useContext(DropdownContext); + if (!context) { + throw new Error('useDropdownManager must be used within DropdownProvider'); + } + return context; +}; + +interface DropdownProviderProps { + children: ReactNode; +} + +export const DropdownProvider = ({ children }: DropdownProviderProps) => { + const activeDropdownRef = useRef<'handle' | 'drag' | null>(null); + + const canShowDropdown = useCallback(() => { + const current = activeDropdownRef.current; + return !current; + }, []); + + const setActiveDropdown = useCallback((type: 'handle' | 'drag') => { + activeDropdownRef.current = type; + }, []); + + const clearActiveDropdown = useCallback(() => { + activeDropdownRef.current = null; + }, []); + + const value: DropdownContextType = { + canShowDropdown, + setActiveDropdown, + clearActiveDropdown, + }; + + return ( + + {children} + + ); +}; diff --git a/web/src/pages/agent/canvas/index.tsx b/web/src/pages/agent/canvas/index.tsx index d7f68b42a..f2e77751b 100644 --- a/web/src/pages/agent/canvas/index.tsx +++ b/web/src/pages/agent/canvas/index.tsx @@ -4,17 +4,20 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; +import { useSetModalState } from '@/hooks/common-hooks'; import { cn } from '@/lib/utils'; import { + Connection, ConnectionMode, ControlButton, Controls, NodeTypes, + Position, ReactFlow, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { NotebookPen } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ChatSheet } from '../chat/chat-sheet'; import { AgentBackground } from '../components/background'; @@ -22,7 +25,9 @@ import { AgentChatContext, AgentChatLogContext, AgentInstanceContext, + HandleContext, } from '../context'; + import FormSheet from '../form-sheet/next'; import { useHandleDrop, @@ -33,6 +38,8 @@ import { useAddNode } from '../hooks/use-add-node'; import { useBeforeDelete } from '../hooks/use-before-delete'; import { useCacheChatLog } from '../hooks/use-cache-chat-log'; import { useMoveNote } from '../hooks/use-move-note'; +import { useDropdownManager } from './context'; + import { useHideFormSheetOnNodeDeletion, useShowDrawer, @@ -46,6 +53,7 @@ import { RagNode } from './node'; import { AgentNode } from './node/agent-node'; import { BeginNode } from './node/begin-node'; import { CategorizeNode } from './node/categorize-node'; +import { InnerNextStepDropdown } from './node/dropdown/next-step-dropdown'; import { GenerateNode } from './node/generate-node'; import { InvokeNode } from './node/invoke-node'; import { IterationNode, IterationStartNode } from './node/iteration-node'; @@ -96,7 +104,7 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) { const { nodes, edges, - onConnect, + onConnect: originalOnConnect, onEdgesChange, onNodesChange, onSelectionChange, @@ -147,14 +155,6 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) { const { theme } = useTheme(); - const onPaneClick = useCallback(() => { - hideFormDrawer(); - if (imgVisible) { - addNoteNode(mouse); - hideImage(); - } - }, [addNoteNode, hideFormDrawer, hideImage, imgVisible, mouse]); - useEffect(() => { if (!chatVisible) { clearEventList(); @@ -172,6 +172,73 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) { useHideFormSheetOnNodeDeletion({ hideFormDrawer }); + 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 preventCloseRef = useRef(false); + + const { setActiveDropdown, clearActiveDropdown } = useDropdownManager(); + + const onPaneClick = useCallback(() => { + hideFormDrawer(); + if (visible && !preventCloseRef.current) { + hideModal(); + clearActiveDropdown(); + } + if (imgVisible) { + addNoteNode(mouse); + hideImage(); + } + }, [ + hideFormDrawer, + visible, + hideModal, + imgVisible, + addNoteNode, + mouse, + hideImage, + clearActiveDropdown, + ]); + + 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) => { + 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 && ( + + { + hideModal(); + clearActiveDropdown(); + }} + position={dropdownPosition} + > + + + + )} ['showModal']>(() => {}); +const OnNodeCreatedContext = createContext< + ((newNodeId: string) => void) | undefined +>(undefined); -function OperatorItemList({ operators }: OperatorItemProps) { +function OperatorItemList({ + operators, + isCustomDropdown = false, + mousePosition, +}: OperatorItemProps) { const { addCanvasNode } = useContext(AgentInstanceContext); - const { nodeId, id, position } = useContext(HandleContext); + const handleContext = useContext(HandleContext); const hideModal = useContext(HideModalContext); + const onNodeCreated = useContext(OnNodeCreatedContext); const { t } = useTranslation(); - return ( -
    - {operators.map((x) => { - return ( - - - hideModal?.()} - > - - {t(`flow.${lowerFirst(x)}`)} - - - -

    {t(`flow.${lowerFirst(x)}Description`)}

    -
    -
    - ); - })} -
- ); + const handleClick = (operator: Operator) => { + const contextData = handleContext || { + nodeId: '', + id: '', + type: 'source' as const, + position: Position.Right, + isFromConnectionDrag: true, + }; + + const mockEvent = mousePosition + ? { + clientX: mousePosition.x, + clientY: mousePosition.y, + } + : undefined; + + const newNodeId = addCanvasNode(operator, contextData)(mockEvent); + + if (onNodeCreated && newNodeId) { + onNodeCreated(newNodeId); + } + + hideModal?.(); + }; + + const renderOperatorItem = (operator: Operator) => { + const commonContent = ( +
+ + {t(`flow.${lowerFirst(operator)}`)} +
+ ); + + return ( + + + {isCustomDropdown ? ( +
  • handleClick(operator)}>{commonContent}
  • + ) : ( + handleClick(operator)} + onSelect={() => hideModal?.()} + > + + {t(`flow.${lowerFirst(operator)}`)} + + )} +
    + +

    {t(`flow.${lowerFirst(operator)}Description`)}

    +
    +
    + ); + }; + + return
      {operators.map(renderOperatorItem)}
    ; } -function AccordionOperators() { +function AccordionOperators({ + isCustomDropdown = false, + mousePosition, +}: { + isCustomDropdown?: boolean; + mousePosition?: { x: number; y: number }; +}) { return ( @@ -76,6 +132,8 @@ function AccordionOperators() { @@ -84,6 +142,8 @@ function AccordionOperators() { @@ -96,6 +156,8 @@ function AccordionOperators() { Operator.Iteration, Operator.Categorize, ]} + isCustomDropdown={isCustomDropdown} + mousePosition={mousePosition} > @@ -106,6 +168,8 @@ function AccordionOperators() { @@ -129,6 +193,8 @@ function AccordionOperators() { Operator.Invoke, Operator.WenCai, ]} + isCustomDropdown={isCustomDropdown} + mousePosition={mousePosition} > @@ -139,9 +205,69 @@ function AccordionOperators() { export function InnerNextStepDropdown({ children, hideModal, -}: PropsWithChildren & IModalProps) { + position, + onNodeCreated, +}: PropsWithChildren & + IModalProps & { + position?: { x: number; y: number }; + onNodeCreated?: (newNodeId: string) => void; + }) { + const dropdownRef = useRef(null); + + useEffect(() => { + if (position && hideModal) { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + hideModal(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + } + }, [position, hideModal]); + + if (position) { + return ( +
    e.stopPropagation()} + > +
    +
    +
    Next Step
    +
    + + + + + +
    +
    + ); + } + return ( - + { + if (!open && hideModal) { + hideModal(); + } + }} + > {children} e.stopPropagation()} diff --git a/web/src/pages/agent/canvas/node/handle.tsx b/web/src/pages/agent/canvas/node/handle.tsx index 45d29e474..71b473cc7 100644 --- a/web/src/pages/agent/canvas/node/handle.tsx +++ b/web/src/pages/agent/canvas/node/handle.tsx @@ -4,6 +4,7 @@ import { Handle, HandleProps } from '@xyflow/react'; import { Plus } from 'lucide-react'; import { useMemo } from 'react'; import { HandleContext } from '../../context'; +import { useDropdownManager } from '../context'; import { InnerNextStepDropdown } from './dropdown/next-step-dropdown'; export function CommonHandle({ @@ -13,12 +14,16 @@ export function CommonHandle({ }: HandleProps & { nodeId: string }) { const { visible, hideModal, showModal } = useSetModalState(); + const { canShowDropdown, setActiveDropdown, clearActiveDropdown } = + useDropdownManager(); + const value = useMemo( () => ({ nodeId, - id: props.id, + id: props.id || undefined, type: props.type, position: props.position, + isFromConnectionDrag: false, }), [nodeId, props.id, props.position, props.type], ); @@ -33,12 +38,23 @@ export function CommonHandle({ )} onClick={(e) => { e.stopPropagation(); + + if (!canShowDropdown()) { + return; + } + + setActiveDropdown('handle'); showModal(); }} > {visible && ( - + { + hideModal(); + clearActiveDropdown(); + }} + > )} diff --git a/web/src/pages/agent/context.ts b/web/src/pages/agent/context.ts index 99da271cd..6839554d3 100644 --- a/web/src/pages/agent/context.ts +++ b/web/src/pages/agent/context.ts @@ -42,6 +42,7 @@ export type HandleContextType = { id?: string; type: HandleType; position: Position; + isFromConnectionDrag: boolean; }; export const HandleContext = createContext( diff --git a/web/src/pages/agent/hooks/use-add-node.ts b/web/src/pages/agent/hooks/use-add-node.ts index 8b2ebb0d8..a4e184d30 100644 --- a/web/src/pages/agent/hooks/use-add-node.ts +++ b/web/src/pages/agent/hooks/use-add-node.ts @@ -208,7 +208,7 @@ function useAddToolNode() { ); const addToolNode = useCallback( - (newNode: Node, nodeId?: string) => { + (newNode: Node, nodeId?: string): boolean => { const agentNode = getNode(nodeId); if (agentNode) { @@ -222,7 +222,7 @@ function useAddToolNode() { childToolNodeIds.length > 0 && nodes.some((x) => x.id === childToolNodeIds[0]) ) { - return; + return false; } newNode.position = { @@ -239,7 +239,9 @@ function useAddToolNode() { targetHandle: NodeHandleId.End, }); } + return true; } + return false; }, [addEdge, addNode, edges, getNode, nodes], ); @@ -295,13 +297,17 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance) { const addCanvasNode = useCallback( ( type: string, - params: { nodeId?: string; position: Position; id?: string } = { + params: { + nodeId?: string; + position: Position; + id?: string; + isFromConnectionDrag?: boolean; + } = { position: Position.Right, }, ) => - (event?: CanvasMouseEvent) => { + (event?: CanvasMouseEvent): string | undefined => { const nodeId = params.nodeId; - const node = getNode(nodeId); // reactFlowInstance.project was renamed to reactFlowInstance.screenToFlowPosition @@ -312,7 +318,11 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance) { y: event?.clientY || 0, }); - if (params.position === Position.Right && type !== Operator.Note) { + if ( + params.position === Position.Right && + type !== Operator.Note && + !params.isFromConnectionDrag + ) { position = calculateNewlyBackChildPosition(nodeId, params.id); } @@ -371,6 +381,7 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance) { targetHandle: NodeHandleId.End, }); } + return newNode.id; } else if ( type === Operator.Agent && params.position === Position.Bottom @@ -406,8 +417,10 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance) { targetHandle: NodeHandleId.AgentTop, }); } + return newNode.id; } else if (type === Operator.Tool) { - addToolNode(newNode, params.nodeId); + const toolNodeAdded = addToolNode(newNode, params.nodeId); + return toolNodeAdded ? newNode.id : undefined; } else { addNode(newNode); addChildEdge(params.position, { @@ -416,6 +429,8 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance) { sourceHandle: params.id, }); } + + return newNode.id; }, [ addChildEdge, diff --git a/web/src/pages/agent/index.tsx b/web/src/pages/agent/index.tsx index e8017430c..6d6cd9886 100644 --- a/web/src/pages/agent/index.tsx +++ b/web/src/pages/agent/index.tsx @@ -34,6 +34,7 @@ import { ComponentPropsWithoutRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'umi'; import AgentCanvas from './canvas'; +import { DropdownProvider } from './canvas/context'; import EmbedDialog from './embed-dialog'; import { useHandleExportOrImportJsonFile } from './hooks/use-export-json'; import { useFetchDataOnMount } from './hooks/use-fetch-data'; @@ -185,10 +186,12 @@ export default function Agent() {
    - + + + {fileUploadVisible && (