feat: support workflow node fold (#2797)

* feat: support workflow node fold

* fix

* fix
This commit is contained in:
heheer
2024-09-26 15:44:04 +08:00
committed by GitHub
parent 5ad8c81ef3
commit 12d7ba5d73
15 changed files with 176 additions and 82 deletions

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useMemo } from 'react';
import { BezierEdge, getBezierPath, EdgeLabelRenderer, EdgeProps } from 'reactflow';
import { Flex } from '@chakra-ui/react';
import { Box, Flex } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { NodeOutputKeyEnum, RuntimeEdgeStatusEnum } from '@fastgpt/global/core/workflow/constants';
import { useContextSelector } from 'use-context-selector';
@@ -27,6 +27,16 @@ const ButtonEdge = (props: EdgeProps) => {
targetHandleId,
style
} = props;
const parentNode = useMemo(() => {
for (const node of nodeList) {
if ((node.nodeId === source || node.nodeId === target) && node.parentNodeId) {
return nodeList.find((parent) => parent.nodeId === node.parentNodeId);
}
}
return undefined;
}, [nodeList, source, target]);
const defaultZIndex = useMemo(
() => (nodeList.find((node) => node.nodeId === source && node.parentNodeId) ? 1001 : 0),
[nodeList, source]
@@ -127,55 +137,59 @@ const ButtonEdge = (props: EdgeProps) => {
})();
return (
<EdgeLabelRenderer>
<Flex
display={isHover || highlightEdge ? 'flex' : 'none'}
alignItems={'center'}
justifyContent={'center'}
position={'absolute'}
transform={`translate(-55%, -50%) translate(${labelX}px,${labelY}px)`}
pointerEvents={'all'}
w={'17px'}
h={'17px'}
bg={'white'}
borderRadius={'17px'}
cursor={'pointer'}
zIndex={9999}
onClick={() => onDelConnect(id)}
>
<MyIcon name={'core/workflow/closeEdge'} w={'100%'}></MyIcon>
</Flex>
{!isToolEdge && (
<Box hidden={parentNode?.isFolded}>
<Flex
display={isHover || highlightEdge ? 'flex' : 'none'}
alignItems={'center'}
justifyContent={'center'}
position={'absolute'}
transform={arrowTransform}
transform={`translate(-55%, -50%) translate(${labelX}px,${labelY}px)`}
pointerEvents={'all'}
w={highlightEdge ? '14px' : '10px'}
h={highlightEdge ? '14px' : '10px'}
// bg={'white'}
zIndex={highlightEdge ? 1000 : defaultZIndex}
w={'17px'}
h={'17px'}
bg={'white'}
borderRadius={'17px'}
cursor={'pointer'}
zIndex={9999}
onClick={() => onDelConnect(id)}
>
<MyIcon
name={'core/workflow/edgeArrow'}
w={'100%'}
color={edgeColor}
{...(highlightEdge
? {
fontWeight: 'bold'
}
: {})}
></MyIcon>
<MyIcon name={'core/workflow/closeEdge'} w={'100%'}></MyIcon>
</Flex>
)}
{!isToolEdge && (
<Flex
alignItems={'center'}
justifyContent={'center'}
position={'absolute'}
transform={arrowTransform}
pointerEvents={'all'}
w={highlightEdge ? '14px' : '10px'}
h={highlightEdge ? '14px' : '10px'}
// bg={'white'}
zIndex={highlightEdge ? 1000 : defaultZIndex}
>
<MyIcon
name={'core/workflow/edgeArrow'}
w={'100%'}
color={edgeColor}
{...(highlightEdge
? {
fontWeight: 'bold'
}
: {})}
></MyIcon>
</Flex>
)}
</Box>
</EdgeLabelRenderer>
);
}, [
parentNode?.isFolded,
isHover,
highlightEdge,
labelX,
labelY,
isToolEdge,
defaultZIndex,
edgeColor,
targetPosition,
newTargetX,
@@ -214,7 +228,8 @@ const ButtonEdge = (props: EdgeProps) => {
targetY={newTargetY}
style={{
...edgeStyle,
stroke: edgeColor
stroke: edgeColor,
display: parentNode?.isFolded ? 'none' : 'block'
}}
/>
);
@@ -227,7 +242,8 @@ const ButtonEdge = (props: EdgeProps) => {
source,
target,
style,
highlightEdge
highlightEdge,
parentNode?.isFolded
]);
return (

View File

@@ -1,5 +1,13 @@
import React, { useMemo } from 'react';
import { Background, ControlButton, MiniMap, Panel, useReactFlow, useViewport } from 'reactflow';
import React, { useCallback, useMemo } from 'react';
import {
Background,
ControlButton,
MiniMap,
MiniMapNodeProps,
Panel,
useReactFlow,
useViewport
} from 'reactflow';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../context';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
@@ -26,7 +34,8 @@ const FlowController = React.memo(function FlowController() {
canUndo,
workflowControlMode,
setWorkflowControlMode,
mouseInCanvas
mouseInCanvas,
nodeList
} = useContextSelector(WorkflowContext, (v) => v);
const { t } = useTranslation();
@@ -52,6 +61,20 @@ const FlowController = React.memo(function FlowController() {
zoomOut();
});
const MiniMapNode = useCallback(
({ x, y, width, height, color, id }: MiniMapNodeProps) => {
const node = nodeList.find((node) => node.nodeId === id);
const parentNode = nodeList.find((n) => n.nodeId === node?.parentNodeId);
if (parentNode?.isFolded) {
return null;
}
return <rect x={x} y={y} width={width} height={height} fill={color} />;
},
[nodeList]
);
const Render = useMemo(() => {
return (
<>
@@ -64,6 +87,7 @@ const FlowController = React.memo(function FlowController() {
boxShadow: '0px 0px 1px rgba(19, 51, 107, 0.10), 0px 4px 10px rgba(19, 51, 107, 0.10)'
}}
pannable
nodeComponent={MiniMapNode}
/>
<Panel
position={'bottom-right'}
@@ -180,9 +204,10 @@ const FlowController = React.memo(function FlowController() {
</>
);
}, [
MiniMapNode,
workflowControlMode,
isMac,
t,
isMac,
undo,
canUndo,
redo,

View File

@@ -13,7 +13,8 @@ import {
getNodesBounds,
Rect,
NodeRemoveChange,
NodeSelectionChange
NodeSelectionChange,
Position
} from 'reactflow';
import { EDGE_TYPE, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import 'reactflow/dist/style.css';
@@ -30,6 +31,8 @@ import {
Input_Template_Node_Width
} from '@fastgpt/global/core/workflow/template/input';
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { getHandleId } from '@fastgpt/global/core/workflow/utils';
import { IfElseResultEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
/*
Compute helper lines for snapping nodes to each other
@@ -367,8 +370,8 @@ export const useWorkflow = () => {
const checkNodeOverLoopNode = useMemoizedFn((node: Node) => {
if (!node) return;
// 获取所有与当前节点相交的节点
const intersections = getIntersectingNodes(node);
// 获取所有与当前节点相交的节点,不包含折叠的节点
const intersections = getIntersectingNodes(node).filter((node) => !node.data.isFolded);
// 获取所有与当前节点相交的节点中,类型为 loop 的节点
const parentNode = intersections.find((item) => item.type === FlowNodeTypeEnum.loop);
@@ -544,9 +547,19 @@ export const useWorkflow = () => {
/* connect */
const onConnectStart = useCallback(
(event: any, params: OnConnectStartParams) => {
if (!params.nodeId) return;
const sourceNode = nodes.find((node) => node.id === params.nodeId);
if (sourceNode?.data.isFolded) {
return onChangeNode({
nodeId: params.nodeId,
type: 'attr',
key: 'isFolded',
value: false
});
}
setConnectingEdge(params);
},
[setConnectingEdge]
[nodes, setConnectingEdge, onChangeNode]
);
const onConnectEnd = useCallback(() => {
setConnectingEdge(undefined);
@@ -581,7 +594,7 @@ export const useWorkflow = () => {
connect
});
},
[onConnect, t, toast]
[onConnect, t, toast, nodes]
);
/* edge */

View File

@@ -22,7 +22,7 @@ import { WorkflowContext } from '../../../context';
const NodeLoop = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const { t } = useTranslation();
const { nodeId, inputs, outputs } = data;
const { nodeId, inputs, outputs, isFolded } = data;
const { onChangeNode, nodeList } = useContextSelector(WorkflowContext, (v) => v);
const { nodeWidth, nodeHeight } = useMemo(() => {
@@ -53,14 +53,14 @@ const NodeLoop = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
return (
<NodeCard
selected={selected}
maxW={'full'}
minW={900}
minH={900}
w={nodeWidth}
h={nodeHeight}
menuForbid={{
copy: true
}}
maxW="full"
{...(!isFolded && {
minW: '900px',
minH: '900px',
w: nodeWidth,
h: nodeHeight
})}
menuForbid={{ copy: true }}
{...data}
>
<Container position={'relative'} flex={1}>

View File

@@ -94,17 +94,15 @@ const NodeLoopStart = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
<NodeCard
selected={selected}
{...data}
w={'420px'}
h={'176px'}
menuForbid={{
copy: true,
delete: true,
debug: true
}}
>
<Box px={4}>
<Box px={4} w={'420px'} h={'116px'}>
{!loopItemInputType ? (
<EmptyTip text={t('workflow:loop_start_tip')} py={0} mt={0} iconSize={'32px'} />
<EmptyTip text={t('workflow:loop_start_tip')} py={0} mt={4} iconSize={'32px'} />
) : (
<Box bg={'white'} borderRadius={'md'} overflow={'hidden'} border={'base'}>
<TableContainer>

View File

@@ -56,11 +56,6 @@ const NodeUserSelect = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
value: options.filter((input) => input.key !== item.key)
}
});
onChangeNode({
nodeId,
type: 'delOutput',
key: item.key
});
}}
/>
</MyTooltip>

View File

@@ -6,7 +6,13 @@ import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../../../context';
export const ConnectionSourceHandle = ({ nodeId }: { nodeId: string }) => {
export const ConnectionSourceHandle = ({
nodeId,
isFoldNode
}: {
nodeId: string;
isFoldNode?: boolean;
}) => {
const connectingEdge = useContextSelector(WorkflowContext, (ctx) => ctx.connectingEdge);
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
const edges = useContextSelector(WorkflowContext, (v) => v.edges);
@@ -26,8 +32,7 @@ export const ConnectionSourceHandle = ({ nodeId }: { nodeId: string }) => {
const rightTargetConnected = edges.some(
(edge) => edge.targetHandle === getHandleId(nodeId, 'target', Position.Right)
);
if (!node || !node?.sourceHandle?.right || rightTargetConnected) return null;
if (!isFoldNode && (!node || !node?.sourceHandle?.right || rightTargetConnected)) return null;
return (
<SourceHandle
@@ -102,7 +107,7 @@ export const ConnectionSourceHandle = ({ nodeId }: { nodeId: string }) => {
TopHandlee,
BottomHandlee
};
}, [connectingEdge, edges, nodeId, nodeList]);
}, [connectingEdge, edges, nodeId, nodeList, isFoldNode]);
return showSourceHandle ? (
<>

View File

@@ -32,8 +32,8 @@ const MySourceHandle = React.memo(function MySourceHandle({
const node = useMemo(() => nodes.find((node) => node.data.nodeId === nodeId), [nodes, nodeId]);
const connected = edges.some((edge) => edge.sourceHandle === handleId);
const nodeFolded = node?.data.isFolded && edges.some((edge) => edge.source === nodeId);
const nodeIsHover = hoverNodeId === nodeId;
const active = useMemo(
() => nodeIsHover || node?.selected || connectingEdge?.handleId === handleId,
[nodeIsHover, node?.selected, connectingEdge, handleId]
@@ -73,7 +73,7 @@ const MySourceHandle = React.memo(function MySourceHandle({
};
}
if (connected) {
if (connected || nodeFolded) {
return {
styles: {
...connectedStyle,
@@ -89,7 +89,7 @@ const MySourceHandle = React.memo(function MySourceHandle({
styles: undefined,
showAddIcon: false
};
}, [active, connected, highlightStyle, translateStr, transform, connectedStyle]);
}, [active, connected, nodeFolded, highlightStyle, translateStr, transform, connectedStyle]);
const RenderHandle = useMemo(() => {
return (

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
import { Box, Button, Card, Flex, Image } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import Avatar from '@fastgpt/web/components/common/Avatar';
@@ -61,7 +61,8 @@ const NodeCard = (props: Props) => {
menuForbid,
isTool = false,
isError = false,
debugResult
debugResult,
isFolded
} = props;
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
@@ -81,7 +82,12 @@ const NodeCard = (props: Props) => {
[isTool, nodeList]
);
const node = nodeList.find((node) => node.nodeId === nodeId);
const { node, parentNode } = useMemo(() => {
const node = nodeList.find((node) => node.nodeId === nodeId);
const parentNode = nodeList.find((n) => n.nodeId === node?.parentNodeId);
return { node, parentNode };
}, [nodeList, nodeId]);
const { openConfirm: onOpenConfirmSync, ConfirmModal: ConfirmSyncModal } = useConfirm({
content: t('app:module.Confirm Sync')
});
@@ -155,6 +161,31 @@ const NodeCard = (props: Props) => {
{/* avatar and name */}
<Flex alignItems={'center'}>
<Box
mr={2}
cursor={'pointer'}
rounded={'sm'}
_hover={{ bg: 'myGray.200' }}
onClick={() => {
onChangeNode({
nodeId,
type: 'attr',
key: 'isFolded',
value: !isFolded
});
}}
>
{!isFolded ? (
<MyIcon name={'core/chat/chevronDown'} w={'24px'} h={'24px'} color={'myGray.500'} />
) : (
<MyIcon
name={'core/chat/chevronRight'}
w={'24px'}
h={'24px'}
color={'myGray.500'}
/>
)}
</Box>
<Avatar src={avatar} borderRadius={'sm'} objectFit={'contain'} w={'30px'} h={'30px'} />
<Box ml={3} fontSize={'md'} fontWeight={'medium'}>
{t(name as any)}
@@ -250,19 +281,21 @@ const NodeCard = (props: Props) => {
ConfirmSyncModal,
onOpenCustomTitleModal,
onChangeNode,
toast
toast,
isFolded
]);
const RenderHandle = useMemo(() => {
return (
<>
<ConnectionSourceHandle nodeId={nodeId} />
<ConnectionSourceHandle nodeId={nodeId} isFoldNode={isFolded} />
<ConnectionTargetHandle nodeId={nodeId} />
</>
);
}, [nodeId]);
}, [nodeId, isFolded]);
return (
<Flex
hidden={parentNode?.isFolded}
flexDirection={'column'}
minW={minW}
maxW={maxW}
@@ -298,7 +331,7 @@ const NodeCard = (props: Props) => {
>
<NodeDebugResponse nodeId={nodeId} debugResult={debugResult} />
{Header}
{children}
{!isFolded && children}
{RenderHandle}
<EditTitleModal maxLength={20} />

View File

@@ -561,9 +561,9 @@ const WorkflowContextProvider = ({
const checkResults = checkWorkflowNodeAndConnection({ nodes, edges });
if (!checkResults) {
const storeNodes = uiWorkflow2StoreWorkflow({ nodes, edges });
const storeWorkflow = uiWorkflow2StoreWorkflow({ nodes, edges });
return storeNodes;
return storeWorkflow;
} else if (!hideTip) {
checkResults.forEach((nodeId) => onUpdateNodeError(nodeId, true));
toast({

View File

@@ -25,7 +25,8 @@ export const uiWorkflow2StoreWorkflow = ({
version: item.data.version,
inputs: item.data.inputs,
outputs: item.data.outputs,
pluginId: item.data.pluginId
pluginId: item.data.pluginId,
isFolded: item.data.isFolded
}));
// get all handle
@@ -49,6 +50,8 @@ export const uiWorkflow2StoreWorkflow = ({
(item) => {
// Not in react flow page
if (!reactFlowViewport) return true;
const currentSourceNode = nodes.find((node) => node.data.nodeId === item.source);
if (currentSourceNode?.data.isFolded) return true;
return handleIdList.includes(item.sourceHandle) && handleIdList.includes(item.targetHandle);
}
);

View File

@@ -532,7 +532,8 @@ export const compareSnapshot = (
name: node.data.name,
intro: node.data.intro,
avatar: node.data.avatar,
version: node.data.version
version: node.data.version,
isFolded: node.data.isFolded
}
}));
};