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

@@ -104,6 +104,7 @@ export type FlowNodeItemType = FlowNodeTemplateType & {
response?: ChatHistoryItemResType;
isExpired?: boolean;
};
isFolded?: boolean;
};
// store node type

View File

@@ -123,6 +123,7 @@ export const iconPaths = {
'core/chat/chatLight': () => import('./icons/core/chat/chatLight.svg'),
'core/chat/chatModelTag': () => import('./icons/core/chat/chatModelTag.svg'),
'core/chat/chevronDown': () => import('./icons/core/chat/chevronDown.svg'),
'core/chat/chevronRight': () => import('./icons/core/chat/chevronRight.svg'),
'core/chat/chevronSelector': () => import('./icons/core/chat/chevronSelector.svg'),
'core/chat/chevronUp': () => import('./icons/core/chat/chevronUp.svg'),
'core/chat/export/pdf': () => import('./icons/core/chat/export/pdf.svg'),

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.19277 5.29289C8.80224 5.68342 8.80224 6.31658 9.19277 6.70711L14.4857 12L9.19277 17.2929C8.80224 17.6834 8.80224 18.3166 9.19277 18.7071C9.58329 19.0976 10.2165 19.0976 10.607 18.7071L16.607 12.7071C16.9975 12.3166 16.9975 11.6834 16.607 11.2929L10.607 5.29289C10.2165 4.90237 9.58329 4.90237 9.19277 5.29289Z" />
</svg>

After

Width:  |  Height:  |  Size: 448 B

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
}
}));
};