diff --git a/web/package-lock.json b/web/package-lock.json index fa21c73383..bf88f374d2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,7 @@ "classnames": "^2.5.1", "dagre": "^0.8.5", "dayjs": "^1.11.10", + "elkjs": "^0.9.3", "eventsource-parser": "^1.1.2", "i18next": "^23.7.16", "i18next-browser-languagedetector": "^8.0.0", @@ -11110,6 +11111,11 @@ "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.767.tgz", "integrity": "sha512-nzzHfmQqBss7CE3apQHkHjXW77+8w3ubGCIoEijKCJebPufREaFETgGXWTkh32t259F3Kcq+R8MZdFdOJROgYw==" }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmmirror.com/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==" + }, "node_modules/elliptic": { "version": "6.5.5", "resolved": "https://registry.npmmirror.com/elliptic/-/elliptic-6.5.5.tgz", diff --git a/web/package.json b/web/package.json index f2798ff901..f8e8f3b640 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "classnames": "^2.5.1", "dagre": "^0.8.5", "dayjs": "^1.11.10", + "elkjs": "^0.9.3", "eventsource-parser": "^1.1.2", "i18next": "^23.7.16", "i18next-browser-languagedetector": "^8.0.0", diff --git a/web/src/locales/zh-traditional.ts b/web/src/locales/zh-traditional.ts index d12babd582..1369318ac5 100644 --- a/web/src/locales/zh-traditional.ts +++ b/web/src/locales/zh-traditional.ts @@ -24,7 +24,7 @@ export default { copied: '複製成功', comingSoon: '即將推出', download: '下載', - close: '关闭', + close: '關閉', preview: '預覽', }, login: { diff --git a/web/src/pages/flow/canvas/context-menu/index.tsx b/web/src/pages/flow/canvas/context-menu/index.tsx index c03e87cdd9..1edc2d2341 100644 --- a/web/src/pages/flow/canvas/context-menu/index.tsx +++ b/web/src/pages/flow/canvas/context-menu/index.tsx @@ -84,9 +84,6 @@ export const useHandleNodeContextMenu = (sideWidth: number) => { // event.clientY >= pane.height - 200 ? pane.height - event.clientY : 0, // }); - console.info('clientX:', event.clientX); - console.info('clientY:', event.clientY); - setMenu({ id: node.id, top: event.clientY - 72, diff --git a/web/src/pages/flow/canvas/index.tsx b/web/src/pages/flow/canvas/index.tsx index 9aa339b601..6db3c81bc2 100644 --- a/web/src/pages/flow/canvas/index.tsx +++ b/web/src/pages/flow/canvas/index.tsx @@ -17,9 +17,13 @@ import 'reactflow/dist/style.css'; import { NodeContextMenu, useHandleNodeContextMenu } from './context-menu'; import FlowDrawer from '../flow-drawer'; -import { useHandleDrop, useShowDrawer } from '../hooks'; -import { initialEdges, initialNodes } from '../mock'; -import { getLayoutedElements } from '../utils'; +import { + useHandleDrop, + useHandleKeyUp, + useHandleSelectionChange, + useShowDrawer, +} from '../hooks'; +import { dsl } from '../mock'; import { TextUpdaterNode } from './node'; const nodeTypes = { textUpdater: TextUpdaterNode }; @@ -29,13 +33,11 @@ interface IProps { } function FlowCanvas({ sideWidth }: IProps) { - const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( - initialNodes, - initialEdges, - 'LR', - ); - const [nodes, setNodes] = useState(layoutedNodes); - const [edges, setEdges] = useState(layoutedEdges); + const [nodes, setNodes] = useState(dsl.graph.nodes); + const [edges, setEdges] = useState(dsl.graph.edges); + + const { selectedEdges, selectedNodes } = useHandleSelectionChange(); + const { ref, menu, onNodeContextMenu, onPaneClick } = useHandleNodeContextMenu(sideWidth); const { drawerVisible, hideDrawer, showDrawer } = useShowDrawer(); @@ -60,6 +62,8 @@ function FlowCanvas({ sideWidth }: IProps) { const { onDrop, onDragOver, setReactFlowInstance } = useHandleDrop(setNodes); + const { handleKeyUp } = useHandleKeyUp(selectedEdges, selectedNodes); + useEffect(() => { console.info('nodes:', nodes); console.info('edges:', edges); @@ -82,6 +86,7 @@ function FlowCanvas({ sideWidth }: IProps) { onDragOver={onDragOver} onNodeClick={onNodeClick} onInit={setReactFlowInstance} + onKeyUp={handleKeyUp} > diff --git a/web/src/pages/flow/elk-hooks.ts b/web/src/pages/flow/elk-hooks.ts new file mode 100644 index 0000000000..eb8d61f154 --- /dev/null +++ b/web/src/pages/flow/elk-hooks.ts @@ -0,0 +1,35 @@ +import { useCallback, useLayoutEffect } from 'react'; +import { getLayoutedElements } from './elk-utils'; + +export const elkOptions = { + 'elk.algorithm': 'layered', + 'elk.layered.spacing.nodeNodeBetweenLayers': '100', + 'elk.spacing.nodeNode': '80', +}; + +export const useLayoutGraph = ( + initialNodes, + initialEdges, + setNodes, + setEdges, +) => { + const onLayout = useCallback(({ direction, useInitialNodes = false }) => { + const opts = { 'elk.direction': direction, ...elkOptions }; + const ns = initialNodes; + const es = initialEdges; + + getLayoutedElements(ns, es, opts).then( + ({ nodes: layoutedNodes, edges: layoutedEdges }) => { + setNodes(layoutedNodes); + setEdges(layoutedEdges); + + // window.requestAnimationFrame(() => fitView()); + }, + ); + }, []); + + // Calculate the initial layout on mount. + useLayoutEffect(() => { + onLayout({ direction: 'RIGHT', useInitialNodes: true }); + }, [onLayout]); +}; diff --git a/web/src/pages/flow/elk-utils.ts b/web/src/pages/flow/elk-utils.ts new file mode 100644 index 0000000000..b1f9ca8d97 --- /dev/null +++ b/web/src/pages/flow/elk-utils.ts @@ -0,0 +1,42 @@ +import ELK from 'elkjs/lib/elk.bundled.js'; +import { Edge, Node } from 'reactflow'; + +const elk = new ELK(); + +export const getLayoutedElements = ( + nodes: Node[], + edges: Edge[], + options = {}, +) => { + const isHorizontal = options?.['elk.direction'] === 'RIGHT'; + const graph = { + id: 'root', + layoutOptions: options, + children: nodes.map((node) => ({ + ...node, + // Adjust the target and source handle positions based on the layout + // direction. + targetPosition: isHorizontal ? 'left' : 'top', + sourcePosition: isHorizontal ? 'right' : 'bottom', + + // Hardcode a width and height for elk to use when layouting. + width: 150, + height: 50, + })), + edges: edges, + }; + + return elk + .layout(graph) + .then((layoutedGraph) => ({ + nodes: layoutedGraph.children.map((node) => ({ + ...node, + // React Flow expects a position property on the node instead of `x` + // and `y` fields. + position: { x: node.x, y: node.y }, + })), + + edges: layoutedGraph.edges, + })) + .catch(console.error); +}; diff --git a/web/src/pages/flow/header/index.less b/web/src/pages/flow/header/index.less new file mode 100644 index 0000000000..832d54bafc --- /dev/null +++ b/web/src/pages/flow/header/index.less @@ -0,0 +1,3 @@ +.flowHeader { + padding: 20px; +} diff --git a/web/src/pages/flow/header/index.tsx b/web/src/pages/flow/header/index.tsx new file mode 100644 index 0000000000..57293ffbd0 --- /dev/null +++ b/web/src/pages/flow/header/index.tsx @@ -0,0 +1,26 @@ +import { Button, Flex } from 'antd'; + +import { useSaveGraph } from '../hooks'; +import styles from './index.less'; + +const FlowHeader = () => { + const { saveGraph } = useSaveGraph(); + + return ( + + + + + ); +}; + +export default FlowHeader; diff --git a/web/src/pages/flow/hooks.ts b/web/src/pages/flow/hooks.ts index 57d09c5da6..23e5216bcb 100644 --- a/web/src/pages/flow/hooks.ts +++ b/web/src/pages/flow/hooks.ts @@ -1,6 +1,18 @@ import { useSetModalState } from '@/hooks/commonHooks'; -import React, { Dispatch, SetStateAction, useCallback, useState } from 'react'; -import { Node, Position, ReactFlowInstance } from 'reactflow'; +import React, { + Dispatch, + KeyboardEventHandler, + SetStateAction, + useCallback, + useState, +} from 'react'; +import { + Node, + Position, + ReactFlowInstance, + useOnSelectionChange, + useReactFlow, +} from 'reactflow'; import { v4 as uuidv4 } from 'uuid'; export const useHandleDrag = () => { @@ -75,3 +87,52 @@ export const useShowDrawer = () => { showDrawer, }; }; + +export const useHandleSelectionChange = () => { + const [selectedNodes, setSelectedNodes] = useState([]); + const [selectedEdges, setSelectedEdges] = useState([]); + + useOnSelectionChange({ + onChange: ({ nodes, edges }) => { + setSelectedNodes(nodes.map((node) => node.id)); + setSelectedEdges(edges.map((edge) => edge.id)); + }, + }); + + return { selectedEdges, selectedNodes }; +}; + +export const useDeleteEdge = (selectedEdges: string[]) => { + const { setEdges } = useReactFlow(); + + const deleteEdge = useCallback(() => { + setEdges((edges) => + edges.filter((edge) => selectedEdges.every((x) => x !== edge.id)), + ); + }, [setEdges, selectedEdges]); + + return deleteEdge; +}; + +export const useHandleKeyUp = ( + selectedEdges: string[], + selectedNodes: string[], +) => { + const deleteEdge = useDeleteEdge(selectedEdges); + const handleKeyUp: KeyboardEventHandler = useCallback( + (e) => { + if (e.code === 'Delete') { + deleteEdge(); + } + }, + [deleteEdge], + ); + + return { handleKeyUp }; +}; + +export const useSaveGraph = () => { + const saveGraph = useCallback(() => {}, []); + + return { saveGraph }; +}; diff --git a/web/src/pages/flow/index.tsx b/web/src/pages/flow/index.tsx index 5aa32abd79..b1775b526b 100644 --- a/web/src/pages/flow/index.tsx +++ b/web/src/pages/flow/index.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { ReactFlowProvider } from 'reactflow'; import FlowCanvas from './canvas'; import Sider from './flow-sider'; +import FlowHeader from './header'; const { Content } = Layout; @@ -14,6 +15,7 @@ function RagFlow() { + diff --git a/web/src/pages/flow/mock.tsx b/web/src/pages/flow/mock.tsx index 77bdd41561..4b89164600 100644 --- a/web/src/pages/flow/mock.tsx +++ b/web/src/pages/flow/mock.tsx @@ -45,6 +45,100 @@ export const initialEdges = [ ]; export const dsl = { + graph: { + nodes: [ + { + id: 'begin', + type: 'textUpdater', + position: { + x: 50, + y: 200, + }, + data: { + label: 'Begin', + }, + sourcePosition: 'left', + targetPosition: 'right', + }, + { + id: 'Answer:China', + type: 'textUpdater', + position: { + x: 150, + y: 200, + }, + data: { + label: 'Answer', + }, + sourcePosition: 'left', + targetPosition: 'right', + }, + { + id: 'Retrieval:China', + type: 'textUpdater', + position: { + x: 250, + y: 200, + }, + data: { + label: 'Retrieval', + }, + sourcePosition: 'left', + targetPosition: 'right', + }, + { + id: 'Generate:China', + type: 'textUpdater', + position: { + x: 100, + y: 100, + }, + data: { + label: 'Generate', + }, + sourcePosition: 'left', + targetPosition: 'right', + }, + ], + edges: [ + { + id: '7facb53d-65c9-43b3-ac55-339c445d3891', + label: '', + source: 'begin', + target: 'Answer:China', + markerEnd: { + type: 'arrow', + }, + }, + { + id: '7ac83631-502d-410f-a6e7-bec6866a5e99', + label: '', + source: 'Generate:China', + target: 'Answer:China', + markerEnd: { + type: 'arrow', + }, + }, + { + id: '0aaab297-5779-43ed-9281-2c4d3741566f', + label: '', + source: 'Answer:China', + target: 'Retrieval:China', + markerEnd: { + type: 'arrow', + }, + }, + { + id: '3477f9f3-0a7d-400e-af96-a11ea7673183', + label: '', + source: 'Retrieval:China', + target: 'Generate:China', + markerEnd: { + type: 'arrow', + }, + }, + ], + }, components: { begin: { obj: { diff --git a/web/src/pages/flow/utils.ts b/web/src/pages/flow/utils.ts index 9da8ed02c8..eadd8e53e5 100644 --- a/web/src/pages/flow/utils.ts +++ b/web/src/pages/flow/utils.ts @@ -1,6 +1,6 @@ import { DSLComponents } from '@/interfaces/database/flow'; import dagre from 'dagre'; -import { Edge, Node, Position } from 'reactflow'; +import { Edge, MarkerType, Node, Position } from 'reactflow'; import { v4 as uuidv4 } from 'uuid'; const buildEdges = ( @@ -16,9 +16,12 @@ const buildEdges = ( allEdges.push({ id: uuidv4(), label: '', - type: 'step', + // type: 'step', source: source, target: target, + markerEnd: { + type: MarkerType.Arrow, + }, }); } });