### What problem does this PR solve? feat: duplicate node #918 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.8.0
| @@ -3,18 +3,20 @@ import { DeleteOutlined, MoreOutlined } from '@ant-design/icons'; | |||
| import { Dropdown, MenuProps, Space } from 'antd'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import React from 'react'; | |||
| import React, { useMemo } from 'react'; | |||
| import styles from './index.less'; | |||
| interface IProps { | |||
| deleteItem: () => Promise<any> | void; | |||
| iconFontSize?: number; | |||
| items?: MenuProps['items']; | |||
| } | |||
| const OperateDropdown = ({ | |||
| deleteItem, | |||
| children, | |||
| iconFontSize = 30, | |||
| items: otherItems = [], | |||
| }: React.PropsWithChildren<IProps>) => { | |||
| const { t } = useTranslation(); | |||
| const showDeleteConfirm = useShowDeleteConfirm(); | |||
| @@ -31,17 +33,20 @@ const OperateDropdown = ({ | |||
| } | |||
| }; | |||
| const items: MenuProps['items'] = [ | |||
| { | |||
| key: '1', | |||
| label: ( | |||
| <Space> | |||
| {t('common.delete')} | |||
| <DeleteOutlined /> | |||
| </Space> | |||
| ), | |||
| }, | |||
| ]; | |||
| const items: MenuProps['items'] = useMemo(() => { | |||
| return [ | |||
| { | |||
| key: '1', | |||
| label: ( | |||
| <Space> | |||
| {t('common.delete')} | |||
| <DeleteOutlined /> | |||
| </Space> | |||
| ), | |||
| }, | |||
| ...otherItems, | |||
| ]; | |||
| }, [t, otherItems]); | |||
| return ( | |||
| <Dropdown | |||
| @@ -63,6 +63,8 @@ export function NodeContextMenu({ | |||
| ); | |||
| } | |||
| /* @deprecated | |||
| */ | |||
| export const useHandleNodeContextMenu = (sideWidth: number) => { | |||
| const [menu, setMenu] = useState<INodeContextMenu>({} as INodeContextMenu); | |||
| const ref = useRef<any>(null); | |||
| @@ -8,7 +8,6 @@ import ReactFlow, { | |||
| } from 'reactflow'; | |||
| import 'reactflow/dist/style.css'; | |||
| import { NodeContextMenu, useHandleNodeContextMenu } from './context-menu'; | |||
| import { ButtonEdge } from './edge'; | |||
| import FlowDrawer from '../flow-drawer'; | |||
| @@ -30,12 +29,11 @@ const edgeTypes = { | |||
| }; | |||
| interface IProps { | |||
| sideWidth: number; | |||
| chatDrawerVisible: boolean; | |||
| hideChatDrawer(): void; | |||
| } | |||
| function FlowCanvas({ sideWidth, chatDrawerVisible, hideChatDrawer }: IProps) { | |||
| function FlowCanvas({ chatDrawerVisible, hideChatDrawer }: IProps) { | |||
| const { | |||
| nodes, | |||
| edges, | |||
| @@ -45,8 +43,6 @@ function FlowCanvas({ sideWidth, chatDrawerVisible, hideChatDrawer }: IProps) { | |||
| onSelectionChange, | |||
| } = useSelectCanvasData(); | |||
| const { ref, menu, onNodeContextMenu, onPaneClick } = | |||
| useHandleNodeContextMenu(sideWidth); | |||
| const { drawerVisible, hideDrawer, showDrawer, clickedNode } = | |||
| useShowDrawer(); | |||
| @@ -64,18 +60,15 @@ function FlowCanvas({ sideWidth, chatDrawerVisible, hideChatDrawer }: IProps) { | |||
| return ( | |||
| <div className={styles.canvasWrapper}> | |||
| <ReactFlow | |||
| ref={ref} | |||
| connectionMode={ConnectionMode.Loose} | |||
| nodes={nodes} | |||
| onNodesChange={onNodesChange} | |||
| onNodeContextMenu={onNodeContextMenu} | |||
| edges={edges} | |||
| onEdgesChange={onEdgesChange} | |||
| fitView | |||
| onConnect={onConnect} | |||
| nodeTypes={nodeTypes} | |||
| edgeTypes={edgeTypes} | |||
| onPaneClick={onPaneClick} | |||
| onDrop={onDrop} | |||
| onDragOver={onDragOver} | |||
| onNodeClick={onNodeClick} | |||
| @@ -95,9 +88,6 @@ function FlowCanvas({ sideWidth, chatDrawerVisible, hideChatDrawer }: IProps) { | |||
| > | |||
| <Background /> | |||
| <Controls /> | |||
| {Object.keys(menu).length > 0 && ( | |||
| <NodeContextMenu onClick={onPaneClick} {...(menu as any)} /> | |||
| )} | |||
| </ReactFlow> | |||
| <FlowDrawer | |||
| node={clickedNode} | |||
| @@ -2,24 +2,50 @@ import classNames from 'classnames'; | |||
| import { Handle, NodeProps, Position } from 'reactflow'; | |||
| import OperateDropdown from '@/components/operate-dropdown'; | |||
| import { Flex, Space } from 'antd'; | |||
| import { CopyOutlined } from '@ant-design/icons'; | |||
| import { Flex, MenuProps, Space, Typography } from 'antd'; | |||
| import { useCallback } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { Operator, operatorMap } from '../../constant'; | |||
| import OperatorIcon from '../../operator-icon'; | |||
| import useGraphStore from '../../store'; | |||
| import styles from './index.less'; | |||
| const { Text } = Typography; | |||
| export function RagNode({ | |||
| id, | |||
| data, | |||
| isConnectable = true, | |||
| selected, | |||
| }: NodeProps<{ label: string }>) { | |||
| const { t } = useTranslation(); | |||
| const deleteNodeById = useGraphStore((store) => store.deleteNodeById); | |||
| const duplicateNodeById = useGraphStore((store) => store.duplicateNode); | |||
| const deleteNode = useCallback(() => { | |||
| deleteNodeById(id); | |||
| }, [id, deleteNodeById]); | |||
| const duplicateNode = useCallback(() => { | |||
| duplicateNodeById(id); | |||
| }, [id, duplicateNodeById]); | |||
| const description = operatorMap[data.label as Operator].description; | |||
| const items: MenuProps['items'] = [ | |||
| { | |||
| key: '2', | |||
| onClick: duplicateNode, | |||
| label: ( | |||
| <Flex justify={'space-between'}> | |||
| {t('common.copy')} | |||
| <CopyOutlined /> | |||
| </Flex> | |||
| ), | |||
| }, | |||
| ]; | |||
| return ( | |||
| <section | |||
| className={classNames(styles.ragNode, { | |||
| @@ -57,10 +83,17 @@ export function RagNode({ | |||
| <OperateDropdown | |||
| iconFontSize={14} | |||
| deleteItem={deleteNode} | |||
| items={items} | |||
| ></OperateDropdown> | |||
| </Flex> | |||
| <div className={styles.description}> | |||
| {operatorMap[data.label as Operator].description} | |||
| <div> | |||
| <Text | |||
| ellipsis={{ tooltip: description }} | |||
| style={{ width: 130 }} | |||
| className={styles.description} | |||
| > | |||
| {description} | |||
| </Text> | |||
| </div> | |||
| </section> | |||
| ); | |||
| @@ -27,7 +27,6 @@ function RagFlow() { | |||
| <FlowHeader showChatDrawer={showChatDrawer}></FlowHeader> | |||
| <Content style={{ margin: 0 }}> | |||
| <FlowCanvas | |||
| sideWidth={collapsed ? 0 : 200} | |||
| chatDrawerVisible={chatDrawerVisible} | |||
| hideChatDrawer={hideChatDrawer} | |||
| ></FlowCanvas> | |||
| @@ -1,4 +1,5 @@ | |||
| import type {} from '@redux-devtools/extension'; | |||
| import { humanId } from 'human-id'; | |||
| import { | |||
| Connection, | |||
| Edge, | |||
| @@ -32,6 +33,8 @@ export type RFState = { | |||
| updateNodeForm: (nodeId: string, values: any) => void; | |||
| onSelectionChange: OnSelectionChangeFunc; | |||
| addNode: (nodes: Node) => void; | |||
| getNode: (id: string) => Node | undefined; | |||
| duplicateNode: (id: string) => void; | |||
| deleteEdge: () => void; | |||
| deleteEdgeById: (id: string) => void; | |||
| deleteNodeById: (id: string) => void; | |||
| @@ -76,6 +79,26 @@ const useGraphStore = create<RFState>()( | |||
| addNode: (node: Node) => { | |||
| set({ nodes: get().nodes.concat(node) }); | |||
| }, | |||
| getNode: (id: string) => { | |||
| return get().nodes.find((x) => x.id === id); | |||
| }, | |||
| duplicateNode: (id: string) => { | |||
| const { getNode, addNode } = get(); | |||
| const node = getNode(id); | |||
| const position = { | |||
| x: (node?.position?.x || 0) + 30, | |||
| y: (node?.position?.y || 0) + 20, | |||
| }; | |||
| addNode({ | |||
| ...(node || {}), | |||
| data: node?.data, | |||
| selected: false, | |||
| dragging: false, | |||
| id: `${node?.data?.label}:${humanId()}`, | |||
| position, | |||
| }); | |||
| }, | |||
| deleteEdge: () => { | |||
| const { edges, selectedEdgeIds } = get(); | |||
| set({ | |||