mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-08 20:42:30 +08:00
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:
@ -7,7 +7,6 @@ import {
|
|||||||
import { useSetModalState } from '@/hooks/common-hooks';
|
import { useSetModalState } from '@/hooks/common-hooks';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Connection,
|
|
||||||
ConnectionMode,
|
ConnectionMode,
|
||||||
ControlButton,
|
ControlButton,
|
||||||
Controls,
|
Controls,
|
||||||
@ -17,7 +16,7 @@ import {
|
|||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import { NotebookPen } from 'lucide-react';
|
import { NotebookPen } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ChatSheet } from '../chat/chat-sheet';
|
import { ChatSheet } from '../chat/chat-sheet';
|
||||||
import { AgentBackground } from '../components/background';
|
import { AgentBackground } from '../components/background';
|
||||||
@ -37,7 +36,10 @@ import {
|
|||||||
import { useAddNode } from '../hooks/use-add-node';
|
import { useAddNode } from '../hooks/use-add-node';
|
||||||
import { useBeforeDelete } from '../hooks/use-before-delete';
|
import { useBeforeDelete } from '../hooks/use-before-delete';
|
||||||
import { useCacheChatLog } from '../hooks/use-cache-chat-log';
|
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 { useMoveNote } from '../hooks/use-move-note';
|
||||||
|
import { usePlaceholderManager } from '../hooks/use-placeholder-manager';
|
||||||
import { useDropdownManager } from './context';
|
import { useDropdownManager } from './context';
|
||||||
|
|
||||||
import Spotlight from '@/components/spotlight';
|
import Spotlight from '@/components/spotlight';
|
||||||
@ -62,6 +64,7 @@ import { KeywordNode } from './node/keyword-node';
|
|||||||
import { LogicNode } from './node/logic-node';
|
import { LogicNode } from './node/logic-node';
|
||||||
import { MessageNode } from './node/message-node';
|
import { MessageNode } from './node/message-node';
|
||||||
import NoteNode from './node/note-node';
|
import NoteNode from './node/note-node';
|
||||||
|
import { PlaceholderNode } from './node/placeholder-node';
|
||||||
import { RelevantNode } from './node/relevant-node';
|
import { RelevantNode } from './node/relevant-node';
|
||||||
import { RetrievalNode } from './node/retrieval-node';
|
import { RetrievalNode } from './node/retrieval-node';
|
||||||
import { RewriteNode } from './node/rewrite-node';
|
import { RewriteNode } from './node/rewrite-node';
|
||||||
@ -73,6 +76,7 @@ export const nodeTypes: NodeTypes = {
|
|||||||
ragNode: RagNode,
|
ragNode: RagNode,
|
||||||
categorizeNode: CategorizeNode,
|
categorizeNode: CategorizeNode,
|
||||||
beginNode: BeginNode,
|
beginNode: BeginNode,
|
||||||
|
placeholderNode: PlaceholderNode,
|
||||||
relevantNode: RelevantNode,
|
relevantNode: RelevantNode,
|
||||||
logicNode: LogicNode,
|
logicNode: LogicNode,
|
||||||
noteNode: NoteNode,
|
noteNode: NoteNode,
|
||||||
@ -176,19 +180,36 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
|
|||||||
const { visible, hideModal, showModal } = useSetModalState();
|
const { visible, hideModal, showModal } = useSetModalState();
|
||||||
const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 });
|
const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
const isConnectedRef = useRef(false);
|
const { clearActiveDropdown } = useDropdownManager();
|
||||||
const connectionStartRef = useRef<{
|
|
||||||
nodeId: string;
|
|
||||||
handleId: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
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(() => {
|
const onPaneClick = useCallback(() => {
|
||||||
hideFormDrawer();
|
hideFormDrawer();
|
||||||
if (visible && !preventCloseRef.current) {
|
if (visible && !shouldPreventClose()) {
|
||||||
|
removePlaceholderNode();
|
||||||
hideModal();
|
hideModal();
|
||||||
clearActiveDropdown();
|
clearActiveDropdown();
|
||||||
}
|
}
|
||||||
@ -199,55 +220,16 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
|
|||||||
}, [
|
}, [
|
||||||
hideFormDrawer,
|
hideFormDrawer,
|
||||||
visible,
|
visible,
|
||||||
|
shouldPreventClose,
|
||||||
hideModal,
|
hideModal,
|
||||||
imgVisible,
|
imgVisible,
|
||||||
addNoteNode,
|
addNoteNode,
|
||||||
mouse,
|
mouse,
|
||||||
hideImage,
|
hideImage,
|
||||||
clearActiveDropdown,
|
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 (
|
return (
|
||||||
<div className={styles.canvasWrapper}>
|
<div className={styles.canvasWrapper}>
|
||||||
<svg
|
<svg
|
||||||
@ -278,12 +260,13 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
|
|||||||
edges={edges}
|
edges={edges}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
fitView
|
fitView
|
||||||
onConnect={onConnect}
|
onConnect={handleConnect}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onConnectStart={OnConnectStart}
|
onConnectStart={onConnectStart}
|
||||||
onConnectEnd={OnConnectEnd}
|
onConnectEnd={onConnectEnd}
|
||||||
|
onMove={onMove}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
@ -324,20 +307,24 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
|
|||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
{visible && (
|
{visible && (
|
||||||
<HandleContext.Provider
|
<HandleContext.Provider
|
||||||
value={{
|
value={
|
||||||
nodeId: connectionStartRef.current?.nodeId || '',
|
getConnectionStartContext() || {
|
||||||
id: connectionStartRef.current?.handleId || '',
|
nodeId: '',
|
||||||
|
id: '',
|
||||||
type: 'source',
|
type: 'source',
|
||||||
position: Position.Right,
|
position: Position.Right,
|
||||||
isFromConnectionDrag: true,
|
isFromConnectionDrag: true,
|
||||||
}}
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<InnerNextStepDropdown
|
<InnerNextStepDropdown
|
||||||
hideModal={() => {
|
hideModal={() => {
|
||||||
|
removePlaceholderNode();
|
||||||
hideModal();
|
hideModal();
|
||||||
clearActiveDropdown();
|
clearActiveDropdown();
|
||||||
}}
|
}}
|
||||||
position={dropdownPosition}
|
position={dropdownPosition}
|
||||||
|
onNodeCreated={onNodeCreated}
|
||||||
>
|
>
|
||||||
<span></span>
|
<span></span>
|
||||||
</InnerNextStepDropdown>
|
</InnerNextStepDropdown>
|
||||||
|
|||||||
47
web/src/pages/agent/canvas/node/placeholder-node.tsx
Normal file
47
web/src/pages/agent/canvas/node/placeholder-node.tsx
Normal 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);
|
||||||
@ -90,6 +90,7 @@ export enum Operator {
|
|||||||
UserFillUp = 'UserFillUp',
|
UserFillUp = 'UserFillUp',
|
||||||
StringTransform = 'StringTransform',
|
StringTransform = 'StringTransform',
|
||||||
SearXNG = 'SearXNG',
|
SearXNG = 'SearXNG',
|
||||||
|
Placeholder = 'Placeholder',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SwitchLogicOperatorOptions = ['and', 'or'];
|
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 = [
|
export const CategorizeAnchorPointPositions = [
|
||||||
{ top: 1, right: 34 },
|
{ top: 1, right: 34 },
|
||||||
{ top: 8, right: 18 },
|
{ top: 8, right: 18 },
|
||||||
@ -900,6 +906,7 @@ export const NodeMap = {
|
|||||||
[Operator.UserFillUp]: 'ragNode',
|
[Operator.UserFillUp]: 'ragNode',
|
||||||
[Operator.StringTransform]: 'ragNode',
|
[Operator.StringTransform]: 'ragNode',
|
||||||
[Operator.TavilyExtract]: 'ragNode',
|
[Operator.TavilyExtract]: 'ragNode',
|
||||||
|
[Operator.Placeholder]: 'placeholderNode',
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum BeginQueryType {
|
export enum BeginQueryType {
|
||||||
@ -950,3 +957,12 @@ export enum AgentExceptionMethod {
|
|||||||
Comment = 'comment',
|
Comment = 'comment',
|
||||||
Goto = 'goto',
|
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;
|
||||||
|
|||||||
@ -336,6 +336,7 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
|
|||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
},
|
},
|
||||||
|
draggable: type === Operator.Placeholder ? false : undefined,
|
||||||
data: {
|
data: {
|
||||||
label: `${type}`,
|
label: `${type}`,
|
||||||
name: generateNodeNamesWithIncreasingIndex(
|
name: generateNodeNamesWithIncreasingIndex(
|
||||||
|
|||||||
200
web/src/pages/agent/hooks/use-connection-drag.ts
Normal file
200
web/src/pages/agent/hooks/use-connection-drag.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
106
web/src/pages/agent/hooks/use-dropdown-position.ts
Normal file
106
web/src/pages/agent/hooks/use-dropdown-position.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
141
web/src/pages/agent/hooks/use-placeholder-manager.ts
Normal file
141
web/src/pages/agent/hooks/use-placeholder-manager.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -61,7 +61,7 @@ export const useShowSingleDebugDrawer = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const ExcludedNodes = [Operator.Note];
|
const ExcludedNodes = [Operator.Note, Operator.Placeholder];
|
||||||
|
|
||||||
export function useShowDrawer({
|
export function useShowDrawer({
|
||||||
drawerVisible,
|
drawerVisible,
|
||||||
|
|||||||
Reference in New Issue
Block a user