mirror of
https://github.com/labring/FastGPT.git
synced 2025-07-23 21:13:50 +00:00
feat: support workflow node fold (#2797)
* feat: support workflow node fold * fix * fix
This commit is contained in:
@@ -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 (
|
||||
|
@@ -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,
|
||||
|
@@ -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 */
|
||||
|
@@ -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}>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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 ? (
|
||||
<>
|
||||
|
@@ -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 (
|
||||
|
@@ -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} />
|
||||
|
@@ -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({
|
||||
|
@@ -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);
|
||||
}
|
||||
);
|
||||
|
@@ -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
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
Reference in New Issue
Block a user