Workflow experience optimization (#2681)

* perf: workflow handle connect

* feat: pan drag mode

* perf: workflow keyboard and touchTable adapt

* perf: teaxtarea no wheel

* remove render error
This commit is contained in:
Archer
2024-09-12 13:59:44 +08:00
parent 22bb4c1e2e
commit fde1618af2
22 changed files with 250 additions and 189 deletions

View File

@@ -68,7 +68,13 @@ const AIModelSelector = ({ list, onchange, disableTip, ...props }: Props) => {
return (
<MyTooltip label={disableTip}>
<MySelect isDisabled={!!disableTip} list={expandList} {...props} onchange={onSelect} />
<MySelect
className="nowheel"
isDisabled={!!disableTip}
list={expandList}
{...props}
onchange={onSelect}
/>
</MyTooltip>
);
};

View File

@@ -33,10 +33,7 @@ const AppCard = ({
const { appDetail, onOpenInfoEdit, onOpenTeamTagModal, onDelApp, currentTab } =
useContextSelector(AppContext, (v) => v);
const { historiesDefaultData, onSaveWorkflow, isSaving } = useContextSelector(
WorkflowContext,
(v) => v
);
const { historiesDefaultData } = useContextSelector(WorkflowContext, (v) => v);
const { isOpen: isOpenImport, onOpen: onOpenImport, onClose: onCloseImport } = useDisclosure();

View File

@@ -1,55 +1,58 @@
import React, { useEffect, useMemo } from 'react';
import React, { useMemo } from 'react';
import { Background, ControlButton, MiniMap, Panel, useReactFlow, useViewport } from 'reactflow';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../context';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { Box } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { useTranslation } from 'next-i18next';
import styles from './index.module.scss';
import { maxZoom, minZoom } from '../index';
import { useKeyPress } from 'ahooks';
const buttonStyle = {
border: 'none',
borderRadius: '6px',
padding: '7px'
};
const FlowController = React.memo(function FlowController() {
const { fitView, zoomIn, zoomOut } = useReactFlow();
const { zoom } = useViewport();
const { undo, redo, canRedo, canUndo } = useContextSelector(WorkflowContext, (v) => v);
const {
undo,
redo,
canRedo,
canUndo,
workflowControlMode,
setWorkflowControlMode,
mouseInCanvas
} = useContextSelector(WorkflowContext, (v) => v);
const { t } = useTranslation();
const isMac = !window ? false : window.navigator.userAgent.toLocaleLowerCase().includes('mac');
useEffect(() => {
const keyDownHandler = (event: KeyboardEvent) => {
if (
(event.key === 'z' || event.key === 'Z') &&
(event.ctrlKey || event.metaKey) &&
event.shiftKey
) {
event.preventDefault();
redo();
} else if (event.key === 'z' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
undo();
} else if ((event.key === '=' || event.key === '+') && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
zoomIn();
} else if (event.key === '-' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
zoomOut();
}
};
document.addEventListener('keydown', keyDownHandler);
return () => {
document.removeEventListener('keydown', keyDownHandler);
};
}, [undo, redo, zoomIn, zoomOut]);
const buttonStyle = {
border: 'none',
borderRadius: '6px',
padding: '7px'
};
// Controller shortcut key
useKeyPress(['ctrl.z', 'meta.z'], (e) => {
e.preventDefault();
if (!mouseInCanvas) return;
undo();
});
useKeyPress(['ctrl.shift.z', 'meta.shift.z', 'ctrl.y', 'meta.y'], (e) => {
e.preventDefault();
if (!mouseInCanvas) return;
redo();
});
useKeyPress(['ctrl.add', 'meta.add', 'ctrl.equalsign', 'meta.equalsign'], (e) => {
e.preventDefault();
if (!mouseInCanvas) return;
zoomIn();
});
useKeyPress(['ctrl.dash', 'meta.dash'], (e) => {
e.preventDefault();
if (!mouseInCanvas) return;
zoomOut();
});
const Render = useMemo(() => {
return (
@@ -79,6 +82,35 @@ const FlowController = React.memo(function FlowController() {
'0px 0px 1px 0px rgba(19, 51, 107, 0.20), 0px 12px 16px -4px rgba(19, 51, 107, 0.20)'
}}
>
{/* Control Mode */}
<MyTooltip
label={
workflowControlMode === 'select'
? t('workflow:pan_priority')
: t('workflow:mouse_priority')
}
>
<ControlButton
onClick={() => {
setWorkflowControlMode(workflowControlMode === 'select' ? 'drag' : 'select');
}}
style={{
...buttonStyle
}}
className={`${styles.customControlButton}`}
>
<MyIcon
name={
workflowControlMode === 'select'
? 'core/workflow/touchTable'
: 'core/workflow/mouse'
}
/>
</ControlButton>
</MyTooltip>
<Box w="1px" h="20px" bg="gray.200" mx={1.5}></Box>
{/* undo */}
<MyTooltip label={isMac ? t('common:common.undo_tip_mac') : t('common:common.undo_tip')}>
<ControlButton
@@ -107,7 +139,7 @@ const FlowController = React.memo(function FlowController() {
{/* zoom out */}
<MyTooltip
label={isMac ? t('common:common.zoomout_tip_mac') : t('common:common.zoomout_tip')}
label={isMac ? t('common:common.zoomin_tip_mac') : t('common:common.zoomin_tip')}
>
<ControlButton
onClick={() => zoomOut()}
@@ -121,7 +153,7 @@ const FlowController = React.memo(function FlowController() {
{/* zoom in */}
<MyTooltip
label={isMac ? t('common:common.zoomin_tip_mac') : t('common:common.zoomin_tip')}
label={isMac ? t('common:common.zoomout_tip_mac') : t('common:common.zoomout_tip')}
>
<ControlButton
onClick={() => zoomIn()}
@@ -149,7 +181,20 @@ const FlowController = React.memo(function FlowController() {
<Background />
</>
);
}, [isMac, t, undo, buttonStyle, canUndo, redo, canRedo, zoom, zoomOut, zoomIn, fitView]);
}, [
workflowControlMode,
isMac,
t,
undo,
canUndo,
redo,
canRedo,
zoom,
setWorkflowControlMode,
zoomOut,
zoomIn,
fitView
]);
return Render;
});

View File

@@ -3,7 +3,8 @@ import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useTranslation } from 'next-i18next';
type FormType = {
versionName: string;
isPublish: boolean | undefined;

View File

@@ -1,20 +1,21 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback } from 'react';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { useCopyData } from '@/web/common/hooks/useCopyData';
import { useTranslation } from 'next-i18next';
import { Node } from 'reactflow';
import { Node, useKeyPress } from 'reactflow';
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext, getWorkflowStore } from '../../context';
import { useWorkflowUtils } from './useUtils';
import { useKeyPress as useKeyPressEffect } from 'ahooks';
export const useKeyboard = () => {
const { t } = useTranslation();
const { setNodes, onSaveWorkflow } = useContextSelector(WorkflowContext, (v) => v);
const { setNodes, mouseInCanvas } = useContextSelector(WorkflowContext, (v) => v);
const { copyData } = useCopyData();
const { computedNewNodeName } = useWorkflowUtils();
const [isDowningCtrl, setIsDowningCtrl] = useState(false);
const isDowningCtrl = useKeyPress(['Meta', 'Control']);
const hasInputtingElement = useCallback(() => {
const activeElement = document.activeElement;
@@ -84,48 +85,20 @@ export const useKeyboard = () => {
} catch (error) {}
}, [computedNewNodeName, hasInputtingElement, setNodes]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey) {
setIsDowningCtrl(true);
switch (event.key) {
case 'c':
onCopy();
break;
case 'v':
onParse();
break;
case 's':
event.preventDefault();
onSaveWorkflow();
break;
default:
break;
}
}
},
[onCopy, onParse, onSaveWorkflow]
);
const handleKeyUp = useCallback((event: KeyboardEvent) => {
setIsDowningCtrl(false);
}, []);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
useEffect(() => {
document.addEventListener('keyup', handleKeyUp);
return () => {
document.removeEventListener('keyup', handleKeyUp);
};
}, [handleKeyUp]);
useKeyPressEffect(['ctrl.c', 'meta.c'], (e) => {
e.preventDefault();
if (!mouseInCanvas) return;
onCopy();
});
useKeyPressEffect(['ctrl.v', 'meta.v'], (e) => {
e.preventDefault();
if (!mouseInCanvas) return;
onParse();
});
useKeyPressEffect(['ctrl.s', 'meta.s'], (e) => {
e.preventDefault();
if (!mouseInCanvas) return;
});
return {
isDowningCtrl

View File

@@ -1,5 +1,5 @@
import React from 'react';
import ReactFlow, { NodeProps, ReactFlowProvider } from 'reactflow';
import ReactFlow, { NodeProps, ReactFlowProvider, SelectionMode } from 'reactflow';
import { Box, IconButton, useDisclosure } from '@chakra-ui/react';
import { SmallCloseIcon } from '@chakra-ui/icons';
import { EDGE_TYPE, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
@@ -59,7 +59,10 @@ const edgeTypes = {
};
const Workflow = () => {
const { nodes, edges, reactFlowWrapper } = useContextSelector(WorkflowContext, (v) => v);
const { nodes, edges, reactFlowWrapper, workflowControlMode } = useContextSelector(
WorkflowContext,
(v) => v
);
const {
handleNodesChange,
@@ -124,6 +127,7 @@ const Workflow = () => {
connectionLineStyle={connectionLineStyle}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionRadius={50}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgeChange}
onConnect={customOnConnect}
@@ -131,6 +135,17 @@ const Workflow = () => {
onConnectEnd={onConnectEnd}
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseLeave={onEdgeMouseLeave}
panOnScrollSpeed={2}
{...(workflowControlMode === 'select'
? {
selectionMode: SelectionMode.Full,
selectNodesOnDrag: false,
selectionOnDrag: true,
selectionKeyCode: null,
panOnDrag: false,
panOnScroll: true
}
: {})}
>
<FlowController />
<HelperLines horizontal={helperLineHorizontal} vertical={helperLineVertical} />

View File

@@ -35,7 +35,7 @@ const NodeTools = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
/>
</Box>
<ToolSourceHandle nodeId={nodeId} />
<ToolSourceHandle />
</Box>
</NodeCard>
);

View File

@@ -4,15 +4,16 @@ import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { useTranslation } from 'next-i18next';
import { Connection, Handle, Position } from 'reactflow';
import { useCallback, useMemo } from 'react';
import { getHandleId } from '@fastgpt/global/core/workflow/utils';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '@/pages/app/detail/components/WorkflowComponents/context';
const handleSize = '14px';
const handleSize = '16px';
type ToolHandleProps = BoxProps & {
nodeId: string;
show: boolean;
};
export const ToolTargetHandle = ({ nodeId }: ToolHandleProps) => {
export const ToolTargetHandle = ({ show, nodeId }: ToolHandleProps) => {
const { t } = useTranslation();
const connectingEdge = useContextSelector(WorkflowContext, (ctx) => ctx.connectingEdge);
const edges = useContextSelector(WorkflowContext, (v) => v.edges);
@@ -22,44 +23,45 @@ export const ToolTargetHandle = ({ nodeId }: ToolHandleProps) => {
const connected = edges.some((edge) => edge.target === nodeId && edge.targetHandle === handleId);
// if top handle is connected, return null
const hidden =
!connected &&
(connectingEdge?.handleId !== NodeOutputKeyEnum.selectedTools ||
edges.some((edge) => edge.targetHandle === getHandleId(nodeId, 'target', 'top')));
const showHandle =
connected || (show && connectingEdge?.handleId === NodeOutputKeyEnum.selectedTools);
const Render = useMemo(() => {
return hidden ? null : (
<MyTooltip label={t('common:core.workflow.tool.Handle')} shouldWrapChildren={false}>
<Handle
style={{
borderRadius: '0',
backgroundColor: 'transparent',
border: 'none',
width: handleSize,
height: handleSize
}}
type="target"
id={handleId}
position={Position.Top}
>
<Box
className="flow-handle"
w={handleSize}
h={handleSize}
border={'4px solid #8774EE'}
transform={'translate(0,-30%) rotate(45deg)'}
pointerEvents={'none'}
visibility={'visible'}
/>
</Handle>
</MyTooltip>
return (
<Handle
style={{
borderRadius: '0',
backgroundColor: 'transparent',
border: 'none',
width: handleSize,
height: handleSize,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
top: '-10px',
...(showHandle ? {} : { visibility: 'hidden' })
}}
type="target"
id={handleId}
position={Position.Top}
isConnectableStart={false}
>
<Box
className="flow-handle"
w={handleSize}
h={handleSize}
border={'4px solid #8774EE'}
transform={'translate(0,0) rotate(45deg)'}
pointerEvents={'none'}
/>
</Handle>
);
}, [handleId, hidden, t]);
}, [handleId, showHandle]);
return Render;
};
export const ToolSourceHandle = ({ nodeId }: ToolHandleProps) => {
export const ToolSourceHandle = () => {
const { t } = useTranslation();
const setEdges = useContextSelector(WorkflowContext, (v) => v.setEdges);
@@ -86,7 +88,11 @@ export const ToolSourceHandle = ({ nodeId }: ToolHandleProps) => {
backgroundColor: 'transparent',
border: 'none',
width: handleSize,
height: handleSize
height: handleSize,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bottom: '-10px'
}}
type="source"
id={NodeOutputKeyEnum.selectedTools}
@@ -97,7 +103,7 @@ export const ToolSourceHandle = ({ nodeId }: ToolHandleProps) => {
w={handleSize}
h={handleSize}
border={'4px solid #8774EE'}
transform={'translate(0,30%) rotate(45deg)'}
transform={'translate(0,0) rotate(45deg)'}
pointerEvents={'none'}
/>
</Handle>

View File

@@ -218,7 +218,7 @@ const MyTargetHandle = React.memo(function MyTargetHandle({
return (
<Handle
style={
!!styles && showHandle
styles && showHandle
? styles
: {
visibility: 'hidden',
@@ -226,10 +226,10 @@ const MyTargetHandle = React.memo(function MyTargetHandle({
...handleSize
}
}
isConnectableEnd={styles && showHandle}
type="target"
id={handleId}
position={position}
isConnectableStart={false}
></Handle>
);
}, [styles, showHandle, transform, handleId, position]);

View File

@@ -145,7 +145,7 @@ const NodeCard = (props: Props) => {
{/* debug */}
<Box px={4} py={3}>
{/* tool target handle */}
{showToolHandle && <ToolTargetHandle nodeId={nodeId} />}
<ToolTargetHandle show={showToolHandle} nodeId={nodeId} />
{/* avatar and name */}
<Flex alignItems={'center'}>

View File

@@ -54,6 +54,7 @@ const JsonEditor = ({ inputs = [], item, nodeId }: RenderInputProps) => {
const Render = useMemo(() => {
return (
<JSONEditor
className="nowheel"
bg={'white'}
borderRadius={'sm'}
placeholder={t(item.placeholder as any)}

View File

@@ -10,6 +10,7 @@ const SelectRender = ({ item, nodeId }: RenderInputProps) => {
const Render = useMemo(() => {
return (
<MySelect
className="nowheel"
width={'100%'}
value={item.value}
list={item.list || []}

View File

@@ -6,7 +6,7 @@ import {
storeNode2FlowNode
} from '@/web/core/workflow/utils';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { NodeOutputKeyEnum, RuntimeEdgeStatusEnum } from '@fastgpt/global/core/workflow/constants';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
import { FlowNodeItemType, StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
@@ -48,7 +48,8 @@ import { useTranslation } from 'next-i18next';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { formatTime2YMDHMS, formatTime2YMDHMW } from '@fastgpt/global/common/string/time';
import type { InitProps } from '@/pages/app/detail/components/PublishHistoriesSlider';
import { cloneDeep, isEqual } from 'lodash';
import { cloneDeep } from 'lodash';
import { SetState } from 'ahooks/lib/createUseStorageState';
type OnChange<ChangesType> = (changes: ChangesType[]) => void;
@@ -64,6 +65,7 @@ type WorkflowContextType = {
basicNodeTemplates: FlowNodeTemplateType[];
filterAppIds?: string[];
reactFlowWrapper: React.RefObject<HTMLDivElement> | null;
mouseInCanvas: boolean;
// nodes
nodes: Node<FlowNodeItemType, string | undefined>[];
@@ -145,8 +147,6 @@ type WorkflowContextType = {
edges: StoreEdgeItemType[];
}
| undefined;
onSaveWorkflow: () => Promise<null | undefined>;
isSaving: boolean;
// debug
workflowDebugData:
@@ -182,6 +182,10 @@ type WorkflowContextType = {
| undefined
>
>;
//
workflowControlMode?: 'drag' | 'select';
setWorkflowControlMode: (value?: SetState<'drag' | 'select'> | undefined) => void;
};
type DebugDataType = {
@@ -192,7 +196,6 @@ type DebugDataType = {
};
export const WorkflowContext = createContext<WorkflowContextType>({
isSaving: false,
setConnectingEdge: function (
value: React.SetStateAction<OnConnectStartParams | undefined>
): void {
@@ -205,6 +208,7 @@ export const WorkflowContext = createContext<WorkflowContextType>({
reactFlowWrapper: null,
nodes: [],
nodeList: [],
mouseInCanvas: false,
setNodes: function (
value: React.SetStateAction<Node<FlowNodeItemType, string | undefined>[]>
): void {
@@ -295,9 +299,6 @@ export const WorkflowContext = createContext<WorkflowContextType>({
| undefined {
throw new Error('Function not implemented.');
},
onSaveWorkflow: function (): Promise<null | undefined> {
throw new Error('Function not implemented.');
},
historiesDefaultData: undefined,
setHistoriesDefaultData: function (value: React.SetStateAction<InitProps | undefined>): void {
throw new Error('Function not implemented.');
@@ -323,7 +324,11 @@ export const WorkflowContext = createContext<WorkflowContextType>({
throw new Error('Function not implemented.');
},
canRedo: false,
canUndo: false
canUndo: false,
workflowControlMode: 'drag',
setWorkflowControlMode: function (value?: SetState<'drag' | 'select'> | undefined): void {
throw new Error('Function not implemented.');
}
});
const WorkflowContextProvider = ({
@@ -337,9 +342,34 @@ const WorkflowContextProvider = ({
const { toast } = useToast();
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const { appDetail, setAppDetail, updateAppDetail } = useContextSelector(AppContext, (v) => v);
const { appDetail, setAppDetail } = useContextSelector(AppContext, (v) => v);
const appId = appDetail._id;
const [workflowControlMode, setWorkflowControlMode] = useLocalStorageState<'drag' | 'select'>(
'workflow-control-mode',
{
defaultValue: 'select',
listenStorageChange: true
}
);
// Mouse in canvas
const [mouseInCanvas, setMouseInCanvas] = useState(false);
useEffect(() => {
const handleMouseInCanvas = (e: MouseEvent) => {
setMouseInCanvas(true);
};
const handleMouseOutCanvas = (e: MouseEvent) => {
setMouseInCanvas(false);
};
reactFlowWrapper?.current?.addEventListener('mouseenter', handleMouseInCanvas);
reactFlowWrapper?.current?.addEventListener('mouseleave', handleMouseOutCanvas);
return () => {
reactFlowWrapper?.current?.removeEventListener('mouseenter', handleMouseInCanvas);
reactFlowWrapper?.current?.removeEventListener('mouseleave', handleMouseOutCanvas);
};
}, [reactFlowWrapper?.current]);
/* edge */
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [hoverEdgeId, setHoverEdgeId] = useState<string>();
@@ -586,33 +616,6 @@ const WorkflowContextProvider = ({
return storeNodes;
});
/* save workflow */
const { runAsync: onSaveWorkflow, loading: isSaving } = useRequest2(async () => {
const { nodes } = await getWorkflowStore();
// version preview / debug mode, not save
if (appDetail.version !== 'v2' || historiesDefaultData || isSaving || !!workflowDebugData)
return;
const storeWorkflow = uiWorkflow2StoreWorkflow({ nodes, edges });
// check valid
if (storeWorkflow.nodes.length === 0 || storeWorkflow.edges.length === 0) {
return;
}
try {
await updateAppDetail({
...storeWorkflow,
chatConfig: appDetail.chatConfig,
//@ts-ignore
version: 'v2'
});
} catch (error) {}
return null;
});
/* debug */
const [workflowDebugData, setWorkflowDebugData] = useState<DebugDataType>();
const onNextNodeDebug = useCallback(
@@ -944,6 +947,10 @@ const WorkflowContextProvider = ({
appId,
reactFlowWrapper,
basicNodeTemplates,
workflowControlMode,
setWorkflowControlMode,
mouseInCanvas,
// node
nodes,
setNodes,
@@ -984,8 +991,6 @@ const WorkflowContextProvider = ({
initData,
flowData2StoreDataAndCheck,
flowData2StoreData,
onSaveWorkflow,
isSaving,
// debug
workflowDebugData,

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import NextHead from '@/components/common/NextHead';
import { delChatRecordById, getChatHistories, getTeamChatInfo } from '@/web/core/chat/api';
import { delChatRecordById, getTeamChatInfo } from '@/web/core/chat/api';
import { useRouter } from 'next/router';
import { Box, Flex, Drawer, DrawerOverlay, DrawerContent, useTheme } from '@chakra-ui/react';
import { useToast } from '@fastgpt/web/hooks/useToast';
@@ -11,7 +11,6 @@ import ChatHistorySlider from './components/ChatHistorySlider';
import ChatHeader from './components/ChatHeader';
import { serviceSideProps } from '@/web/common/utils/i18n';
import { useTranslation } from 'next-i18next';
import { checkChatSupportSelectFileByChatModels } from '@/web/core/chat/utils';
import { customAlphabet } from 'nanoid';
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 12);
import ChatBox from '@/components/core/chat/ChatContainer/ChatBox';